Compare commits
95 Commits
should-pro
...
vmdk-explo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
737b40e668 | ||
|
|
c5e8bee5f0 | ||
|
|
67fda0bd42 | ||
|
|
f9cd8d1f2b | ||
|
|
f996feb9cb | ||
|
|
1caef5f7fc | ||
|
|
12ad942ff9 | ||
|
|
7314521fcb | ||
|
|
405f1d2bbf | ||
|
|
ac8b03bc11 | ||
|
|
eb06c8a0be | ||
|
|
8a8757072b | ||
|
|
d0c7284d3b | ||
|
|
37b38a5af1 | ||
|
|
a609772415 | ||
|
|
0876de77f5 | ||
|
|
19b0d5f584 | ||
|
|
29a38cdf1a | ||
|
|
960c569e86 | ||
|
|
fa183fc97e | ||
|
|
a1d63118c0 | ||
|
|
f95a20173c | ||
|
|
b82d0fadc3 | ||
|
|
0635b3316e | ||
|
|
113235aec3 | ||
|
|
3921401e96 | ||
|
|
2e514478a4 | ||
|
|
b3d53b230e | ||
|
|
45dcb914ba | ||
|
|
711087b686 | ||
|
|
b100a59d1d | ||
|
|
109b2b0055 | ||
|
|
9dda99eb20 | ||
|
|
fa0f75b474 | ||
|
|
2d93e0d4be | ||
|
|
fe6406336d | ||
|
|
1037d44089 | ||
|
|
a8c3669f43 | ||
|
|
d91753aa82 | ||
|
|
b548514d44 | ||
|
|
ba782d2698 | ||
|
|
0552dc23a5 | ||
|
|
574bbbf5ff | ||
|
|
df11a92cdb | ||
|
|
33ae59adf7 | ||
|
|
e0a115b41d | ||
|
|
f838d6c179 | ||
|
|
6c3229f517 | ||
|
|
6973928b1a | ||
|
|
a5daba2a4d | ||
|
|
40ef83416e | ||
|
|
8518146455 | ||
|
|
d58f563de5 | ||
|
|
ad2454adab | ||
|
|
1f32557743 | ||
|
|
e95aae2129 | ||
|
|
9176171f20 | ||
|
|
d4f2249a4d | ||
|
|
e0b4069c17 | ||
|
|
6b25a21151 | ||
|
|
716dc45d85 | ||
|
|
57850230c8 | ||
|
|
362d597031 | ||
|
|
e89b84b37b | ||
|
|
ae6f6bf536 | ||
|
|
6f765bdd6f | ||
|
|
1982c6e6e6 | ||
|
|
527dceb43f | ||
|
|
f5a3d68d07 | ||
|
|
6c904fbc96 | ||
|
|
295036a1e3 | ||
|
|
5601d61b49 | ||
|
|
1c35c1a61a | ||
|
|
4143014466 | ||
|
|
90fea69b7e | ||
|
|
625663d619 | ||
|
|
403afc7aaf | ||
|
|
d295524c3c | ||
|
|
5eb4294e70 | ||
|
|
90598522a6 | ||
|
|
519fa1bcf8 | ||
|
|
7b0e5afe37 | ||
|
|
0b6b3a47a2 | ||
|
|
75db810508 | ||
|
|
2f52c564f5 | ||
|
|
011d582b80 | ||
|
|
32d21b2308 | ||
|
|
45971ca622 | ||
|
|
f3a09f2dad | ||
|
|
552a9c7b9f | ||
|
|
ed34d9cbc0 | ||
|
|
187ee99931 | ||
|
|
ff78dd8f7c | ||
|
|
b0eadb8ea4 | ||
|
|
a95754715a |
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"dependencies": {
|
||||
"@vates/multi-key-map": "^0.1.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.1.1"
|
||||
"vhd-lib": "^4.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"object-hash": "^2.0.1"
|
||||
},
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.29.0",
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/backups": "^0.29.4",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox":"^0.21.0"
|
||||
"promise-toolbox": "^0.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "0.7.8",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -232,21 +232,23 @@ class RemoteAdapter {
|
||||
return promise
|
||||
}
|
||||
|
||||
#removeVmBackupsFromCache(backups) {
|
||||
for (const [dir, filenames] of Object.entries(
|
||||
groupBy(
|
||||
backups.map(_ => _._filename),
|
||||
dirname
|
||||
)
|
||||
)) {
|
||||
// detached async action, will not reject
|
||||
this._updateCache(dir + '/cache.json.gz', backups => {
|
||||
for (const filename of filenames) {
|
||||
debug('removing cache entry', { entry: filename })
|
||||
delete backups[filename]
|
||||
}
|
||||
})
|
||||
}
|
||||
async #removeVmBackupsFromCache(backups) {
|
||||
await asyncEach(
|
||||
Object.entries(
|
||||
groupBy(
|
||||
backups.map(_ => _._filename),
|
||||
dirname
|
||||
)
|
||||
),
|
||||
([dir, filenames]) =>
|
||||
// will not reject
|
||||
this._updateCache(dir + '/cache.json.gz', backups => {
|
||||
for (const filename of filenames) {
|
||||
debug('removing cache entry', { entry: filename })
|
||||
delete backups[filename]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async deleteDeltaVmBackups(backups) {
|
||||
@@ -255,7 +257,7 @@ class RemoteAdapter {
|
||||
// this will delete the json, unused VHDs will be detected by `cleanVm`
|
||||
await asyncMapSettled(backups, ({ _filename }) => handler.unlink(_filename))
|
||||
|
||||
this.#removeVmBackupsFromCache(backups)
|
||||
await this.#removeVmBackupsFromCache(backups)
|
||||
}
|
||||
|
||||
async deleteMetadataBackup(backupId) {
|
||||
@@ -284,7 +286,7 @@ class RemoteAdapter {
|
||||
Promise.all([handler.unlink(_filename), handler.unlink(resolveRelativeFromFile(_filename, xva))])
|
||||
)
|
||||
|
||||
this.#removeVmBackupsFromCache(backups)
|
||||
await this.#removeVmBackupsFromCache(backups)
|
||||
}
|
||||
|
||||
deleteVmBackup(file) {
|
||||
@@ -508,7 +510,7 @@ class RemoteAdapter {
|
||||
return `${BACKUP_DIR}/${vmUuid}/cache.json.gz`
|
||||
}
|
||||
|
||||
async #readCache(path) {
|
||||
async _readCache(path) {
|
||||
try {
|
||||
return JSON.parse(await fromCallback(zlib.gunzip, await this.handler.readFile(path)))
|
||||
} catch (error) {
|
||||
@@ -521,15 +523,15 @@ class RemoteAdapter {
|
||||
_updateCache = synchronized.withKey()(this._updateCache)
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
async _updateCache(path, fn) {
|
||||
const cache = await this.#readCache(path)
|
||||
const cache = await this._readCache(path)
|
||||
if (cache !== undefined) {
|
||||
fn(cache)
|
||||
|
||||
await this.#writeCache(path, cache)
|
||||
await this._writeCache(path, cache)
|
||||
}
|
||||
}
|
||||
|
||||
async #writeCache(path, data) {
|
||||
async _writeCache(path, data) {
|
||||
try {
|
||||
await this.handler.writeFile(path, await fromCallback(zlib.gzip, JSON.stringify(data)), { flags: 'w' })
|
||||
} catch (error) {
|
||||
@@ -577,7 +579,7 @@ class RemoteAdapter {
|
||||
async _readCacheListVmBackups(vmUuid) {
|
||||
const path = this.#getVmBackupsCache(vmUuid)
|
||||
|
||||
const cache = await this.#readCache(path)
|
||||
const cache = await this._readCache(path)
|
||||
if (cache !== undefined) {
|
||||
debug('found VM backups cache, using it', { path })
|
||||
return cache
|
||||
@@ -590,7 +592,7 @@ class RemoteAdapter {
|
||||
}
|
||||
|
||||
// detached async action, will not reject
|
||||
this.#writeCache(path, backups)
|
||||
this._writeCache(path, backups)
|
||||
|
||||
return backups
|
||||
}
|
||||
@@ -641,7 +643,7 @@ class RemoteAdapter {
|
||||
})
|
||||
|
||||
// will not throw
|
||||
this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
|
||||
await this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
|
||||
debug('adding cache entry', { entry: path })
|
||||
backups[path] = {
|
||||
...metadata,
|
||||
|
||||
@@ -311,7 +311,6 @@ exports.cleanVm = async function cleanVm(
|
||||
}
|
||||
|
||||
const jsons = new Set()
|
||||
let mustInvalidateCache = false
|
||||
const xvas = new Set()
|
||||
const xvaSums = []
|
||||
const entries = await handler.list(vmDir, {
|
||||
@@ -327,6 +326,20 @@ exports.cleanVm = async function cleanVm(
|
||||
}
|
||||
})
|
||||
|
||||
const cachePath = vmDir + '/cache.json.gz'
|
||||
|
||||
let mustRegenerateCache
|
||||
{
|
||||
const cache = await this._readCache(cachePath)
|
||||
const actual = cache === undefined ? 0 : Object.keys(cache).length
|
||||
const expected = jsons.size
|
||||
|
||||
mustRegenerateCache = actual !== expected
|
||||
if (mustRegenerateCache) {
|
||||
logWarn('unexpected number of entries in backup cache', { path: cachePath, actual, expected })
|
||||
}
|
||||
}
|
||||
|
||||
await asyncMap(xvas, async path => {
|
||||
// check is not good enough to delete the file, the best we can do is report
|
||||
// it
|
||||
@@ -338,6 +351,8 @@ exports.cleanVm = async function cleanVm(
|
||||
const unusedVhds = new Set(vhds)
|
||||
const unusedXvas = new Set(xvas)
|
||||
|
||||
const backups = new Map()
|
||||
|
||||
// compile the list of unused XVAs and VHDs, and remove backup metadata which
|
||||
// reference a missing XVA/VHD
|
||||
await asyncMap(jsons, async json => {
|
||||
@@ -350,19 +365,16 @@ exports.cleanVm = async function cleanVm(
|
||||
return
|
||||
}
|
||||
|
||||
let isBackupComplete
|
||||
|
||||
const { mode } = metadata
|
||||
if (mode === 'full') {
|
||||
const linkedXva = resolve('/', vmDir, metadata.xva)
|
||||
if (xvas.has(linkedXva)) {
|
||||
isBackupComplete = xvas.has(linkedXva)
|
||||
if (isBackupComplete) {
|
||||
unusedXvas.delete(linkedXva)
|
||||
} else {
|
||||
logWarn('the XVA linked to the backup is missing', { backup: json, xva: linkedXva })
|
||||
if (remove) {
|
||||
logInfo('deleting incomplete backup', { path: json })
|
||||
jsons.delete(json)
|
||||
mustInvalidateCache = true
|
||||
await handler.unlink(json)
|
||||
}
|
||||
}
|
||||
} else if (mode === 'delta') {
|
||||
const linkedVhds = (() => {
|
||||
@@ -371,22 +383,28 @@ exports.cleanVm = async function cleanVm(
|
||||
})()
|
||||
|
||||
const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
|
||||
isBackupComplete = missingVhds.length === 0
|
||||
|
||||
// FIXME: find better approach by keeping as much of the backup as
|
||||
// possible (existing disks) even if one disk is missing
|
||||
if (missingVhds.length === 0) {
|
||||
if (isBackupComplete) {
|
||||
linkedVhds.forEach(_ => unusedVhds.delete(_))
|
||||
linkedVhds.forEach(path => {
|
||||
vhdsToJSons[path] = json
|
||||
})
|
||||
} else {
|
||||
logWarn('some VHDs linked to the backup are missing', { backup: json, missingVhds })
|
||||
if (remove) {
|
||||
logInfo('deleting incomplete backup', { path: json })
|
||||
mustInvalidateCache = true
|
||||
jsons.delete(json)
|
||||
await handler.unlink(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBackupComplete) {
|
||||
backups.set(json, metadata)
|
||||
} else {
|
||||
jsons.delete(json)
|
||||
if (remove) {
|
||||
logInfo('deleting incomplete backup', { backup: json })
|
||||
mustRegenerateCache = true
|
||||
await handler.unlink(json)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -496,7 +514,7 @@ exports.cleanVm = async function cleanVm(
|
||||
// check for the other that the size is the same as the real file size
|
||||
|
||||
await asyncMap(jsons, async metadataPath => {
|
||||
const metadata = JSON.parse(await handler.readFile(metadataPath))
|
||||
const metadata = backups.get(metadataPath)
|
||||
|
||||
let fileSystemSize
|
||||
const merged = metadataWithMergedVhd[metadataPath] !== undefined
|
||||
@@ -538,6 +556,7 @@ exports.cleanVm = async function cleanVm(
|
||||
// systematically update size after a merge
|
||||
if ((merged || fixMetadata) && size !== fileSystemSize) {
|
||||
metadata.size = fileSystemSize
|
||||
mustRegenerateCache = true
|
||||
try {
|
||||
await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
|
||||
} catch (error) {
|
||||
@@ -546,9 +565,16 @@ exports.cleanVm = async function cleanVm(
|
||||
}
|
||||
})
|
||||
|
||||
// purge cache if a metadata file has been deleted
|
||||
if (mustInvalidateCache) {
|
||||
await handler.unlink(vmDir + '/cache.json.gz')
|
||||
if (mustRegenerateCache) {
|
||||
const cache = {}
|
||||
for (const [path, content] of backups.entries()) {
|
||||
cache[path] = {
|
||||
_filename: path,
|
||||
id: path,
|
||||
...content,
|
||||
}
|
||||
}
|
||||
await this._writeCache(cachePath, cache)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.4",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
@@ -21,13 +21,13 @@
|
||||
"@vates/cached-dns.lookup": "^1.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.2",
|
||||
"@vates/disposable": "^0.1.3",
|
||||
"@vates/fuse-vhd": "^1.0.0",
|
||||
"@vates/nbd-client": "*",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^5.0.1",
|
||||
"d3-time-format": "^3.0.0",
|
||||
@@ -42,7 +42,7 @@
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.1.1",
|
||||
"vhd-lib": "^4.2.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,7 +52,7 @@
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^1.5.2"
|
||||
"@xen-orchestra/xapi": "^1.6.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -11,7 +11,6 @@ const { dirname } = require('path')
|
||||
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
const { getOldEntries } = require('../_getOldEntries.js')
|
||||
const { getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
const { Task } = require('../Task.js')
|
||||
|
||||
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
|
||||
@@ -29,8 +28,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
const backup = this._backup
|
||||
const adapter = this._adapter
|
||||
|
||||
const backupDir = getVmBackupDir(backup.vm.uuid)
|
||||
const vdisDir = `${backupDir}/vdis/${backup.job.id}`
|
||||
const vdisDir = `${this._vmBackupDir}/vdis/${backup.job.id}`
|
||||
|
||||
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
|
||||
let found = false
|
||||
@@ -143,7 +141,6 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
|
||||
const jobId = job.id
|
||||
const handler = adapter.handler
|
||||
const backupDir = getVmBackupDir(vm.uuid)
|
||||
|
||||
// TODO: clean VM backup directory
|
||||
|
||||
@@ -177,7 +174,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
|
||||
const { size } = await Task.run({ name: 'transfer' }, async () => {
|
||||
await Promise.all(
|
||||
map(deltaExport.vdis, async (vdi, id) => {
|
||||
const path = `${backupDir}/${vhds[id]}`
|
||||
const path = `${this._vmBackupDir}/${vhds[id]}`
|
||||
|
||||
const isDelta = vdi.other_config['xo:base_delta'] !== undefined
|
||||
let parentPath
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
const { getOldEntries } = require('../_getOldEntries.js')
|
||||
const { getVmBackupDir } = require('../_getVmBackupDir.js')
|
||||
const { Task } = require('../Task.js')
|
||||
|
||||
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
|
||||
@@ -34,7 +33,6 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
|
||||
const { job, scheduleId, vm } = backup
|
||||
|
||||
const adapter = this._adapter
|
||||
const backupDir = getVmBackupDir(vm.uuid)
|
||||
|
||||
// TODO: clean VM backup directory
|
||||
|
||||
@@ -47,7 +45,7 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
|
||||
const basename = formatFilenameDate(timestamp)
|
||||
|
||||
const dataBasename = basename + '.xva'
|
||||
const dataFilename = backupDir + '/' + dataBasename
|
||||
const dataFilename = this._vmBackupDir + '/' + dataBasename
|
||||
|
||||
const metadata = {
|
||||
jobId: job.id,
|
||||
|
||||
@@ -16,7 +16,6 @@ const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
|
||||
exports.MixinBackupWriter = (BaseClass = Object) =>
|
||||
class MixinBackupWriter extends BaseClass {
|
||||
#lock
|
||||
#vmBackupDir
|
||||
|
||||
constructor({ remoteId, ...rest }) {
|
||||
super(rest)
|
||||
@@ -24,13 +23,13 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
|
||||
this._adapter = rest.backup.remoteAdapters[remoteId]
|
||||
this._remoteId = remoteId
|
||||
|
||||
this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
|
||||
this._vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
|
||||
}
|
||||
|
||||
async _cleanVm(options) {
|
||||
try {
|
||||
return await Task.run({ name: 'clean-vm' }, () => {
|
||||
return this._adapter.cleanVm(this.#vmBackupDir, {
|
||||
return this._adapter.cleanVm(this._vmBackupDir, {
|
||||
...options,
|
||||
fixMetadata: true,
|
||||
logInfo: info,
|
||||
@@ -50,7 +49,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
|
||||
|
||||
async beforeBackup() {
|
||||
const { handler } = this._adapter
|
||||
const vmBackupDir = this.#vmBackupDir
|
||||
const vmBackupDir = this._vmBackupDir
|
||||
await handler.mktree(vmBackupDir)
|
||||
this.#lock = await handler.lock(vmBackupDir)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"bind-property-descriptor": "^2.0.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"execa": "^5.0.0",
|
||||
|
||||
@@ -14,7 +14,7 @@ import { basename, dirname, normalize as normalizePath } from './path'
|
||||
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
|
||||
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
|
||||
|
||||
const { info, warn } = createLogger('@xen-orchestra:fs')
|
||||
const { info, warn } = createLogger('xo:fs:abstract')
|
||||
|
||||
const checksumFile = file => file + '.checksum'
|
||||
const computeRate = (hrtime, size) => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- Settings page (PR [#6418](https://github.com/vatesfr/xen-orchestra/pull/6418))
|
||||
- Uncollapse hosts in the tree by default (PR [#6428](https://github.com/vatesfr/xen-orchestra/pull/6428))
|
||||
- Display RAM usage in pool dashboard (PR [#6419](https://github.com/vatesfr/xen-orchestra/pull/6419))
|
||||
- Implement not found page (PR [#6410](https://github.com/vatesfr/xen-orchestra/pull/6410))
|
||||
|
||||
## **0.1.0**
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<a :href="url.href" target="_blank" rel="noopener">{{ url.href }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<template #buttons>
|
||||
<UiButton color="success" @click="reload">{{
|
||||
$t("unreachable-hosts-reload-page")
|
||||
}}</UiButton>
|
||||
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
<div v-if="!xenApiStore.isConnected">
|
||||
<AppLogin />
|
||||
@@ -43,6 +49,7 @@ import AppHeader from "@/components/AppHeader.vue";
|
||||
import AppLogin from "@/components/AppLogin.vue";
|
||||
import AppTooltips from "@/components/AppTooltips.vue";
|
||||
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import { useChartTheme } from "@/composables/chart-theme.composable";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
@@ -105,6 +112,7 @@ watch(
|
||||
);
|
||||
|
||||
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
|
||||
const reload = () => window.location.reload();
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
1
@xen-orchestra/lite/src/assets/object-not-found.svg
Normal file
1
@xen-orchestra/lite/src/assets/object-not-found.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
1
@xen-orchestra/lite/src/assets/page-not-found.svg
Normal file
1
@xen-orchestra/lite/src/assets/page-not-found.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -2,15 +2,24 @@
|
||||
<div class="app-login form-container">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<img alt="XO Lite" src="../assets/logo-title.svg" />
|
||||
<input v-model="login" name="login" readonly type="text" />
|
||||
<input
|
||||
v-model="password"
|
||||
:readonly="isConnecting"
|
||||
name="password"
|
||||
:placeholder="$t('password')"
|
||||
type="password"
|
||||
/>
|
||||
<UiButton :busy="isConnecting" type="submit">
|
||||
<FormInputWrapper>
|
||||
<FormInput v-model="login" name="login" readonly type="text" />
|
||||
</FormInputWrapper>
|
||||
<FormInputWrapper :error="error">
|
||||
<FormInput
|
||||
name="password"
|
||||
ref="passwordRef"
|
||||
type="password"
|
||||
v-model="password"
|
||||
:placeholder="$t('password')"
|
||||
:readonly="isConnecting"
|
||||
/>
|
||||
</FormInputWrapper>
|
||||
<UiButton
|
||||
type="submit"
|
||||
:busy="isConnecting"
|
||||
:disabled="password.trim().length < 1"
|
||||
>
|
||||
{{ $t("login") }}
|
||||
</UiButton>
|
||||
</form>
|
||||
@@ -19,21 +28,47 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import FormInput from "@/components/form/FormInput.vue";
|
||||
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const { t } = useI18n();
|
||||
const xenApiStore = useXenApiStore();
|
||||
const { isConnecting } = storeToRefs(xenApiStore);
|
||||
const login = ref("root");
|
||||
const password = ref("");
|
||||
const error = ref<string>();
|
||||
const passwordRef = ref<InstanceType<typeof FormInput>>();
|
||||
const isInvalidPassword = ref(false);
|
||||
|
||||
const focusPasswordInput = () => passwordRef.value?.focus();
|
||||
|
||||
onMounted(() => {
|
||||
xenApiStore.reconnect();
|
||||
focusPasswordInput();
|
||||
});
|
||||
|
||||
watch(password, () => {
|
||||
isInvalidPassword.value = false;
|
||||
error.value = undefined;
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
await xenApiStore.connect(login.value, password.value);
|
||||
try {
|
||||
await xenApiStore.connect(login.value, password.value);
|
||||
} catch (err) {
|
||||
if ((err as Error).message === "SESSION_AUTHENTICATION_FAILED") {
|
||||
focusPasswordInput();
|
||||
isInvalidPassword.value = true;
|
||||
error.value = t("password-invalid");
|
||||
} else {
|
||||
error.value = t("error-occured");
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -50,6 +85,7 @@ async function handleSubmit() {
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
font-size: 2rem;
|
||||
min-width: 30em;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
@@ -72,12 +108,6 @@ img {
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 120%;
|
||||
font-weight: bold;
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 45rem;
|
||||
max-width: 100%;
|
||||
@@ -89,6 +119,6 @@ input {
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 3rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
47
@xen-orchestra/lite/src/components/ObjectNotFoundWrapper.vue
Normal file
47
@xen-orchestra/lite/src/components/ObjectNotFoundWrapper.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="wrapper-spinner" v-if="!store.isReady">
|
||||
<UiSpinner class="spinner" />
|
||||
</div>
|
||||
<ObjectNotFoundView :id="id" v-else-if="isRecordNotFound" />
|
||||
<slot v-else />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const storeByType = {
|
||||
vm: useVmStore,
|
||||
host: useHostStore,
|
||||
};
|
||||
|
||||
const props = defineProps<{ objectType: "vm" | "host"; id?: string }>();
|
||||
|
||||
const store = storeByType[props.objectType]();
|
||||
const { currentRoute } = useRouter();
|
||||
|
||||
const id = computed(
|
||||
() => props.id ?? (currentRoute.value.params.uuid as string)
|
||||
);
|
||||
const isRecordNotFound = computed(
|
||||
() => store.isReady && !store.hasRecordByUuid(id.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper-spinner {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
color: var(--color-extra-blue-base);
|
||||
display: flex;
|
||||
margin: auto;
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div class="progress-bar-component">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" />
|
||||
</div>
|
||||
<div class="legend" v-if="label !== undefined">
|
||||
<span class="circle" />
|
||||
{{ label }}
|
||||
<UiBadge class="badge">{{ badgeLabel ?? progressWithUnit }}</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
badgeLabel?: string | number;
|
||||
label?: string;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxValue: 100,
|
||||
});
|
||||
|
||||
const progressWithUnit = computed(() => {
|
||||
const progress = Math.round((props.value / props.maxValue) * 100);
|
||||
return `${progress}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.legend {
|
||||
text-align: right;
|
||||
margin: 1.6em 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.9em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
background-color: #716ac6;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
overflow: hidden;
|
||||
height: 1.2rem;
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(--color-blue-scale-400);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
transition: width 1s ease-in-out;
|
||||
width: v-bind(progressWithUnit);
|
||||
height: 1.2rem;
|
||||
background-color: var(--color-extra-blue-d20);
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<template v-if="data !== undefined">
|
||||
<ProgressBar
|
||||
<div
|
||||
v-for="item in computedData.sortedArray"
|
||||
:key="item.id"
|
||||
:value="item.value"
|
||||
:label="item.label"
|
||||
:badge-label="item.badgeLabel"
|
||||
/>
|
||||
class="progress-item"
|
||||
>
|
||||
<UiProgressBar :value="item.value" color="custom" />
|
||||
<div class="legend">
|
||||
<span class="circle" />
|
||||
{{ item.label }}
|
||||
<UiBadge class="badge">{{
|
||||
item.badgeLabel ?? `${item.value}%`
|
||||
}}</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<slot name="footer" :total-percent="computedData.totalPercentUsage" />
|
||||
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
<UiSpinner v-else class="spinner" />
|
||||
@@ -20,8 +24,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
import UiProgressBar from "@/components/ui/UiProgressBar.vue";
|
||||
import { computed } from "vue";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
|
||||
interface Data {
|
||||
@@ -33,7 +38,7 @@ interface Data {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data?: Array<Data>;
|
||||
data?: Data[];
|
||||
nItems?: number;
|
||||
}
|
||||
|
||||
@@ -60,15 +65,6 @@ const computedData = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
color: var(--color-extra-blue-base);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-extra-blue-base);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -84,23 +80,43 @@ const computedData = computed(() => {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.progress-bar-component:nth-of-type(2) .progress-bar-fill,
|
||||
.progress-bar-component:nth-of-type(2) .circle {
|
||||
background-color: var(--color-extra-blue-d60);
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin: 1.6em 0;
|
||||
}
|
||||
.progress-bar-component:nth-of-type(3) .progress-bar-fill,
|
||||
.progress-bar-component:nth-of-type(3) .circle {
|
||||
background-color: var(--color-extra-blue-d40);
|
||||
|
||||
.badge {
|
||||
font-size: 0.9em;
|
||||
font-weight: 700;
|
||||
}
|
||||
.progress-bar-component:nth-of-type(4) .progress-bar-fill,
|
||||
.progress-bar-component:nth-of-type(4) .circle {
|
||||
background-color: var(--color-extra-blue-d20);
|
||||
|
||||
.progress-item {
|
||||
--progress-bar-height: 1.2rem;
|
||||
--progress-bar-color: var(--color-extra-blue-l20);
|
||||
--progress-bar-background-color: var(--color-blue-scale-400);
|
||||
}
|
||||
.progress-bar-component .progress-bar-fill,
|
||||
.progress-bar-component .circle {
|
||||
background-color: var(--color-extra-blue-l20);
|
||||
|
||||
.progress-item:nth-child(1) {
|
||||
--progress-bar-color: var(--color-extra-blue-d60);
|
||||
}
|
||||
|
||||
.progress-item:nth-child(2) {
|
||||
--progress-bar-color: var(--color-extra-blue-d40);
|
||||
}
|
||||
|
||||
.progress-item:nth-child(3) {
|
||||
--progress-bar-color: var(--color-extra-blue-d20);
|
||||
}
|
||||
|
||||
.circle {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--progress-bar-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:class="inputClass"
|
||||
:disabled="disabled || isLabelDisabled"
|
||||
class="input"
|
||||
ref="inputElement"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<template v-else>
|
||||
@@ -14,6 +15,7 @@
|
||||
:class="inputClass"
|
||||
:disabled="disabled || isLabelDisabled"
|
||||
class="select"
|
||||
ref="inputElement"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
@@ -70,6 +72,8 @@ interface Props extends Omit<InputHTMLAttributes, ""> {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { color: "info" });
|
||||
|
||||
const inputElement = ref();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: any): void;
|
||||
}>();
|
||||
@@ -78,6 +82,10 @@ const value = useVModel(props, "modelValue", emit);
|
||||
const empty = computed(() => isEmpty(props.modelValue));
|
||||
const isSelect = inject("isSelect", false);
|
||||
const isLabelDisabled = inject("isLabelDisabled", ref(false));
|
||||
const color = inject(
|
||||
"color",
|
||||
computed(() => undefined)
|
||||
);
|
||||
|
||||
const wrapperClass = computed(() => [
|
||||
isSelect ? "form-select" : "form-input",
|
||||
@@ -88,13 +96,19 @@ const wrapperClass = computed(() => [
|
||||
]);
|
||||
|
||||
const inputClass = computed(() => [
|
||||
props.color,
|
||||
color.value ?? props.color,
|
||||
{
|
||||
right: props.right,
|
||||
"has-before": props.before !== undefined,
|
||||
"has-after": props.after !== undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
const focus = () => inputElement.value.focus();
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
96
@xen-orchestra/lite/src/components/form/FormInputWrapper.vue
Normal file
96
@xen-orchestra/lite/src/components/form/FormInputWrapper.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<label
|
||||
v-if="$slots.label"
|
||||
class="form-label"
|
||||
:class="{ disabled, ...formInputWrapperClass }"
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
<slot />
|
||||
<p v-if="hasError || hasWarning" :class="formInputWrapperClass">
|
||||
<UiIcon :icon="faCircleExclamation" v-if="hasError" />{{
|
||||
error ?? warning
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, provide, useSlots } from "vue";
|
||||
import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}>();
|
||||
|
||||
provide("hasLabel", slots.label !== undefined);
|
||||
provide(
|
||||
"isLabelDisabled",
|
||||
computed(() => props.disabled)
|
||||
);
|
||||
|
||||
const hasError = computed(
|
||||
() => props.error !== undefined && props.error.trim() !== ""
|
||||
);
|
||||
const hasWarning = computed(
|
||||
() => props.warning !== undefined && props.warning.trim() !== ""
|
||||
);
|
||||
|
||||
provide(
|
||||
"color",
|
||||
computed(() =>
|
||||
hasError.value ? "error" : hasWarning.value ? "warning" : undefined
|
||||
)
|
||||
);
|
||||
|
||||
const formInputWrapperClass = computed(() => ({
|
||||
error: hasError.value,
|
||||
warning: !hasError.value && hasWarning.value,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wrapper :deep(.input) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 1.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625em;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-blue-scale-300);
|
||||
}
|
||||
}
|
||||
p.error,
|
||||
p.warning {
|
||||
font-size: 0.65em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-red-vates-base);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-orange-world-base);
|
||||
}
|
||||
|
||||
p svg {
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<label :class="{ disabled }" class="form-label">
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, provide } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
provide("hasLabel", true);
|
||||
provide(
|
||||
"isLabelDisabled",
|
||||
computed(() => props.disabled)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-label {
|
||||
font-size: 1.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625em;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-blue-scale-300);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">{{ $t("cpu-usage") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("cpu-usage") }}</UiCardTitle>
|
||||
<HostsCpuUsage />
|
||||
<VmsCpuUsage />
|
||||
</UiCard>
|
||||
@@ -9,5 +9,5 @@
|
||||
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
|
||||
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">{{ $t("ram-usage") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("ram-usage") }}</UiCardTitle>
|
||||
<HostsRamUsage />
|
||||
<VmsRamUsage />
|
||||
</UiCard>
|
||||
@@ -10,5 +10,5 @@
|
||||
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
|
||||
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">{{ $t("status") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("status") }}</UiCardTitle>
|
||||
<template v-if="isReady">
|
||||
<PoolDashboardStatusItem
|
||||
:active="activeHostsCount"
|
||||
:total="totalHostsCount"
|
||||
:label="$t('hosts')"
|
||||
:total="totalHostsCount"
|
||||
/>
|
||||
<UiSeparator />
|
||||
<PoolDashboardStatusItem
|
||||
:active="activeVmsCount"
|
||||
:total="totalVmsCount"
|
||||
:label="$t('vms')"
|
||||
:total="totalVmsCount"
|
||||
/>
|
||||
</template>
|
||||
<UiSpinner v-else class="spinner" />
|
||||
@@ -19,14 +19,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import PoolDashboardStatusItem from "@/components/pool/dashboard/PoolDashboardStatusItem.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiSeparator from "@/components/ui/UiSeparator.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { computed } from "vue";
|
||||
|
||||
const vmStore = useVmStore();
|
||||
const hostMetricsStore = useHostMetricsStore();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle type="h4">{{ $t("storage-usage") }}</UiTitle>
|
||||
<UsageBar :data="srStore.isReady ? data.result : undefined" :nItems="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("storage") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
<UiCardTitle
|
||||
:left="$t('storage-usage')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar
|
||||
:data="srStore.isReady ? data.result : undefined"
|
||||
:nItems="N_ITEMS"
|
||||
>
|
||||
<template #footer v-if="showFooter">
|
||||
<div class="footer-card">
|
||||
<p>{{ $t("total-used") }}:</p>
|
||||
@@ -31,10 +33,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { computed } from "vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import { formatSize, percent } from "@/libs/utils";
|
||||
import { useSrStore } from "@/stores/storage.store";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("hosts") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import type { Stat } from "@/composables/fetch-stats.composable";
|
||||
import { getAvgCpuUsage } from "@/libs/utils";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
|
||||
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
|
||||
"hostStats",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("vms") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import type { Stat } from "@/composables/fetch-stats.composable";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("hosts") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import type { Stat } from "@/composables/fetch-stats.composable";
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
|
||||
<template #header>
|
||||
<span>{{ $t("vms") }}</span>
|
||||
<span>{{ $t("top-#", { n: N_ITEMS }) }}</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiCardTitle
|
||||
subtitle
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import type { Stat } from "@/composables/fetch-stats.composable";
|
||||
|
||||
65
@xen-orchestra/lite/src/components/ui/UiCardTitle.vue
Normal file
65
@xen-orchestra/lite/src/components/ui/UiCardTitle.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div :class="{ subtitle }" class="ui-section-title">
|
||||
<component
|
||||
:is="subtitle ? 'h5' : 'h4'"
|
||||
v-if="$slots.default || left"
|
||||
class="left"
|
||||
>
|
||||
<slot>{{ left }}</slot>
|
||||
</component>
|
||||
<component
|
||||
:is="subtitle ? 'h6' : 'h5'"
|
||||
v-if="$slots.right || right"
|
||||
class="right"
|
||||
>
|
||||
<slot name="right">{{ right }}</slot>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
subtitle?: boolean;
|
||||
left?: string;
|
||||
right?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
--section-title-left-size: 2rem;
|
||||
--section-title-left-color: var(--color-blue-scale-100);
|
||||
--section-title-left-weight: 500;
|
||||
--section-title-right-size: 1.6rem;
|
||||
--section-title-right-color: var(--color-extra-blue-base);
|
||||
--section-title-right-weight: 700;
|
||||
|
||||
&.subtitle {
|
||||
border-bottom: 1px solid var(--color-extra-blue-base);
|
||||
|
||||
--section-title-left-size: 1.6rem;
|
||||
--section-title-left-color: var(--color-extra-blue-base);
|
||||
--section-title-left-weight: 700;
|
||||
--section-title-right-size: 1.4rem;
|
||||
--section-title-right-color: var(--color-extra-blue-base);
|
||||
--section-title-right-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
font-size: var(--section-title-left-size);
|
||||
font-weight: var(--section-title-left-weight);
|
||||
color: var(--section-title-left-color);
|
||||
}
|
||||
|
||||
.right {
|
||||
font-size: var(--section-title-right-size);
|
||||
font-weight: var(--section-title-right-weight);
|
||||
color: var(--section-title-right-color);
|
||||
}
|
||||
</style>
|
||||
60
@xen-orchestra/lite/src/components/ui/UiProgressBar.vue
Normal file
60
@xen-orchestra/lite/src/components/ui/UiProgressBar.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="ui-progress-bar" :class="`color-${color}`">
|
||||
<div class="fill" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Color } from "@/types";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: number;
|
||||
color?: Color | "custom";
|
||||
maxValue?: number;
|
||||
}>(),
|
||||
{ color: "info", maxValue: 100 }
|
||||
);
|
||||
|
||||
const progressWithUnit = computed(() => {
|
||||
const progress = (props.value / props.maxValue) * 100;
|
||||
return `${progress}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-progress-bar {
|
||||
overflow: hidden;
|
||||
height: var(--progress-bar-height, 0.4rem);
|
||||
margin: 1rem 0;
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(
|
||||
--progress-bar-background-color,
|
||||
var(--background-color-extra-blue)
|
||||
);
|
||||
|
||||
&.color-info {
|
||||
--progress-bar-color: var(--color-extra-blue-base);
|
||||
}
|
||||
|
||||
&.color-success {
|
||||
--progress-bar-color: var(--color-green-infra-base);
|
||||
}
|
||||
|
||||
&.color-warning {
|
||||
--progress-bar-color: var(--color-orange-world-base);
|
||||
}
|
||||
|
||||
&.color-error {
|
||||
--progress-bar-color: var(--color-red-vates-base);
|
||||
}
|
||||
}
|
||||
|
||||
.fill {
|
||||
width: v-bind(progressWithUnit);
|
||||
height: var(--progress-bar-height, 0.4rem);
|
||||
transition: width 1s ease-in-out;
|
||||
background-color: var(--progress-bar-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
# useArrayRemovedItemsHistory composable
|
||||
|
||||
This composable allows you to keep a history of each removed item of an array.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
const myArray = ref([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray)
|
||||
|
||||
myArray.push('A'); // myArray = ['A']; history = []
|
||||
myArray.push('B'); // myArray = ['A', 'B']; history = []
|
||||
myArray.shift(); // myArray = ['B']; history = ['A']
|
||||
```
|
||||
|
||||
You can limit the number of items to keep in history:
|
||||
|
||||
```typescript
|
||||
const myArray = ref([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray, 30);
|
||||
```
|
||||
|
||||
Be careful when using an array of objects which is likely to be replaced (instead of being altered):
|
||||
|
||||
```typescript
|
||||
const myArray = ref([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray);
|
||||
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
|
||||
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }, { id: 'bar' }]
|
||||
```
|
||||
|
||||
In this case, `{ id: 'bar' }` is detected as removed since in JavaScript `{ id: 'bar' } !== { id: 'bar' }`.
|
||||
|
||||
You must therefore use an identity function as third parameter to return the value to be used to detect deletion:
|
||||
|
||||
```typescript
|
||||
const myArray = ref<{ id: string }[]>([]);
|
||||
const history = useArrayRemovedItemsHistory(myArray, undefined, (item) => item.id);
|
||||
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
|
||||
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }]
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
import { differenceBy } from "lodash-es";
|
||||
import { type Ref, ref, unref, watch } from "vue";
|
||||
|
||||
export default function useArrayRemovedItemsHistory<T>(
|
||||
list: Ref<T[]>,
|
||||
limit = Infinity,
|
||||
iteratee: (item: T) => unknown = (item) => item
|
||||
) {
|
||||
const currentList: Ref<T[]> = ref([]);
|
||||
const history: Ref<T[]> = ref([]);
|
||||
|
||||
watch(
|
||||
list,
|
||||
(updatedList) => {
|
||||
currentList.value = [...updatedList];
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(currentList, (nextList, previousList) => {
|
||||
const removedItems = differenceBy(previousList, nextList, iteratee);
|
||||
history.value.push(...removedItems);
|
||||
const currentLimit = unref(limit);
|
||||
if (history.value.length > currentLimit) {
|
||||
history.value.slice(-currentLimit);
|
||||
}
|
||||
});
|
||||
|
||||
return history;
|
||||
}
|
||||
@@ -38,11 +38,12 @@ export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
|
||||
const newStats = (await STORES_BY_OBJECT_TYPE[type]().getStats(
|
||||
object.uuid,
|
||||
granularity
|
||||
)) as XapiStatsResponse<S>;
|
||||
)) as XapiStatsResponse<S> | undefined;
|
||||
|
||||
stats.value.get(object.uuid)!.stats = newStats.stats;
|
||||
|
||||
await promiseTimeout(newStats.interval * 1000);
|
||||
if (newStats !== undefined) {
|
||||
stats.value.get(object.uuid)!.stats = newStats.stats;
|
||||
await promiseTimeout(newStats.interval * 1000);
|
||||
}
|
||||
},
|
||||
0,
|
||||
{ immediate: true }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"ascending": "ascending",
|
||||
"available-properties-for-advanced-filter": "Available properties for advanced filter:",
|
||||
"backup": "Backup",
|
||||
"back-pool-dashboard": "Go back to your Pool dashboard",
|
||||
"cancel": "Cancel",
|
||||
"change-power-state": "Change power state",
|
||||
"community": "Community",
|
||||
@@ -23,6 +24,7 @@
|
||||
"descending": "descending",
|
||||
"display": "Display",
|
||||
"edit-config": "Edit config",
|
||||
"error-occured": "An error has occurred",
|
||||
"export": "Export",
|
||||
"export-table-to": "Export table to {type}",
|
||||
"export-vms": "Export VMs",
|
||||
@@ -36,8 +38,11 @@
|
||||
"network": "Network",
|
||||
"news": "News",
|
||||
"news-name": "{name} news",
|
||||
"object-not-found": "Object {id} can't be found…",
|
||||
"or": "Or",
|
||||
"page-not-found": "This page is not to be found…",
|
||||
"password": "Password",
|
||||
"password-invalid": "Password invalid",
|
||||
"property": "Property",
|
||||
"ram-usage":"RAM usage",
|
||||
"send-us-feedback": "Send us feedback",
|
||||
@@ -55,6 +60,7 @@
|
||||
"total-free": "Total free",
|
||||
"total-used": "Total used",
|
||||
"unreachable-hosts": "Unreachable hosts",
|
||||
"unreachable-hosts-reload-page": "Done, reload the page",
|
||||
"version": "Version",
|
||||
"vms": "VMs"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"ascending": "ascendant",
|
||||
"available-properties-for-advanced-filter": "Propriétés disponibles pour le filtrage avancé :",
|
||||
"backup": "Sauvegarde",
|
||||
"back-pool-dashboard": "Revenez au tableau de bord de votre pool",
|
||||
"cancel": "Annuler",
|
||||
"change-power-state": "Changer l'état d'alimentation",
|
||||
"community": "Communauté",
|
||||
@@ -23,6 +24,7 @@
|
||||
"descending": "descendant",
|
||||
"display": "Affichage",
|
||||
"edit-config": "Modifier config",
|
||||
"error-occured": "Une erreur est survenue",
|
||||
"export": "Exporter",
|
||||
"export-table-to": "Exporter le tableau en {type}",
|
||||
"export-vms": "Exporter les VMs",
|
||||
@@ -36,8 +38,11 @@
|
||||
"network": "Réseau",
|
||||
"news": "Actualités",
|
||||
"news-name": "Actualités {name}",
|
||||
"object-not-found": "L'objet {id} est introuvable…",
|
||||
"or": "Ou",
|
||||
"page-not-found": "Cette page est introuvable…",
|
||||
"password": "Mot de passe",
|
||||
"password-invalid": "Mot de passe incorrect",
|
||||
"property": "Propriété",
|
||||
"ram-usage":"Utilisation de la RAM",
|
||||
"send-us-feedback": "Envoyez-nous vos commentaires",
|
||||
@@ -55,6 +60,7 @@
|
||||
"total-free": "Total libre",
|
||||
"total-used": "Total utilisé",
|
||||
"unreachable-hosts": "Hôtes inaccessibles",
|
||||
"unreachable-hosts-reload-page": "C'est fait. Rafraîchir la page",
|
||||
"version": "Version",
|
||||
"vms": "VMs"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import pool from "@/router/pool";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
import PageNotFoundView from "@/views/PageNotFoundView.vue";
|
||||
import HostDashboardView from "@/views/host/HostDashboardView.vue";
|
||||
import HostRootView from "@/views/host/HostRootView.vue";
|
||||
import SettingsView from "@/views/settings/SettingsView.vue";
|
||||
@@ -43,6 +44,11 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "notFound",
|
||||
component: PageNotFoundView,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { computed } from "vue";
|
||||
import { sortRecordsByNameLabel } from "@/libs/utils";
|
||||
import type { GRANULARITY } from "@/libs/xapi-stats";
|
||||
import type { XenApiHost } from "@/libs/xen-api";
|
||||
@@ -6,6 +7,10 @@ import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useHostStore = defineStore("host", () => {
|
||||
const xenApiStore = useXenApiStore();
|
||||
const xapiStats = computed(() =>
|
||||
xenApiStore.isConnected ? xenApiStore.getXapiStats() : undefined
|
||||
);
|
||||
const recordContext = createRecordContext<XenApiHost>("host", {
|
||||
sort: sortRecordsByNameLabel,
|
||||
});
|
||||
@@ -15,7 +20,7 @@ export const useHostStore = defineStore("host", () => {
|
||||
if (host === undefined) {
|
||||
throw new Error(`Host ${id} could not be found.`);
|
||||
}
|
||||
return useXenApiStore().getXapiStats()._getAndUpdateStats({
|
||||
return xapiStats.value?._getAndUpdateStats({
|
||||
host,
|
||||
uuid: host.uuid,
|
||||
granularity,
|
||||
|
||||
@@ -62,6 +62,9 @@ export function createRecordContext<T extends XenApiRecord>(
|
||||
const getRecordByUuid = (uuid: string) =>
|
||||
useRecordsStore().getRecordByUuid<T>(uuid);
|
||||
|
||||
const hasRecordByUuid = (uuid: string) =>
|
||||
useRecordsStore().hasRecordByUuid(uuid);
|
||||
|
||||
return {
|
||||
init,
|
||||
opaqueRefs,
|
||||
@@ -69,5 +72,6 @@ export function createRecordContext<T extends XenApiRecord>(
|
||||
getRecordByUuid,
|
||||
isReady,
|
||||
allRecords,
|
||||
hasRecordByUuid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ export const useRecordsStore = defineStore("records", () => {
|
||||
return opaqueRefsByObjectType.get(objectType) || new Set();
|
||||
}
|
||||
|
||||
function hasRecordByUuid(uuid: string): boolean {
|
||||
return uuidToOpaqueRefMapping.has(uuid);
|
||||
}
|
||||
|
||||
return {
|
||||
loadRecords,
|
||||
addOrReplaceRecord,
|
||||
@@ -84,5 +88,6 @@ export const useRecordsStore = defineStore("records", () => {
|
||||
getRecord,
|
||||
getRecordsOpaqueRefs,
|
||||
getRecordByUuid,
|
||||
hasRecordByUuid,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import { defineStore } from "pinia";
|
||||
import { computed } from "vue";
|
||||
|
||||
export const useVmStore = defineStore("vm", () => {
|
||||
const xenApiStore = useXenApiStore();
|
||||
const hostStore = useHostStore();
|
||||
const xapiStats = computed(() =>
|
||||
xenApiStore.isConnected ? xenApiStore.getXapiStats() : undefined
|
||||
);
|
||||
const baseVmContext = createRecordContext<XenApiVm>("VM", {
|
||||
filter: (vm) =>
|
||||
!vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain,
|
||||
@@ -41,7 +45,7 @@ export const useVmStore = defineStore("vm", () => {
|
||||
throw new Error(`VM ${id} is halted or host could not be found.`);
|
||||
}
|
||||
|
||||
return useXenApiStore().getXapiStats()._getAndUpdateStats({
|
||||
return xapiStats.value?._getAndUpdateStats({
|
||||
host,
|
||||
uuid: vm.uuid,
|
||||
granularity,
|
||||
|
||||
@@ -91,6 +91,7 @@ export const useXenApiStore = defineStore("xen-api", () => {
|
||||
}
|
||||
|
||||
async function connect(username: string, password: string) {
|
||||
isConnecting.value = true;
|
||||
try {
|
||||
currentSessionId.value = await xenApi.connectWithPassword(
|
||||
username,
|
||||
|
||||
44
@xen-orchestra/lite/src/views/ObjectNotFoundView.vue
Normal file
44
@xen-orchestra/lite/src/views/ObjectNotFoundView.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div>
|
||||
<img alt="Not found" src="../assets/object-not-found.svg" />
|
||||
<p class="text">{{ $t("object-not-found", { id }) }}</p>
|
||||
<UiButton @click="router.push({ name: 'home' })">{{
|
||||
$t("back-pool-dashboard")
|
||||
}}</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
|
||||
defineProps<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--color-extra-blue-base);
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
line-height: 150%;
|
||||
margin: 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
49
@xen-orchestra/lite/src/views/PageNotFoundView.vue
Normal file
49
@xen-orchestra/lite/src/views/PageNotFoundView.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div>
|
||||
<img alt="Not found" src="../assets/page-not-found.svg" />
|
||||
<p class="numeric">404</p>
|
||||
<p class="text">{{ $t("page-not-found") }}</p>
|
||||
<UiButton @click="router.push({ name: 'home' })">{{
|
||||
$t("back-pool-dashboard")
|
||||
}}</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
img {
|
||||
width: 30%;
|
||||
}
|
||||
.numeric {
|
||||
color: var(--color-extra-blue-base);
|
||||
font-size: 96px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1em;
|
||||
line-height: 100%;
|
||||
margin-right: -1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--color-extra-blue-base);
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
line-height: 150%;
|
||||
margin: 0.5em;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<ObjectNotFoundWrapper object-type="host">
|
||||
<RouterView />
|
||||
</ObjectNotFoundWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
<script lang="ts" setup>
|
||||
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<TitleBar :icon="faGear">{{ $t("settings") }}</TitleBar>
|
||||
<div class="card-view">
|
||||
<UiCard class="group">
|
||||
<UiTitle type="h4">Xen Orchestra Lite</UiTitle>
|
||||
<UiCardTitle>Xen Orchestra Lite</UiCardTitle>
|
||||
<UiKeyValueList>
|
||||
<UiKeyValueRow>
|
||||
<template #key>{{ $t("version") }}</template>
|
||||
@@ -50,24 +50,22 @@
|
||||
</UiKeyValueList>
|
||||
</UiCard>
|
||||
<UiCard class="group">
|
||||
<UiTitle type="h4">{{ $t("display") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("display") }}</UiCardTitle>
|
||||
<UiKeyValueList>
|
||||
<UiKeyValueRow>
|
||||
<template #key>{{ $t("appearance") }}</template>
|
||||
<template #value>
|
||||
<FormLabel>
|
||||
<FormSelect v-model="colorMode">
|
||||
<option value="auto">{{ $t("theme-auto") }}</option>
|
||||
<option value="dark">{{ $t("theme-dark") }}</option>
|
||||
<option value="light">{{ $t("theme-light") }}</option>
|
||||
</FormSelect>
|
||||
</FormLabel>
|
||||
<FormSelect v-model="colorMode">
|
||||
<option value="auto">{{ $t("theme-auto") }}</option>
|
||||
<option value="dark">{{ $t("theme-dark") }}</option>
|
||||
<option value="light">{{ $t("theme-light") }}</option>
|
||||
</FormSelect>
|
||||
</template>
|
||||
</UiKeyValueRow>
|
||||
</UiKeyValueList>
|
||||
</UiCard>
|
||||
<UiCard class="group">
|
||||
<UiTitle type="h4">{{ $t("language") }}</UiTitle>
|
||||
<UiCardTitle>{{ $t("language") }}</UiCardTitle>
|
||||
<UiKeyValueList>
|
||||
<UiKeyValueRow>
|
||||
<template #value>
|
||||
@@ -91,6 +89,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { watch } from "vue";
|
||||
@@ -99,11 +98,9 @@ import { locales } from "@/i18n";
|
||||
import { faEarthAmericas, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import TitleBar from "@/components/TitleBar.vue";
|
||||
import FormLabel from "@/components/form/FormLabel.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiKeyValueList from "@/components/ui/UiKeyValueList.vue";
|
||||
import UiKeyValueRow from "@/components/ui/UiKeyValueRow.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
|
||||
const version = XO_LITE_VERSION;
|
||||
const gitHead = XO_LITE_GIT_HEAD;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<ObjectNotFoundWrapper object-type="vm">
|
||||
<RouterView />
|
||||
</ObjectNotFoundWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
|
||||
import { watchEffect } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/log",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "ISC",
|
||||
"description": "Logging system with decoupled producers/consumer",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/log",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"engines": {
|
||||
"node": ">=15.6"
|
||||
},
|
||||
@@ -22,7 +22,7 @@
|
||||
"@vates/event-listeners-manager": "^1.0.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/emit-async": "^1.0.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"acme-client": "^5.0.0",
|
||||
"app-conf": "^2.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.26.4",
|
||||
"version": "0.26.9",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -30,15 +30,15 @@
|
||||
"@vates/cached-dns.lookup": "^1.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.2",
|
||||
"@vates/disposable": "^0.1.3",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.29.0",
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/backups": "^0.29.4",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.8.1",
|
||||
"@xen-orchestra/mixins": "^0.8.2",
|
||||
"@xen-orchestra/self-signed": "^0.1.3",
|
||||
"@xen-orchestra/xapi": "^1.5.2",
|
||||
"@xen-orchestra/xapi": "^1.6.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^2.3.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"pw": "^0.0.4",
|
||||
"xdg-basedir": "^4.0.0",
|
||||
"xo-lib": "^0.11.1",
|
||||
"xo-vmdk-to-vhd": "^2.4.3"
|
||||
"xo-vmdk-to-vhd": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
1
@xen-orchestra/vmware-explorer/.npmignore
Symbolic link
1
@xen-orchestra/vmware-explorer/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
154
@xen-orchestra/vmware-explorer/VhdEsxiCowd.mjs
Normal file
154
@xen-orchestra/vmware-explorer/VhdEsxiCowd.mjs
Normal file
@@ -0,0 +1,154 @@
|
||||
import { notEqual, strictEqual } from 'node:assert'
|
||||
import { VhdAbstract } from 'vhd-lib'
|
||||
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
|
||||
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
|
||||
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
|
||||
|
||||
export default class VhdCowd extends VhdAbstract {
|
||||
#esxi
|
||||
#datastore
|
||||
#parentFileName
|
||||
#path
|
||||
|
||||
#header
|
||||
#footer
|
||||
|
||||
#grainDirectory
|
||||
|
||||
static async open(esxi, datastore, path) {
|
||||
const vhd = new VhdCowd(esxi, datastore, path)
|
||||
await vhd.readHeaderAndFooter()
|
||||
return vhd
|
||||
}
|
||||
constructor(esxi, datastore, path, parentFileName) {
|
||||
super()
|
||||
this.#esxi = esxi
|
||||
this.#path = path
|
||||
this.#datastore = datastore
|
||||
this.#parentFileName = parentFileName
|
||||
}
|
||||
|
||||
get header() {
|
||||
return this.#header
|
||||
}
|
||||
|
||||
get footer() {
|
||||
return this.#footer
|
||||
}
|
||||
|
||||
containsBlock(blockId) {
|
||||
notEqual(this.#grainDirectory, undefined, "bat must be loaded to use contain blocks'")
|
||||
// only check if a grain table exist for on of the sector of the block
|
||||
// the great news is that a grain size has 4096 entries of 512B = 2M
|
||||
// and a vhd block is also 2M
|
||||
// so we only need to check if a grain table exists (it's not created without data)
|
||||
return this.#grainDirectory.readInt32LE(blockId * 4) !== 0
|
||||
}
|
||||
|
||||
async #read(start, end) {
|
||||
return (await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)).buffer()
|
||||
}
|
||||
|
||||
async readHeaderAndFooter(checkSecondFooter = true) {
|
||||
const buffer = await this.#read(0, 2048)
|
||||
|
||||
strictEqual(buffer.slice(0, 4).toString('ascii'), 'COWD')
|
||||
strictEqual(buffer.readInt32LE(4), 1) // version
|
||||
strictEqual(buffer.readInt32LE(8), 3) // flags
|
||||
const sectorCapacity = buffer.readInt32LE(12)
|
||||
// const sectorGrainNumber = buffer.readInt32LE(16)
|
||||
strictEqual(buffer.readInt32LE(20), 4) // grain directory position in sectors
|
||||
|
||||
// const nbGrainDirectoryEntries = buffer.readInt32LE(24)
|
||||
// const nextFreeSector = buffer.readInt32LE(28)
|
||||
const size = sectorCapacity * 512
|
||||
// a grain directory entry represent a grain table
|
||||
// a grain table can adresse, at most 4096 grain of 512 B
|
||||
this.#header = unpackHeader(createHeader(Math.ceil(size / (4096 * 512))))
|
||||
this.#header.parentUnicodeName = this.#parentFileName
|
||||
const geometry = _computeGeometryForSize(size)
|
||||
const actualSize = geometry.actualSize
|
||||
this.#footer = unpackFooter(
|
||||
createFooter(
|
||||
actualSize,
|
||||
Math.floor(Date.now() / 1000),
|
||||
geometry,
|
||||
FOOTER_SIZE,
|
||||
this.#parentFileName ? DISK_TYPES.DIFFERENCING : DISK_TYPES.DYNAMIC
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async readBlockAllocationTable() {
|
||||
const nbBlocks = this.header.maxTableEntries
|
||||
this.#grainDirectory = await this.#read(2048, 2048 + nbBlocks * 4 - 1)
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
const sectorOffset = this.#grainDirectory.readInt32LE(blockId * 4)
|
||||
if (sectorOffset === 1) {
|
||||
return Promise.resolve(Buffer.alloc(4096 * 512, 0))
|
||||
}
|
||||
const offset = sectorOffset * 512
|
||||
|
||||
const graintable = await this.#read(offset, offset + 2048 - 1)
|
||||
|
||||
const buf = Buffer.concat([
|
||||
Buffer.alloc(512, 255), // vhd block bitmap,
|
||||
Buffer.alloc(512 * 4096, 0), // empty data
|
||||
])
|
||||
|
||||
// we have no guaranty that data are order or contiguous
|
||||
// let's construct ranges to limit the number of queries
|
||||
|
||||
const fileOffsetToIndexInGrainTable = {}
|
||||
let nbNonEmptyGrain = 0
|
||||
for (let i = 0; i < graintable.length / 4; i++) {
|
||||
const grainOffset = graintable.readInt32LE(i * 4)
|
||||
if (grainOffset !== 0) {
|
||||
// non empty grain
|
||||
fileOffsetToIndexInGrainTable[grainOffset] = i
|
||||
nbNonEmptyGrain++
|
||||
}
|
||||
}
|
||||
// grain table exists but only contains empty grains
|
||||
if (nbNonEmptyGrain === 0) {
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap: buf.slice(0, this.bitmapSize),
|
||||
data: buf.slice(this.bitmapSize),
|
||||
buffer: buf,
|
||||
}
|
||||
}
|
||||
|
||||
const offsets = Object.keys(fileOffsetToIndexInGrainTable).map(offset => parseInt(offset))
|
||||
offsets.sort((a, b) => a - b)
|
||||
let startOffset = offsets[0]
|
||||
|
||||
const ranges = []
|
||||
const OVERPROVISION = 3
|
||||
for (let i = 1; i < offsets.length; i++) {
|
||||
if (offsets[i - 1] + OVERPROVISION < offsets[i]) {
|
||||
ranges.push({ startOffset, endOffset: offsets[i - 1] })
|
||||
startOffset = offsets[i]
|
||||
}
|
||||
}
|
||||
|
||||
ranges.push({ startOffset, endOffset: offsets[offsets.length - 1] })
|
||||
|
||||
for (const { startOffset, endOffset } of ranges) {
|
||||
const startIndex = fileOffsetToIndexInGrainTable[startOffset]
|
||||
const startInBlock = startIndex * 512 + 512 /* block bitmap */
|
||||
const sectors = await this.#read(startOffset * 512, endOffset * 512 - 1)
|
||||
// @todo : if overprovision > 1 , it may copy random data from the vmdk
|
||||
sectors.copy(buf, startInBlock)
|
||||
}
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap: buf.slice(0, 512),
|
||||
data: buf.slice(512),
|
||||
buffer: buf,
|
||||
}
|
||||
}
|
||||
}
|
||||
131
@xen-orchestra/vmware-explorer/VhdEsxiRaw.mjs
Normal file
131
@xen-orchestra/vmware-explorer/VhdEsxiRaw.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
|
||||
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
|
||||
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { readChunk } from '@vates/read-chunk'
|
||||
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
|
||||
import { VhdAbstract } from 'vhd-lib'
|
||||
import assert from 'node:assert'
|
||||
|
||||
const VHD_BLOCK_LENGTH = 2 * 1024 * 1024
|
||||
export default class VhdEsxiRaw extends VhdAbstract {
|
||||
#esxi
|
||||
#datastore
|
||||
#path
|
||||
|
||||
#bat
|
||||
#header
|
||||
#footer
|
||||
|
||||
#stream
|
||||
#bytesRead = 0
|
||||
|
||||
static async open(esxi, datastore, path) {
|
||||
const vhd = new VhdEsxiRaw(esxi, datastore, path)
|
||||
await vhd.readHeaderAndFooter()
|
||||
return vhd
|
||||
}
|
||||
|
||||
get header() {
|
||||
return this.#header
|
||||
}
|
||||
|
||||
get footer() {
|
||||
return this.#footer
|
||||
}
|
||||
|
||||
constructor(esxi, datastore, path) {
|
||||
super()
|
||||
this.#esxi = esxi
|
||||
this.#path = path
|
||||
this.#datastore = datastore
|
||||
}
|
||||
|
||||
async readHeaderAndFooter(checkSecondFooter = true) {
|
||||
const res = await this.#esxi.download(this.#datastore, this.#path)
|
||||
const length = res.headers.get('content-length')
|
||||
|
||||
this.#header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
const geometry = _computeGeometryForSize(length)
|
||||
const actualSize = geometry.actualSize
|
||||
|
||||
this.#footer = unpackFooter(
|
||||
createFooter(actualSize, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
)
|
||||
}
|
||||
|
||||
containsBlock(blockId) {
|
||||
assert.notEqual(this.#bat, undefined, "bat is not loaded")
|
||||
return this.#bat.has(blockId)
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
const start = blockId * VHD_BLOCK_LENGTH
|
||||
if (!this.#stream) {
|
||||
this.#stream = (await this.#esxi.download(this.#datastore, this.#path)).body
|
||||
this.#bytesRead = 0
|
||||
}
|
||||
if (this.#bytesRead > start) {
|
||||
this.#stream.destroy()
|
||||
this.#stream = (
|
||||
await this.#esxi.download(this.#datastore, this.#path, `${start}-${this.footer.currentSize}`)
|
||||
).body
|
||||
this.#bytesRead = start
|
||||
}
|
||||
|
||||
if (start - this.#bytesRead > 0) {
|
||||
this.#stream.destroy()
|
||||
this.#stream = (
|
||||
await this.#esxi.download(this.#datastore, this.#path, `${start}-${this.footer.currentSize}`)
|
||||
).body
|
||||
this.#bytesRead = start
|
||||
}
|
||||
|
||||
const data = await readChunk(this.#stream, VHD_BLOCK_LENGTH)
|
||||
this.#bytesRead += data.length
|
||||
const bitmap = Buffer.alloc(512, 255)
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap,
|
||||
data,
|
||||
buffer: Buffer.concat([bitmap, data]),
|
||||
}
|
||||
}
|
||||
|
||||
async readBlockAllocationTable() {
|
||||
const res = await this.#esxi.download(this.#datastore, this.#path)
|
||||
const length = res.headers.get('content-length')
|
||||
const stream = res.body
|
||||
const empty = Buffer.alloc(VHD_BLOCK_LENGTH, 0)
|
||||
let pos = 0
|
||||
this.#bat = new Set()
|
||||
let nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length)
|
||||
|
||||
const progress = setInterval(() => {
|
||||
console.log("reading blocks", pos / VHD_BLOCK_LENGTH, "/", length/ VHD_BLOCK_LENGTH)
|
||||
}, 30 * 1000)
|
||||
|
||||
while (nextChunkLength > 0) {
|
||||
try{
|
||||
const chunk = await readChunk(stream, nextChunkLength)
|
||||
let isEmpty
|
||||
if (nextChunkLength === VHD_BLOCK_LENGTH) {
|
||||
isEmpty = empty.equals(chunk)
|
||||
} else {
|
||||
// last block can be smaller
|
||||
isEmpty = Buffer.alloc(nextChunkLength, 0).equals(chunk)
|
||||
}
|
||||
if (!isEmpty) {
|
||||
this.#bat.add(pos / VHD_BLOCK_LENGTH)
|
||||
}
|
||||
pos += VHD_BLOCK_LENGTH
|
||||
nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length - pos)
|
||||
}catch(error){
|
||||
clearInterval(progress)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
console.log("BAT reading done, remaining ", this.#bat.size, "/", Math.ceil(length / VHD_BLOCK_LENGTH))
|
||||
clearInterval(progress)
|
||||
|
||||
}
|
||||
}
|
||||
315
@xen-orchestra/vmware-explorer/esxi.mjs
Normal file
315
@xen-orchestra/vmware-explorer/esxi.mjs
Normal file
@@ -0,0 +1,315 @@
|
||||
import { Client } from 'node-vsphere-soap'
|
||||
import { dirname } from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { strictEqual } from 'node:assert'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import parseVmdk from './parsers/vmdk.mjs'
|
||||
import parseVmsd from './parsers/vmsd.mjs'
|
||||
import parseVmx from './parsers/vmx.mjs'
|
||||
import VhdCowd from './VhdEsxiCowd.mjs'
|
||||
import VhdEsxiRaw from './VhdEsxiRaw.mjs'
|
||||
|
||||
const MAX_SCSI = 9
|
||||
const MAX_ETHERNET = 9
|
||||
|
||||
export default class Esxi extends EventEmitter {
|
||||
#client
|
||||
#cookies
|
||||
#host
|
||||
#user
|
||||
#password
|
||||
#ready = false
|
||||
|
||||
constructor(host, user, password, sslVerify = true) {
|
||||
super()
|
||||
this.#host = host
|
||||
this.#user = user
|
||||
this.#password = password
|
||||
this.#client = new Client(host, user, password, sslVerify)
|
||||
process.on('warning', this.#eatTlsWarning )
|
||||
this.#client.once('ready', () => {
|
||||
process.off('warning', this.#eatTlsWarning )
|
||||
this.#ready = true
|
||||
this.emit('ready')
|
||||
})
|
||||
this.#client.on('error', err => {
|
||||
process.off('warning', this.#eatTlsWarning )
|
||||
console.error({
|
||||
in:'ERROR',
|
||||
code: err.code,
|
||||
message: err.message
|
||||
})
|
||||
this.emit('error', err)
|
||||
})
|
||||
}
|
||||
|
||||
#eatTlsWarning (/* err */){
|
||||
// console.log('yummy', err.code, err.message)
|
||||
}
|
||||
#exec(cmd, args) {
|
||||
strictEqual(this.#ready, true)
|
||||
const client = this.#client
|
||||
return new Promise(function (resolve, reject) {
|
||||
client.once('error', function (error) {
|
||||
client.off('result', resolve)
|
||||
reject(error)
|
||||
})
|
||||
client.runCommand(cmd, args).once('result', function () {
|
||||
client.off('error', reject)
|
||||
resolve(...arguments)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async download(dataStore, path, range) {
|
||||
strictEqual(this.#ready, true)
|
||||
const url = `https://${this.#host}/folder/${path}?dsName=${dataStore}`
|
||||
const headers = {}
|
||||
if(this.#cookies){
|
||||
headers.cookie= this.#cookies
|
||||
} else {
|
||||
headers.Authorization = 'Basic ' + Buffer.from(this.#user + ':' + this.#password).toString('base64')
|
||||
}
|
||||
if (range) {
|
||||
headers['content-type'] = 'multipart/byteranges'
|
||||
headers.Range = 'bytes=' + range
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
highWaterMark: 10 * 1024 * 1024,
|
||||
})
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
const error = new Error(res.status + ' ' + res.statusText + ' ' + url)
|
||||
error.cause = res
|
||||
throw error
|
||||
}
|
||||
if(res.headers.raw()['set-cookie']){
|
||||
this.#cookies = res.headers.raw()['set-cookie']
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async search(type, properties) {
|
||||
// get property collector
|
||||
const propertyCollector = this.#client.serviceContent.propertyCollector
|
||||
// get view manager
|
||||
const viewManager = this.#client.serviceContent.viewManager
|
||||
// get root folder
|
||||
const rootFolder = this.#client.serviceContent.rootFolder
|
||||
let result = await this.#exec('CreateContainerView', {
|
||||
_this: viewManager,
|
||||
container: rootFolder,
|
||||
type: [type],
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
// build all the data structures needed to query all the vm names
|
||||
const containerView = result.returnval
|
||||
|
||||
const objectSpec = {
|
||||
attributes: { 'xsi:type': 'ObjectSpec' }, // setting attributes xsi:type is important or else the server may mis-recognize types!
|
||||
obj: containerView,
|
||||
skip: true,
|
||||
selectSet: [
|
||||
{
|
||||
attributes: { 'xsi:type': 'TraversalSpec' },
|
||||
name: 'traverseEntities',
|
||||
type: 'ContainerView',
|
||||
path: 'view',
|
||||
skip: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const propertyFilterSpec = {
|
||||
attributes: { 'xsi:type': 'PropertyFilterSpec' },
|
||||
propSet: properties.map(p => ({
|
||||
attributes: { 'xsi:type': 'PropertySpec' },
|
||||
type,
|
||||
pathSet: [p],
|
||||
})),
|
||||
objectSet: [objectSpec],
|
||||
}
|
||||
|
||||
result = await this.#exec('RetrievePropertiesEx', {
|
||||
_this: propertyCollector,
|
||||
specSet: [propertyFilterSpec],
|
||||
options: { attributes: { type: 'RetrieveOptions' } },
|
||||
})
|
||||
|
||||
const objects = {}
|
||||
const returnObj = Array.isArray(result.returnval.objects) ? result.returnval.objects : [result.returnval.objects]
|
||||
|
||||
returnObj.forEach(({ obj, propSet }) => {
|
||||
objects[obj.$value] = {}
|
||||
propSet = Array.isArray(propSet) ? propSet : [propSet]
|
||||
propSet.forEach(({ name, val }) => {
|
||||
// don't care about the type for now
|
||||
delete val.attributes
|
||||
// a scalar value : simplify it
|
||||
if (val.$value) {
|
||||
objects[obj.$value][name] = val.$value
|
||||
} else {
|
||||
objects[obj.$value][name] = val
|
||||
}
|
||||
})
|
||||
})
|
||||
return objects
|
||||
}
|
||||
|
||||
async #inspectVmdk(dataStores, currentDataStore, currentPath, filePath) {
|
||||
let diskDataStore, diskPath
|
||||
if (filePath.startsWith('/')) {
|
||||
// disk is on another datastore
|
||||
Object.keys(dataStores).forEach(dataStoreUrl => {
|
||||
if (filePath.startsWith(dataStoreUrl)) {
|
||||
diskDataStore = dataStores[dataStoreUrl].name
|
||||
diskPath = filePath.substring(dataStoreUrl.length + 1)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
diskDataStore = currentDataStore
|
||||
diskPath = currentPath + '/' + filePath
|
||||
}
|
||||
const vmdkRes = await this.download(diskDataStore, diskPath)
|
||||
const text = await vmdkRes.text()
|
||||
const parsed = parseVmdk(text)
|
||||
const { fileName, parentFileName, capacity } = parsed
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
datastore: diskDataStore,
|
||||
path: dirname(diskPath),
|
||||
descriptionLabel: ' from esxi',
|
||||
vhd: async () => {
|
||||
if (fileName.endsWith('-flat.vmdk')) {
|
||||
const vhd = await VhdEsxiRaw.open(this, diskDataStore, dirname(diskPath) + '/' + fileName)
|
||||
await vhd.readBlockAllocationTable()
|
||||
return vhd.stream()
|
||||
}
|
||||
// last snasphot only works when vm is powered off
|
||||
const vhd = await VhdCowd.open(this, diskDataStore, dirname(diskPath) + '/' + fileName, parentFileName)
|
||||
await vhd.readBlockAllocationTable()
|
||||
|
||||
return vhd.stream()
|
||||
},
|
||||
rawStream: async () => {
|
||||
if (!fileName.endsWith('-flat.vmdk')) {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo : only if vm is powered off
|
||||
const stream = (await this.download(diskDataStore, dirname(diskPath) + '/' + fileName)).body
|
||||
stream.length = capacity
|
||||
return stream
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async getTransferableVmMetadata(vmId) {
|
||||
const search = await this.search('VirtualMachine', ['name', 'config', 'storage', 'runtime', 'snapshot'])
|
||||
if (search[vmId] === undefined) {
|
||||
throw new Error(`VM ${vmId} not found `)
|
||||
}
|
||||
const { config, runtime } = search[vmId]
|
||||
|
||||
const [, dataStore, vmxPath] = config.files.vmPathName.match(/^\[(.*)\] (.+.vmx)$/)
|
||||
const res = await this.download(dataStore, vmxPath)
|
||||
|
||||
const vmx = parseVmx(await res.text())
|
||||
|
||||
// list datastores
|
||||
const dataStores = {}
|
||||
Object.values(await this.search('Datastore', ['summary'])).forEach(({ summary }) => {
|
||||
dataStores[summary.url] = summary
|
||||
})
|
||||
|
||||
const disks = []
|
||||
|
||||
for (let scsiIndex = 0; scsiIndex < MAX_SCSI; scsiIndex++) {
|
||||
const scsiChannel = vmx[`scsi${scsiIndex}`]
|
||||
if (scsiChannel === undefined) {
|
||||
continue
|
||||
}
|
||||
for (const diskIndex in Object.values(scsiChannel)) {
|
||||
const disk = scsiChannel[diskIndex]
|
||||
if (typeof disk !== 'object' || disk.deviceType !== 'scsi-hardDisk') {
|
||||
continue
|
||||
}
|
||||
disks.push({
|
||||
...(await this.#inspectVmdk(dataStores, dataStore, dirname(vmxPath), disk.fileName)),
|
||||
node: `scsi${scsiIndex}:${diskIndex}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const networks = []
|
||||
for (let ethernetIndex = 0; ethernetIndex < MAX_ETHERNET; ethernetIndex++) {
|
||||
const ethernet = vmx[`ethernet${ethernetIndex}`]
|
||||
if (ethernet === undefined) {
|
||||
continue
|
||||
}
|
||||
networks.push({
|
||||
label: ethernet.networkName,
|
||||
macAddress: ethernet.generatedAddress,
|
||||
isGenerated: ethernet.addressType === 'generated',
|
||||
})
|
||||
}
|
||||
const vmsd = await (await this.download(dataStore, vmxPath.replace('.vmx', '.vmsd'))).text()
|
||||
let snapshots
|
||||
if(vmsd){
|
||||
snapshots = parseVmsd(vmsd)
|
||||
|
||||
for (const snapshotIndex in snapshots?.snapshots) {
|
||||
const snapshot = snapshots.snapshots[snapshotIndex]
|
||||
for (const diskIndex in snapshot.disks) {
|
||||
const fileName = snapshot.disks[diskIndex].fileName
|
||||
snapshot.disks[diskIndex] = {
|
||||
node: snapshot.disks[diskIndex]?.node, // 'scsi0:0',
|
||||
...(await this.#inspectVmdk(dataStores, dataStore, dirname(vmxPath), fileName)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
name_label: config.name,
|
||||
memory: parseInt(config.hardware.memoryMB) * 1024 * 1024,
|
||||
numCpu: parseInt(config.hardware.numCPU),
|
||||
guestToolsInstalled: false,
|
||||
firmware: config.firmware, // bios or uefi
|
||||
powerState: runtime.powerState,
|
||||
snapshots,
|
||||
disks: disks.map(({ fileName, rawDiskFileName, datastore, path, parentFileName, ...other }) => {
|
||||
return {
|
||||
...other,
|
||||
vhd: async () => {
|
||||
if (fileName.endsWith('-flat.vmdk')) {
|
||||
const vhd = await VhdEsxiRaw.open(this, datastore, path + '/' + fileName)
|
||||
await vhd.readBlockAllocationTable()
|
||||
return vhd.stream()
|
||||
}
|
||||
// last snasphot only works when vm is powered off
|
||||
const vhd = await VhdCowd.open(this, datastore, path + '/' + fileName, parentFileName)
|
||||
await vhd.readBlockAllocationTable()
|
||||
|
||||
return vhd.stream()
|
||||
},
|
||||
rawStream: async () => {
|
||||
if (fileName.endsWith('-flat.vmdk')) {
|
||||
return
|
||||
}
|
||||
|
||||
// @todo : only if vm is powered off
|
||||
const stream = (await this.download(datastore, path + '/' + fileName)).body
|
||||
stream.length = other.capacity
|
||||
return stream
|
||||
},
|
||||
}
|
||||
}),
|
||||
networks,
|
||||
}
|
||||
}
|
||||
}
|
||||
14
@xen-orchestra/vmware-explorer/index.mjs
Normal file
14
@xen-orchestra/vmware-explorer/index.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import Esxi from './esxi.mjs'
|
||||
const host = '10.10.0.62'
|
||||
const user = 'root'
|
||||
const password = ''
|
||||
const sslVerify = false
|
||||
|
||||
console.log(Esxi)
|
||||
const esxi = new Esxi(host, user, password, sslVerify)
|
||||
console.log(esxi)
|
||||
esxi.on('ready', async function (){
|
||||
const metadata = await esxi.getTransferableVmMetadata('4')
|
||||
console.log('metadata', metadata)
|
||||
})
|
||||
|
||||
30
@xen-orchestra/vmware-explorer/package.json
Normal file
30
@xen-orchestra/vmware-explorer/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"version": "0.0.2",
|
||||
"name": "@xen-orchestra/vmware-explorer",
|
||||
"dependencies": {
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^3.3.0",
|
||||
"node-vsphere-soap": "^0.0.2-5",
|
||||
"vhd-lib": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/vmware-explorer",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@xen-orchestra/vmware-explorer",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
}
|
||||
}
|
||||
45
@xen-orchestra/vmware-explorer/parsers/vmdk.mjs
Normal file
45
@xen-orchestra/vmware-explorer/parsers/vmdk.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import { strictEqual } from 'node:assert'
|
||||
|
||||
|
||||
|
||||
// this file contains the disk metadata
|
||||
export function parseDescriptor(text) {
|
||||
const descriptorText = text.toString('ascii').replace(/\x00+$/, '') // eslint-disable-line no-control-regex
|
||||
strictEqual(descriptorText.substr(0, 21), '# Disk DescriptorFile')
|
||||
const descriptorDict = {}
|
||||
const extentList = []
|
||||
const lines = descriptorText.split(/\r?\n/).filter(line => {
|
||||
return line.trim().length > 0 && line[0] !== '#'
|
||||
})
|
||||
for (const line of lines) {
|
||||
const defLine = line.split('=')
|
||||
// the wonky quote test is to avoid having an equal sign in the name of an extent
|
||||
if (defLine.length === 2 && defLine[0].indexOf('"') === -1) {
|
||||
descriptorDict[defLine[0].toLowerCase()] = defLine[1].replace(/['"]+/g, '')
|
||||
} else {
|
||||
const [, access, sizeSectors, type, name, offset] = line.match(/([A-Z]+) ([0-9]+) ([A-Z]+) "(.*)" ?(.*)$/)
|
||||
extentList.push({ access, sizeSectors, type, name, offset })
|
||||
}
|
||||
}
|
||||
strictEqual(extentList.length, 1, 'only one extent per vmdk is supported')
|
||||
return { ...descriptorDict, extent: extentList[0] }
|
||||
}
|
||||
|
||||
// https://github.com/libyal/libvmdk/blob/main/documentation/VMWare%20Virtual%20Disk%20Format%20(VMDK).asciidoc#5-the-cowd-sparse-extent-data-file
|
||||
// vmdk file can be only a descriptor, or a
|
||||
export default function parseVmdk(raw) {
|
||||
strictEqual(typeof raw, 'string')
|
||||
|
||||
const descriptor = parseDescriptor(raw)
|
||||
const isFull = !descriptor.parentfilenamehint
|
||||
return {
|
||||
capacity: descriptor.extent.sizeSectors * 512,
|
||||
isFull,
|
||||
uid: descriptor.cid,
|
||||
fileName: descriptor.extent.name,
|
||||
parentId: descriptor.parentcid,
|
||||
parentFileName: descriptor.parentfilenamehint,
|
||||
vmdFormat: descriptor.extent.type,
|
||||
nameLabel: descriptor.extent.name,
|
||||
}
|
||||
}
|
||||
53
@xen-orchestra/vmware-explorer/parsers/vmsd.mjs
Normal file
53
@xen-orchestra/vmware-explorer/parsers/vmsd.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
// these files contains the snapshot history of the VM
|
||||
|
||||
function set(obj, keyPath, val) {
|
||||
const [key, ...other] = keyPath
|
||||
const match = key.match(/^(.+)([0-9])$/)
|
||||
if (match) {
|
||||
// an array
|
||||
let [, label, index] = match
|
||||
label += 's'
|
||||
if (!obj[label]) {
|
||||
obj[label] = []
|
||||
}
|
||||
if (other.length) {
|
||||
if (!obj[label][index]) {
|
||||
obj[label][parseInt(index)] = {}
|
||||
}
|
||||
set(obj[label][index], other, val)
|
||||
} else {
|
||||
obj[label][index] = val
|
||||
}
|
||||
} else {
|
||||
if (other.length) {
|
||||
// an object
|
||||
if (!obj[key]) {
|
||||
// first time
|
||||
obj[key] = {}
|
||||
}
|
||||
set(obj[key], other, val)
|
||||
} else {
|
||||
// a scalar
|
||||
obj[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function parseVmsd(text) {
|
||||
const parsed = {}
|
||||
text.split('\n').forEach(line => {
|
||||
const [key, val] = line.split(' = ')
|
||||
if (!key.startsWith('snapshot')) {
|
||||
return
|
||||
}
|
||||
|
||||
set(parsed, key.split('.'), val?.substring(1, val.length - 1))
|
||||
})
|
||||
|
||||
return {
|
||||
lastUID: parsed.snapshot.current,
|
||||
current: parsed.snapshot.current,
|
||||
numSnapshots: parsed.snapshot.numSnapshots,
|
||||
snapshots: Object.values(parsed.snapshots) || [],
|
||||
}
|
||||
}
|
||||
48
@xen-orchestra/vmware-explorer/parsers/vmx.mjs
Normal file
48
@xen-orchestra/vmware-explorer/parsers/vmx.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
function set(obj, keyPath, val) {
|
||||
let [key, ...other] = keyPath
|
||||
|
||||
if (key.includes(':')) {
|
||||
// it's an array
|
||||
let index
|
||||
;[key, index] = key.split(':')
|
||||
index = parseInt(index)
|
||||
if (!obj[key]) {
|
||||
// first time on this array
|
||||
obj[key] = []
|
||||
}
|
||||
if (!other.length) {
|
||||
// without descendant
|
||||
obj[key][index] = val
|
||||
} else {
|
||||
// with descendant
|
||||
if (!obj[key][index]) {
|
||||
// first time on this descendant
|
||||
obj[key][index] = {}
|
||||
}
|
||||
set(obj[key][index], other, val)
|
||||
}
|
||||
} else {
|
||||
// it's an object
|
||||
if (!other.length) {
|
||||
// wihtout descendant
|
||||
obj[key] = val
|
||||
} else {
|
||||
// with descendant
|
||||
if (obj[key] === undefined) {
|
||||
// first time
|
||||
obj[key] = {}
|
||||
}
|
||||
set(obj[key], other, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this file contains the vm configuration
|
||||
export default function parseVmx(text) {
|
||||
const vmx = {}
|
||||
text.split('\n').forEach(line => {
|
||||
const [key, val] = line.split(' = ')
|
||||
set(vmx, key.split('.'), val?.substring(1, val.length - 1))
|
||||
})
|
||||
return vmx
|
||||
}
|
||||
@@ -211,9 +211,10 @@ class Xapi extends Base {
|
||||
})
|
||||
|
||||
if (timeout !== undefined) {
|
||||
const error = new Error(`waitObjectState: timeout reached before ${refOrUuid} in expected state`)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
stop()
|
||||
reject(new Error(`waitObjectState: timeout reached before ${refOrUuid} in expected state`))
|
||||
reject(error)
|
||||
}, timeout)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/xapi",
|
||||
"version": "1.5.2",
|
||||
"version": "1.6.0",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
@@ -23,14 +23,14 @@
|
||||
"dependencies": {
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"http-request-plus": "^0.14.0",
|
||||
"json-rpc-protocol": "^0.13.2",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.1.1",
|
||||
"vhd-lib": "^4.2.0",
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"private": false,
|
||||
|
||||
@@ -7,7 +7,6 @@ const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const pickBy = require('lodash/pickBy.js')
|
||||
const omit = require('lodash/omit.js')
|
||||
const pCatch = require('promise-toolbox/catch')
|
||||
const pRetry = require('promise-toolbox/retry')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { decorateClass } = require('@vates/decorate-with')
|
||||
@@ -637,92 +636,44 @@ class Vm {
|
||||
}
|
||||
}
|
||||
|
||||
let destroyNobakVdis = false
|
||||
let ignoredVbds
|
||||
if (ignoreNobakVdis) {
|
||||
if (isHalted) {
|
||||
await asyncMap(await listNobakVbds(this, vm.VBDs), async vbd => {
|
||||
await this.VBD_destroy(vbd.$ref)
|
||||
$defer.call(this, 'VBD_create', vbd)
|
||||
})
|
||||
} else {
|
||||
// cannot unplug VBDs on Running, Paused and Suspended VMs
|
||||
destroyNobakVdis = true
|
||||
}
|
||||
ignoredVbds = await listNobakVbds(this, vm.VBDs)
|
||||
ignoreNobakVdis = ignoredVbds.length !== 0
|
||||
}
|
||||
|
||||
if (name_label === undefined) {
|
||||
name_label = vm.name_label
|
||||
const params = [cancelToken, 'VM.snapshot', vmRef, name_label ?? vm.name_label]
|
||||
if (ignoreNobakVdis) {
|
||||
params.push(ignoredVbds.map(_ => _.VDI))
|
||||
}
|
||||
let ref
|
||||
do {
|
||||
if (!vm.tags.includes('xo-disable-quiesce')) {
|
||||
try {
|
||||
let { snapshots } = vm
|
||||
ref = await pRetry(
|
||||
async () => {
|
||||
try {
|
||||
return await this.callAsync(cancelToken, 'VM.snapshot_with_quiesce', vmRef, name_label)
|
||||
} catch (error) {
|
||||
if (error == null || error.code !== 'VM_SNAPSHOT_WITH_QUIESCE_FAILED') {
|
||||
throw pRetry.bail(error)
|
||||
}
|
||||
// detect and remove new broken snapshots
|
||||
//
|
||||
// see https://github.com/vatesfr/xen-orchestra/issues/3936
|
||||
const prevSnapshotRefs = new Set(snapshots)
|
||||
const snapshotNameLabelPrefix = `Snapshot of ${vm.uuid} [`
|
||||
snapshots = await this.getField('VM', vm.$ref, 'snapshots')
|
||||
const createdSnapshots = (
|
||||
await this.getRecords(
|
||||
'VM',
|
||||
snapshots.filter(_ => !prevSnapshotRefs.has(_))
|
||||
)
|
||||
).filter(_ => _.name_label.startsWith(snapshotNameLabelPrefix))
|
||||
// be safe: only delete if there was a single match
|
||||
if (createdSnapshots.length === 1) {
|
||||
const snapshotRef = createdSnapshots[0]
|
||||
this.VM_destroy(snapshotRef).catch(error => {
|
||||
warn('VM_sapshot: failed to destroy broken snapshot', {
|
||||
error,
|
||||
snapshotRef,
|
||||
vmRef,
|
||||
})
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{
|
||||
delay: 60e3,
|
||||
tries: 3,
|
||||
}
|
||||
).then(extractOpaqueRef)
|
||||
this.call('VM.add_tags', ref, 'quiesce').catch(error => {
|
||||
warn('VM_snapshot: failed to add quiesce tag', {
|
||||
vmRef,
|
||||
snapshotRef: ref,
|
||||
error,
|
||||
})
|
||||
|
||||
let destroyNobakVdis = false
|
||||
let result
|
||||
try {
|
||||
result = await this.callAsync(...params)
|
||||
} catch (error) {
|
||||
if (error.code !== 'MESSAGE_PARAMETER_COUNT_MISMATCH') {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (ignoreNobakVdis) {
|
||||
if (isHalted) {
|
||||
await asyncMap(ignoredVbds, async vbd => {
|
||||
await this.VBD_destroy(vbd.$ref)
|
||||
$defer.call(this, 'VBD_create', vbd)
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
const { code } = error
|
||||
if (
|
||||
// removed in CH 8.1
|
||||
code !== 'MESSAGE_REMOVED' &&
|
||||
code !== 'VM_SNAPSHOT_WITH_QUIESCE_NOT_SUPPORTED' &&
|
||||
// quiesce only work on a running VM
|
||||
code !== 'VM_BAD_POWER_STATE' &&
|
||||
// quiesce failed, fallback on standard snapshot
|
||||
// TODO: emit warning
|
||||
code !== 'VM_SNAPSHOT_WITH_QUIESCE_FAILED'
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
// cannot unplug VBDs on Running, Paused and Suspended VMs
|
||||
destroyNobakVdis = true
|
||||
}
|
||||
}
|
||||
ref = await this.callAsync(cancelToken, 'VM.snapshot', vmRef, name_label).then(extractOpaqueRef)
|
||||
} while (false)
|
||||
|
||||
params.pop()
|
||||
|
||||
result = await this.callAsync(...params)
|
||||
}
|
||||
|
||||
const ref = extractOpaqueRef(result)
|
||||
|
||||
// detached async
|
||||
this._httpHook(vm, '/post-sync').catch(noop)
|
||||
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -1,8 +1,105 @@
|
||||
# ChangeLog
|
||||
|
||||
## **next**
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Snapshot] Use the new [`ignore_vdis` feature](https://github.com/xapi-project/xen-api/pull/4563) of XCP-ng/XenServer 8.3
|
||||
- [Hub/Recipes/Kubernetes] Now use the [Flannel](https://github.com/flannel-io/flannel) Container Network Interface plugin to handle network
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Nagios] Fix reporting, broken in 5.77.2
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/xapi 1.6.0
|
||||
- @xen-orchestra/backups 0.29.4
|
||||
- @xen-orchestra/proxy 0.26.9
|
||||
- xo-server 5.107.5
|
||||
- xo-web 5.109.0
|
||||
|
||||
## **5.77.2** (2022-12-12)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Backups] Fixes most of the _unexpected number of entries in backup cache_ errors
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/backups 0.29.3
|
||||
- @xen-orchestra/proxy 0.26.7
|
||||
- xo-server 5.107.3
|
||||
|
||||
## **5.77.1** (2022-12-07)
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Backups] Automatically detect, report and fix cache inconsistencies
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Warm migration] Fix start and delete VMs after a warm migration [#6568](https://github.com/vatesfr/xen-orchestra/issues/6568)
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/backups 0.29.2
|
||||
- @xen-orchestra/proxy 0.26.6
|
||||
- xo-server 0.107.2
|
||||
|
||||
## **5.77.0** (2022-11-30)
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Proxies] Ability to register an existing proxy (PR [#6556](https://github.com/vatesfr/xen-orchestra/pull/6556))
|
||||
- [VM] [Warm migration](https://xen-orchestra.com/blog/warm-migration-with-xen-orchestra/) support (PRs [6549](https://github.com/vatesfr/xen-orchestra/pull/6549) & [6549](https://github.com/vatesfr/xen-orchestra/pull/6549))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Remotes] Prevent remote path from ending with `xo-vm-backups` as it's usually a mistake
|
||||
- [OVA export] Speed up OVA generation by 2. Generated file will be bigger (as big as uncompressed XVA) (PR [#6487](https://github.com/vatesfr/xen-orchestra/pull/6487))
|
||||
- [Settings/Users] Add `Remove` button to delete OTP of users from the admin panel [Forum#6521](https://xcp-ng.org/forum/topic/6521/remove-totp-on-a-user-account) (PR [#6541](https://github.com/vatesfr/xen-orchestra/pull/6541))
|
||||
- [Plugin/transport-nagios] XO now reports backed up VMs invidually with the VM name label used as _host_ and backup job name used as _service_
|
||||
- [VM/Advanced] Add warm migration button (PR [#6533](https://github.com/vatesfr/xen-orchestra/pull/6533))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Dashboard/Health] Fix `Unknown SR` and `Unknown VDI` in Unhealthy VDIs (PR [#6519](https://github.com/vatesfr/xen-orchestra/pull/6519))
|
||||
- [Delta Backup] Can now recover VHD merge when failed at the begining
|
||||
- [Delta Backup] Fix `ENOENT` errors when merging a VHD directory on non-S3 remote
|
||||
- [Remote] Prevent the browser from auto-completing the encryption key field
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/log 0.5.0
|
||||
- @vates/disposable 0.1.3
|
||||
- @xen-orchestra/fs 3.3.0
|
||||
- vhd-lib 4.2.0
|
||||
- @xen-orchestra/audit-core 0.2.2
|
||||
- @xen-orchestra/backups 0.29.1
|
||||
- @xen-orchestra/backups-cli 1.0.0
|
||||
- @xen-orchestra/mixins 0.8.2
|
||||
- @xen-orchestra/xapi 1.5.3
|
||||
- @xen-orchestra/proxy 0.26.5
|
||||
- xo-vmdk-to-vhd 2.5.0
|
||||
- xo-cli 0.14.2
|
||||
- xo-server 5.107.1
|
||||
- xo-server-audit 0.10.2
|
||||
- xo-server-auth-ldap 0.10.6
|
||||
- xo-server-backup-reports 0.17.2
|
||||
- xo-server-load-balancer 0.7.2
|
||||
- xo-server-netbox 0.3.5
|
||||
- xo-server-sdn-controller 1.0.7
|
||||
- xo-server-transport-nagios 1.0.0
|
||||
- xo-server-usage-report 0.10.2
|
||||
- xo-server-web-hooks 0.3.2
|
||||
- xo-web 5.108.0
|
||||
|
||||
## **5.76.2** (2022-11-14)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -82,8 +179,6 @@
|
||||
|
||||
## **5.75.0** (2022-09-30)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Backup/Restore file] Implement File level restore for s3 and encrypted backups (PR [#6409](https://github.com/vatesfr/xen-orchestra/pull/6409))
|
||||
|
||||
@@ -7,25 +7,15 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [Remotes] Prevent remote path from ending with `xo-vm-backups` as it's usually a mistake
|
||||
- [OVA export] Speed up OVA generation by 2. Generated file will be bigger (as big as uncompressed XVA) (PR [#6487](https://github.com/vatesfr/xen-orchestra/pull/6487))
|
||||
- [Settings/Users] Add `Remove` button to delete OTP of users from the admin panel [Forum#6521](https://xcp-ng.org/forum/topic/6521/remove-totp-on-a-user-account) (PR [#6541](https://github.com/vatesfr/xen-orchestra/pull/6541))
|
||||
- [Plugin/transport-nagios] XO now reports beckup VMs invidually with the VM name label used as *host* and backup job name used as *service*
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Dashboard/Health] Fix `Unknown SR` and `Unknown VDI` in Unhealthy VDIs (PR [#6519](https://github.com/vatesfr/xen-orchestra/pull/6519))
|
||||
- [Delta Backup] Can now recover VHD merge when failed at the begining
|
||||
- [Delta Backup] Fix `ENOENT` errors when merging a VHD directory on non-S3 remote
|
||||
- [Remote] Prevent the browser from auto-completing the encryption key field
|
||||
|
||||
### Packages to release
|
||||
|
||||
> When modifying a package, add it here with its release type.
|
||||
>
|
||||
> The format is the following: - `$packageName` `$releaseType`
|
||||
> The format is the following: `- $packageName $releaseType`
|
||||
>
|
||||
> Where `$releaseType` is
|
||||
>
|
||||
@@ -37,14 +27,4 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @xen-orchestra/backups-cli major
|
||||
- @xen-orchestra/fs minor
|
||||
- @xen-orchestra/log minor
|
||||
- vhd-lib minor
|
||||
- xo-cli patch
|
||||
- xo-server minor
|
||||
- xo-server-transport-nagios major
|
||||
- xo-vmdk-to-vhd minor
|
||||
- xo-web minor
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
BIN
docs/assets/detach-host.png
Normal file
BIN
docs/assets/detach-host.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -570,6 +570,17 @@ If your hosts are already in a pool you only need to add your pool master host t
|
||||
Don't add pool slaves to your XOA server list! XOA will automatically find them from the master you add.
|
||||
:::
|
||||
|
||||
### Remove a host from an existing pool
|
||||
|
||||
To remove one host from a pool, you can go to the "Advanced" tab of the host page for the host you wish to remove, and click on "Detach"
|
||||
|
||||

|
||||
|
||||
:::warning
|
||||
- Detaching a host will remove all the VM disks stored on the Local Storage of this host, and reboot the host.
|
||||
- The host you want to remove must be a slave, not the master!
|
||||
:::
|
||||
|
||||
## Visualizations
|
||||
|
||||
Visualizations can help you to understand your XenServer infrastructure, as well as correlate events and detect bottlenecks.
|
||||
|
||||
13
package.json
13
package.json
@@ -3,10 +3,9 @@
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/eslint-parser": "^7.13.8",
|
||||
"@babel/register": "^7.0.0",
|
||||
"@vates/async-each": "1.0.0",
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"babel-jest": "^29.0.3",
|
||||
"benchmark": "^2.1.4",
|
||||
"deptree": "^1.0.0",
|
||||
"eslint": "^8.7.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-config-standard": "^17.0.0",
|
||||
@@ -28,6 +27,7 @@
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"semver": "^7.3.6",
|
||||
"sorted-object": "^2.0.1",
|
||||
"vue": "^2.7.14",
|
||||
"vuepress": "^1.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -84,10 +84,10 @@
|
||||
"build": "scripts/run-script.js --parallel --concurrency 2 build",
|
||||
"ci": "yarn && yarn build && yarn test-lint && yarn test-integration",
|
||||
"clean": "scripts/run-script.js --parallel clean",
|
||||
"dev": "scripts/run-script.js --parallel dev",
|
||||
"dev": "scripts/run-script.js --parallel --concurrency 0 --verbose dev",
|
||||
"dev-test": "jest --bail --watch \"^(?!.*\\.integ\\.spec\\.js$)\"",
|
||||
"docs:dev": "vuepress dev docs",
|
||||
"docs:build": "vuepress build docs",
|
||||
"docs:dev": "NODE_OPTIONS=--openssl-legacy-provider vuepress dev docs",
|
||||
"docs:build": "NODE_OPTIONS=--openssl-legacy-provider vuepress build docs",
|
||||
"prepare": "husky install",
|
||||
"prettify": "prettier --ignore-path .gitignore --write '**/*.{cjs,js,jsx,md,mjs,ts,tsx}'",
|
||||
"test": "npm run test-lint && npm run test-unit",
|
||||
@@ -98,5 +98,6 @@
|
||||
"workspaces": [
|
||||
"@*/*",
|
||||
"packages/*"
|
||||
]
|
||||
],
|
||||
"packageManager": "yarn@1.22.19"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"node": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"cli-progress": "^3.1.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"getopts": "^2.2.3",
|
||||
@@ -31,7 +31,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.1.1"
|
||||
"vhd-lib": "^4.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -8,7 +8,7 @@ const tmp = require('tmp')
|
||||
const { getSyncedHandler } = require('@xen-orchestra/fs')
|
||||
const { pFromCallback, Disposable } = require('promise-toolbox')
|
||||
|
||||
const { VhdFile, chainVhd, openVhd } = require('./index')
|
||||
const { VhdFile, chainVhd, openVhd, VhdAbstract } = require('./index')
|
||||
const { mergeVhdChain } = require('./merge')
|
||||
|
||||
const { checkFile, createRandomFile, convertFromRawToVhd } = require('./tests/utils')
|
||||
@@ -163,25 +163,29 @@ test('it can resume a simple merge ', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('it can resume a failed renaming ', async () => {
|
||||
test('it can resume a failed renaming', async () => {
|
||||
const mbOfFather = 8
|
||||
const mbOfChildren = 4
|
||||
const parentRandomFileName = `${tempDir}/randomfile`
|
||||
|
||||
const parentName = 'parentvhd.alias.vhd'
|
||||
const childName = 'childvhd.alias.vhd'
|
||||
|
||||
await createRandomFile(`${tempDir}/randomfile`, mbOfFather)
|
||||
await convertFromRawToVhd(`${tempDir}/randomfile`, `${tempDir}/parent.vhd`)
|
||||
const parentVhd = new VhdFile(handler, 'parent.vhd')
|
||||
await convertFromRawToVhd(`${tempDir}/randomfile`, `${tempDir}/parentdata.vhd`)
|
||||
VhdAbstract.createAlias(handler, parentName, 'parentdata.vhd')
|
||||
const parentVhd = new VhdFile(handler, 'parentdata.vhd')
|
||||
await parentVhd.readHeaderAndFooter()
|
||||
|
||||
await createRandomFile(`${tempDir}/small_randomfile`, mbOfChildren)
|
||||
await convertFromRawToVhd(`${tempDir}/small_randomfile`, `${tempDir}/child1.vhd`)
|
||||
await chainVhd(handler, 'parent.vhd', handler, 'child1.vhd', true)
|
||||
|
||||
const childVhd = new VhdFile(handler, 'child1.vhd')
|
||||
await convertFromRawToVhd(`${tempDir}/small_randomfile`, `${tempDir}/childdata.vhd`)
|
||||
await chainVhd(handler, 'parentdata.vhd', handler, 'childdata.vhd', true)
|
||||
VhdAbstract.createAlias(handler, childName, 'childdata.vhd')
|
||||
const childVhd = new VhdFile(handler, 'childdata.vhd')
|
||||
await childVhd.readHeaderAndFooter()
|
||||
|
||||
await handler.writeFile(
|
||||
'.parent.vhd.merge.json',
|
||||
`.${parentName}.merge.json`,
|
||||
JSON.stringify({
|
||||
parent: {
|
||||
header: parentVhd.header.checksum,
|
||||
@@ -192,15 +196,20 @@ test('it can resume a failed renaming ', async () => {
|
||||
step: 'cleanupVhds',
|
||||
})
|
||||
)
|
||||
// expect merge to succed
|
||||
await mergeVhdChain(handler, ['parent.vhd', 'child1.vhd'])
|
||||
// parent have been renamed
|
||||
expect(await fs.exists(`${tempDir}/parent.vhd`)).toBeFalsy()
|
||||
expect(await fs.exists(`${tempDir}/.parent.vhd.merge.json`)).toBeFalsy()
|
||||
// expect merge to succeed
|
||||
await mergeVhdChain(handler, [parentName, childName])
|
||||
|
||||
Disposable.use(openVhd(handler, 'child1.vhd'), async mergedVhd => {
|
||||
// parent have been renamed
|
||||
expect(await fs.exists(`${tempDir}/${parentName}`)).toBeFalsy()
|
||||
expect(await fs.exists(`${tempDir}/${childName}`)).toBeTruthy()
|
||||
expect(await fs.exists(`${tempDir}/.${parentName}.merge.json`)).toBeFalsy()
|
||||
// we shouldn't have moved the data, but the child data should have been merged into parent
|
||||
expect(await fs.exists(`${tempDir}/parentdata.vhd`)).toBeTruthy()
|
||||
expect(await fs.exists(`${tempDir}/childdata.vhd`)).toBeFalsy()
|
||||
|
||||
Disposable.use(openVhd(handler, childName), async mergedVhd => {
|
||||
await mergedVhd.readBlockAllocationTable()
|
||||
// the resume is at the step 'cleanupVhds' it should not have merged blocks and should still contians parent data
|
||||
// the resume is at the step 'cleanupVhds' it should not have merged blocks and should still contains parent data
|
||||
|
||||
let offset = 0
|
||||
const fd = await fs.open(parentRandomFileName, 'r')
|
||||
@@ -208,16 +217,14 @@ test('it can resume a failed renaming ', async () => {
|
||||
const blockContent = block.data
|
||||
const buffer = Buffer.alloc(blockContent.length)
|
||||
await fs.read(fd, buffer, 0, buffer.length, offset)
|
||||
|
||||
expect(buffer.equals(blockContent)).toEqual(true)
|
||||
offset += childVhd.header.blockSize
|
||||
}
|
||||
})
|
||||
|
||||
// merge succeed if renaming was already done
|
||||
|
||||
await handler.writeFile(
|
||||
'.parent.vhd.merge.json',
|
||||
`.${parentName}.merge.json`,
|
||||
JSON.stringify({
|
||||
parent: {
|
||||
header: parentVhd.header.checksum,
|
||||
@@ -228,10 +235,13 @@ test('it can resume a failed renaming ', async () => {
|
||||
step: 'cleanupVhds',
|
||||
})
|
||||
)
|
||||
await mergeVhdChain(handler, ['parent.vhd', 'child1.vhd'])
|
||||
expect(await fs.exists(`${tempDir}/parent.vhd`)).toBeFalsy()
|
||||
expect(await fs.exists(`${tempDir}/child1.vhd`)).toBeTruthy()
|
||||
expect(await fs.exists(`${tempDir}/.parent.vhd.merge.json`)).toBeFalsy()
|
||||
await mergeVhdChain(handler, [parentName, childName])
|
||||
expect(await fs.exists(`${tempDir}/${parentName}`)).toBeFalsy()
|
||||
expect(await fs.exists(`${tempDir}/${childName}`)).toBeTruthy()
|
||||
// we shouldn't have moved the data, but the child data should have been merged into parent
|
||||
expect(await fs.exists(`${tempDir}/parentdata.vhd`)).toBeTruthy()
|
||||
expect(await fs.exists(`${tempDir}/childdata.vhd`)).toBeFalsy()
|
||||
expect(await fs.exists(`${tempDir}/.${parentName}.merge.json`)).toBeFalsy()
|
||||
})
|
||||
|
||||
test('it can resume a multiple merge ', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const { VhdAbstract } = require('./Vhd/VhdAbstract')
|
||||
const { VhdDirectory } = require('./Vhd/VhdDirectory')
|
||||
const { VhdSynthetic } = require('./Vhd/VhdSynthetic')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { isVhdAlias, resolveVhdAlias } = require('./aliases')
|
||||
|
||||
const { warn } = createLogger('vhd-lib:merge')
|
||||
|
||||
@@ -253,10 +254,16 @@ class Merger {
|
||||
// in the case is an alias, renaming parent to mergeTargetChild will keep the real data
|
||||
// of mergeTargetChild in the data folder
|
||||
// mergeTargetChild is already in an incomplete state, its blocks have been transferred to parent
|
||||
await VhdAbstract.unlink(handler, mergeTargetChild)
|
||||
let oldTarget
|
||||
if (isVhdAlias(mergeTargetChild)) {
|
||||
oldTarget = await resolveVhdAlias(handler, mergeTargetChild)
|
||||
}
|
||||
|
||||
try {
|
||||
await handler.rename(parent, mergeTargetChild)
|
||||
if (oldTarget !== undefined) {
|
||||
await VhdAbstract.unlink(handler, oldTarget).catch(warn)
|
||||
}
|
||||
} catch (error) {
|
||||
// maybe the renaming was already successfull during merge
|
||||
if (error.code === 'ENOENT' && this.#isResuming) {
|
||||
@@ -265,6 +272,8 @@ class Merger {
|
||||
assert.strictEqual(vhd.header.checksum, this.#state.parent.header)
|
||||
})
|
||||
this.#logInfo(`the VHD parent was already renamed`, { parent, mergeTargetChild })
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "vhd-lib",
|
||||
"version": "4.1.1",
|
||||
"version": "4.2.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Primitives for VHD file handling",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/vhd-lib",
|
||||
@@ -18,8 +18,8 @@
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"async-iterator-to-stream": "^1.0.2",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
@@ -31,7 +31,7 @@
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"execa": "^5.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"rimraf": "^3.0.2",
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"promise-toolbox": "^0.19.2",
|
||||
"readable-stream": "^3.1.1",
|
||||
"throttle": "^1.0.3",
|
||||
"vhd-lib": "^4.1.1"
|
||||
"vhd-lib": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xo-cli",
|
||||
"version": "0.14.1",
|
||||
"version": "0.14.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Basic CLI for Xen-Orchestra",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-audit",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Audit plugin for XO-Server",
|
||||
"keywords": [
|
||||
@@ -44,9 +44,9 @@
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/audit-core": "^0.2.1",
|
||||
"@xen-orchestra/audit-core": "^0.2.2",
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"readable-stream": "^4.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-auth-ldap",
|
||||
"version": "0.10.5",
|
||||
"version": "0.10.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "LDAP authentication plugin for XO-Server",
|
||||
"keywords": [
|
||||
@@ -31,7 +31,7 @@
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"ensure-array": "^1.0.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"inquirer": "^8.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-backup-reports",
|
||||
"version": "0.17.1",
|
||||
"version": "0.17.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Backup reports plugin for XO-Server",
|
||||
"keywords": [
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/defined": "^0.0.1",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"human-format": "^1.0.0",
|
||||
"lodash": "^4.13.1",
|
||||
"moment-timezone": "^0.5.13"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-load-balancer",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Load balancer for XO-Server",
|
||||
"keywords": [
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"lodash": "^4.16.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-netbox",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Synchronizes pools managed by Xen Orchestra with Netbox",
|
||||
"keywords": [
|
||||
@@ -29,7 +29,7 @@
|
||||
"node": ">=14.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"ipaddr.js": "^2.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"semver": "^7.3.5"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"predev": "yarn run prebuild",
|
||||
"prepublishOnly": "yarn run build"
|
||||
},
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/coalesce-calls": "^0.1.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/openflow": "^0.1.2",
|
||||
"ipaddr.js": "^2.0.1",
|
||||
"lodash": "^4.17.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-transport-nagios",
|
||||
"version": "0.1.2",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Send backup runs statuses to Nagios",
|
||||
"keywords": [
|
||||
@@ -28,7 +28,8 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"buffer-crc32": "^0.2.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,11 +2,24 @@ import crc32 from 'buffer-crc32'
|
||||
import net from 'net'
|
||||
import { Buffer } from 'buffer'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { compileTemplate } from '@xen-orchestra/template'
|
||||
|
||||
const { debug, warn } = createLogger('xo:server:transport:nagios')
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const hostDescription = `Host name on Nagios.
|
||||
|
||||
Leave empty if the host name equals the vm name (the default configuration).
|
||||
|
||||
Otherwise, you could choose a custom name but the template \`{vm.name_label}\` must be included. For example: \`xo-backup-{vm.name_label}\`.`
|
||||
|
||||
const serviceDescription = `Service name on Nagios.
|
||||
|
||||
Leave empty if the host name equals the backup job name (the default configuration).
|
||||
|
||||
Otherwise, you could choose a custom name but the template \`{job.name}\` must e included. For example: \`{job.name}-Xen Orchestra\`.`
|
||||
|
||||
export const configurationSchema = {
|
||||
type: 'object',
|
||||
|
||||
@@ -23,6 +36,16 @@ export const configurationSchema = {
|
||||
type: 'string',
|
||||
description: 'The encryption key',
|
||||
},
|
||||
host: {
|
||||
default: '{vm.name_label}',
|
||||
description: hostDescription,
|
||||
type: 'string',
|
||||
},
|
||||
service: {
|
||||
default: '{job.name}',
|
||||
description: serviceDescription,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
required: ['server', 'port', 'key'],
|
||||
@@ -31,16 +54,18 @@ export const configurationSchema = {
|
||||
export const testSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: {
|
||||
description: 'Nagios host',
|
||||
VmNameLabel: {
|
||||
title: 'VM Name Label',
|
||||
description: 'Name of a VM',
|
||||
type: 'string',
|
||||
},
|
||||
service: {
|
||||
description: 'Nagios service',
|
||||
jobName: {
|
||||
title: 'Job Name',
|
||||
description: 'Name of a backup job',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['host', 'service'],
|
||||
required: ['VmNameLabel', 'jobName'],
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
@@ -96,9 +121,17 @@ class XoServerNagios {
|
||||
this._key = null
|
||||
}
|
||||
|
||||
configure(configuration) {
|
||||
configure({ host, service, ...configuration }) {
|
||||
this._conf = configuration
|
||||
this._key = Buffer.from(configuration.key, ENCODING)
|
||||
|
||||
const templateRules = {
|
||||
'{vm.name_label}': vmNameLabel => vmNameLabel,
|
||||
'{job.name}': (vmNameLabel, jobName) => jobName,
|
||||
}
|
||||
|
||||
this._getHost = compileTemplate(host, templateRules)
|
||||
this._getService = compileTemplate(service, templateRules)
|
||||
}
|
||||
|
||||
load() {
|
||||
@@ -109,21 +142,24 @@ class XoServerNagios {
|
||||
this._unset()
|
||||
}
|
||||
|
||||
test({ host, service }) {
|
||||
test({ VmNameLabel, jobName }) {
|
||||
return this._sendPassiveCheck(
|
||||
{
|
||||
message: 'The server-nagios plugin for Xen Orchestra server seems to be working fine, nicely done :)',
|
||||
status: OK,
|
||||
},
|
||||
host,
|
||||
service
|
||||
VmNameLabel,
|
||||
jobName
|
||||
)
|
||||
}
|
||||
|
||||
_sendPassiveCheck({ message, status }, host, service) {
|
||||
_sendPassiveCheck({ message, status }, vmNameLabel, jobName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._conf.host = host
|
||||
this._conf.service = service
|
||||
const conf = {
|
||||
...this._conf,
|
||||
host: this._getHost(vmNameLabel, jobName),
|
||||
service: this._getService(vmNameLabel, jobName),
|
||||
}
|
||||
|
||||
if (/\r|\n/.test(message)) {
|
||||
warn('the message must not contain a line break', { message })
|
||||
@@ -139,7 +175,7 @@ class XoServerNagios {
|
||||
|
||||
const client = new net.Socket()
|
||||
|
||||
client.connect(this._conf.port, this._conf.server, () => {
|
||||
client.connect(conf.port, conf.server, () => {
|
||||
debug('Successful connection')
|
||||
})
|
||||
|
||||
@@ -147,7 +183,7 @@ class XoServerNagios {
|
||||
const timestamp = data.readInt32BE(128)
|
||||
const iv = data.slice(0, 128) // initialization vector
|
||||
const packet = nscaPacketBuilder({
|
||||
...this._conf,
|
||||
...conf,
|
||||
iv,
|
||||
message,
|
||||
status,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-usage-report",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Report resources usage with their evolution",
|
||||
"keywords": [
|
||||
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"csv-stringify": "^6.0.0",
|
||||
"handlebars": "^4.0.6",
|
||||
"html-minifier": "^4.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-web-hooks",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Sends HTTP requests on XO-Server API calls",
|
||||
"keywords": [
|
||||
@@ -29,7 +29,7 @@
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.4.0"
|
||||
"@xen-orchestra/log": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.7.0",
|
||||
|
||||
@@ -103,7 +103,7 @@ writeBlockConcurrency = 16
|
||||
# This is a work-around.
|
||||
#
|
||||
# See https://github.com/vatesfr/xen-orchestra/pull/4674
|
||||
maxMergedDeltasPerRun = 2
|
||||
maxMergedDeltasPerRun = inf
|
||||
|
||||
# https://github.com/naugtur/blocked-at#params-and-return-value
|
||||
[blockedAtOptions]
|
||||
@@ -175,7 +175,7 @@ poolMarkingPrefix = 'xo:clientInfo:'
|
||||
[xo-proxy]
|
||||
callTimeout = '1 min'
|
||||
|
||||
channel = 'xo-proxy-appliance-{xoChannel}'
|
||||
channel = 'xo-proxy-appliance-latest'
|
||||
|
||||
namespace = 'xoProxyAppliance'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-server",
|
||||
"version": "5.106.1",
|
||||
"version": "5.107.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Server part of Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -33,25 +33,26 @@
|
||||
"@vates/cached-dns.lookup": "^1.0.0",
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.2",
|
||||
"@vates/disposable": "^0.1.3",
|
||||
"@vates/event-listeners-manager": "^1.0.1",
|
||||
"@vates/otp": "^1.0.0",
|
||||
"@vates/multi-key-map": "^0.1.0",
|
||||
"@vates/otp": "^1.0.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@vates/predicates": "^1.1.0",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.29.0",
|
||||
"@xen-orchestra/backups": "^0.29.4",
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/defined": "^0.0.1",
|
||||
"@xen-orchestra/emit-async": "^1.0.0",
|
||||
"@xen-orchestra/fs": "^3.2.0",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/fs": "^3.3.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.8.1",
|
||||
"@xen-orchestra/mixins": "^0.8.2",
|
||||
"@xen-orchestra/self-signed": "^0.1.3",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"@xen-orchestra/xapi": "^1.5.2",
|
||||
"@xen-orchestra/vmware-explorer": "^0.0.2",
|
||||
"@xen-orchestra/xapi": "^1.6.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^2.3.0",
|
||||
"async-iterator-to-stream": "^1.0.1",
|
||||
@@ -127,7 +128,7 @@
|
||||
"unzipper": "^0.10.5",
|
||||
"uuid": "^9.0.0",
|
||||
"value-matcher": "^0.2.0",
|
||||
"vhd-lib": "^4.1.1",
|
||||
"vhd-lib": "^4.2.0",
|
||||
"ws": "^8.2.3",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"xen-api": "^1.2.2",
|
||||
@@ -135,7 +136,7 @@
|
||||
"xo-collection": "^0.5.0",
|
||||
"xo-common": "^0.8.0",
|
||||
"xo-remote-parser": "^0.9.2",
|
||||
"xo-vmdk-to-vhd": "^2.4.3"
|
||||
"xo-vmdk-to-vhd": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -5,6 +5,11 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
const { warn } = createLogger('xo:server:handleBackupLog')
|
||||
|
||||
async function sendToNagios(app, jobName, vmBackupInfo) {
|
||||
if (app.sendPassiveCheck === undefined) {
|
||||
// Nagios plugin is not loaded
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messageToNagios = {
|
||||
id: vmBackupInfo.id,
|
||||
|
||||
@@ -596,8 +596,8 @@ migrate.resolve = {
|
||||
migrationNetwork: ['migrationNetwork', 'network', 'administrate'],
|
||||
}
|
||||
|
||||
export async function warmMigration({ vm, sr, startVm, deleteSource }) {
|
||||
await this.warmMigrateVm(vm, sr, startVm, deleteSource)
|
||||
export async function warmMigration({ vm, sr, startDestinationVm, deleteSourceVm }) {
|
||||
await this.warmMigrateVm(vm, sr, startDestinationVm, deleteSourceVm)
|
||||
}
|
||||
warmMigration.permission = 'admin'
|
||||
|
||||
@@ -1298,6 +1298,25 @@ import_.resolve = {
|
||||
|
||||
export { import_ as import }
|
||||
|
||||
|
||||
|
||||
export async function importFomEsxi({host, user, password, sslVerify=true, sr, network, vm, thin=false}){
|
||||
return await this.migrationfromEsxi({host, user, password, sslVerify, thin, vm, sr, network})
|
||||
|
||||
}
|
||||
|
||||
importFomEsxi.params = {
|
||||
host: { type: 'string' },
|
||||
network: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
user: { type: 'string' },
|
||||
sr: { type: 'string' },
|
||||
sslVerify: {type: 'boolean', optional: true},
|
||||
vm:{type: 'string'},
|
||||
thin:{type: 'boolean', optional: true}
|
||||
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// FIXME: if position is used, all other disks after this position
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Backup } from '@xen-orchestra/backups/Backup.js'
|
||||
import { v4 as generateUuid } from 'uuid'
|
||||
import Esxi from '@xen-orchestra/vmware-explorer/esxi.mjs'
|
||||
import OTHER_CONFIG_TEMPLATE from '../xapi/other-config-template.mjs'
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
|
||||
import { fromEvent } from 'promise-toolbox'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
|
||||
export default class MigrateVm {
|
||||
constructor(app) {
|
||||
@@ -66,7 +71,7 @@ export default class MigrateVm {
|
||||
|
||||
// run the transfer again to transfer the changed parts
|
||||
// since the source is stopped, there won't be any new change after
|
||||
backup = this.#createWarmBackup(sourceVmId, srId)
|
||||
backup = this.#createWarmBackup(sourceVmId, srId, jobId)
|
||||
await backup.run()
|
||||
// find the destination Vm
|
||||
const targets = Object.keys(
|
||||
@@ -106,4 +111,116 @@ export default class MigrateVm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async migrationfromEsxi({ host, user, password, sslVerify, sr: srId, network: networkId, vm: vmId, thin }) {
|
||||
const esxi = new Esxi(host, user, password, sslVerify)
|
||||
const app = this._app
|
||||
const sr = app.getXapiObject(srId)
|
||||
const xapi = sr.$xapi
|
||||
|
||||
await fromEvent(esxi, 'ready')
|
||||
const esxiVmMetadata = await esxi.getTransferableVmMetadata(vmId)
|
||||
const { memory, name_label, networks, numCpu } = esxiVmMetadata
|
||||
const vm = await xapi._getOrWaitObject(
|
||||
await xapi.VM_create({
|
||||
...OTHER_CONFIG_TEMPLATE,
|
||||
memory_dynamic_max: memory,
|
||||
memory_dynamic_min: memory,
|
||||
memory_static_max: memory,
|
||||
memory_static_min: memory,
|
||||
name_description: 'from esxi',
|
||||
name_label,
|
||||
VCPUs_at_startup: numCpu,
|
||||
VCPUs_max: numCpu,
|
||||
})
|
||||
)
|
||||
await Promise.all([
|
||||
asyncMapSettled(['start', 'start_on'], op => vm.update_blocked_operations(op, 'OVA import in progress...')),
|
||||
vm.set_name_label(`[Importing...] ${name_label}`),
|
||||
])
|
||||
|
||||
const vifDevices = await xapi.call('VM.get_allowed_VIF_devices', vm.$ref)
|
||||
|
||||
await Promise.all(
|
||||
networks.map((network, i) =>
|
||||
xapi.VIF_create({
|
||||
device: vifDevices[i],
|
||||
network: xapi.getObject(networkId).$ref,
|
||||
VM: vm.$ref,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// get the snapshot to migrate
|
||||
const snapshots = esxiVmMetadata.snapshots
|
||||
let chain =[]
|
||||
if(snapshots && snapshots.current){
|
||||
const currentSnapshotId = snapshots.current
|
||||
|
||||
let currentSnapshot = snapshots.snapshots.find(({ uid }) => uid === currentSnapshotId)
|
||||
|
||||
chain = [currentSnapshot.disks]
|
||||
while ((currentSnapshot = snapshots.snapshots.find(({ uid }) => uid === currentSnapshot.parent))) {
|
||||
chain.push(currentSnapshot.disks)
|
||||
}
|
||||
chain.reverse()
|
||||
}
|
||||
|
||||
chain.push(esxiVmMetadata.disks)
|
||||
|
||||
const chainsByNodes = {}
|
||||
chain.forEach(disks => {
|
||||
disks.forEach(disk => {
|
||||
chainsByNodes[disk.node] = chainsByNodes[disk.node] || []
|
||||
chainsByNodes[disk.node].push(disk)
|
||||
})
|
||||
})
|
||||
let userdevice = 0
|
||||
for (const node in chainsByNodes) {
|
||||
const chainByNode = chainsByNodes[node]
|
||||
const vdi = await xapi._getOrWaitObject(
|
||||
await xapi.VDI_create({
|
||||
name_description: 'fromESXI' + chainByNode[0].descriptionLabel,
|
||||
name_label: '[ESXI]' + chainByNode[0].nameLabel,
|
||||
SR: sr.$ref,
|
||||
virtual_size: chainByNode[0].capacity,
|
||||
})
|
||||
)
|
||||
console.log('vdi created')
|
||||
|
||||
await xapi.VBD_create({
|
||||
userdevice: String(userdevice),
|
||||
VDI: vdi.$ref,
|
||||
VM: vm.$ref,
|
||||
})
|
||||
console.log('vbd created')
|
||||
for (const disk of chainByNode) {
|
||||
// the first one is a RAW disk ( full )
|
||||
|
||||
console.log('will import ', { disk })
|
||||
let format = VDI_FORMAT_VHD
|
||||
let stream
|
||||
if (!thin) {
|
||||
stream = await disk.rawStream()
|
||||
format = VDI_FORMAT_RAW
|
||||
}
|
||||
if (!stream) {
|
||||
stream = await disk.vhd()
|
||||
}
|
||||
console.log('will import in format ', { disk, format })
|
||||
await vdi.$importContent(stream, { format })
|
||||
// for now we don't handle snapshots
|
||||
break
|
||||
}
|
||||
userdevice ++
|
||||
}
|
||||
console.log('disks created')
|
||||
// remove the importing in label
|
||||
await vm.set_name_label(esxiVmMetadata.name_label)
|
||||
|
||||
// remove lock on start
|
||||
await asyncMapSettled(['start', 'start_on'], op => vm.update_blocked_operations(op, null))
|
||||
|
||||
return vm.uuid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Ajv from 'ajv'
|
||||
import cloneDeep from 'lodash/cloneDeep.js'
|
||||
import mapToArray from 'lodash/map.js'
|
||||
import noop from 'lodash/noop.js'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
@@ -153,22 +154,18 @@ export default class {
|
||||
}
|
||||
|
||||
const validate = this._ajv.compile(configurationSchema)
|
||||
|
||||
// deep clone the configuration to avoid modifying the parameter
|
||||
configuration = cloneDeep(configuration)
|
||||
|
||||
if (!validate(configuration)) {
|
||||
throw invalidParameters(validate.errors)
|
||||
}
|
||||
|
||||
// Sets the plugin configuration.
|
||||
await plugin.instance.configure(
|
||||
{
|
||||
// Shallow copy of the configuration object to avoid most of the
|
||||
// errors when the plugin is altering the configuration object
|
||||
// which is handed over to it.
|
||||
...configuration,
|
||||
},
|
||||
{
|
||||
loaded: plugin.loaded,
|
||||
}
|
||||
)
|
||||
await plugin.instance.configure(configuration, {
|
||||
loaded: plugin.loaded,
|
||||
})
|
||||
plugin.configured = true
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export default class {
|
||||
const handlers = this._handlers
|
||||
for (const id in handlers) {
|
||||
try {
|
||||
delete handlers[id]
|
||||
await handlers[id].forget()
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -251,8 +252,10 @@ export default class {
|
||||
}
|
||||
|
||||
async removeRemote(id) {
|
||||
const handler = this._handlers[id]
|
||||
const handlers = this._handlers
|
||||
const handler = handlers[id]
|
||||
if (handler !== undefined) {
|
||||
delete handlers[id]
|
||||
ignoreErrors.call(handler.forget())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xo-vmdk-to-vhd",
|
||||
"version": "2.4.3",
|
||||
"version": "2.5.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "JS lib reading and writing .vmdk and .ova files",
|
||||
"keywords": [
|
||||
@@ -26,7 +26,7 @@
|
||||
"pako": "^2.0.4",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"tar-stream": "^2.2.0",
|
||||
"vhd-lib": "^4.1.1",
|
||||
"vhd-lib": "^4.2.0",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-web",
|
||||
"version": "5.107.0",
|
||||
"version": "5.109.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Web interface client for Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -40,7 +40,7 @@
|
||||
"@nraynaud/novnc": "0.6.1",
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/defined": "^0.0.1",
|
||||
"@xen-orchestra/log": "^0.4.0",
|
||||
"@xen-orchestra/log": "^0.5.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"ansi_up": "^4.0.3",
|
||||
"asap": "^2.0.6",
|
||||
@@ -138,7 +138,7 @@
|
||||
"xo-common": "^0.8.0",
|
||||
"xo-lib": "^0.11.1",
|
||||
"xo-remote-parser": "^0.9.2",
|
||||
"xo-vmdk-to-vhd": "^2.4.3"
|
||||
"xo-vmdk-to-vhd": "^2.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "GIT_HEAD=$(git rev-parse HEAD) NODE_ENV=production gulp build",
|
||||
|
||||
@@ -245,7 +245,7 @@ export default {
|
||||
// Original text: "Add your XCP-ng hosts or pools"
|
||||
homeWelcomeText: 'Añade tus hosts/pools de XenServer',
|
||||
|
||||
// Original text: 'Some XenServers have been registered but are not connected'
|
||||
// Original text: 'Some XCP-ng hosts have been registered but are not connected'
|
||||
homeConnectServerText: undefined,
|
||||
|
||||
// Original text: "Want some help?"
|
||||
|
||||
@@ -246,10 +246,10 @@ export default {
|
||||
homeWelcome: 'Bienvenue sur Xen Orchestra !',
|
||||
|
||||
// Original text: "Add your XCP-ng hosts or pools"
|
||||
homeWelcomeText: 'Ajouter vos serveurs ou pools XenServer',
|
||||
homeWelcomeText: 'Ajouter vos serveurs ou pools XCP-ng',
|
||||
|
||||
// Original text: "Some XenServers have been registered but are not connected"
|
||||
homeConnectServerText: "Des XenServers sont enregistrés mais aucun n'est connecté",
|
||||
// Original text: "Some XCP-ng hosts have been registered but are not connected"
|
||||
homeConnectServerText: "Des hôtes XCP-ng sont enregistrés mais aucun n'est connecté",
|
||||
|
||||
// Original text: "Want some help?"
|
||||
homeHelp: "Besoin d'aide ?",
|
||||
|
||||
@@ -228,10 +228,10 @@ export default {
|
||||
homeWelcome: 'Üdvözöljük a Felhőben!',
|
||||
|
||||
// Original text: "Add your XCP-ng hosts or pools"
|
||||
homeWelcomeText: 'Hozzáadása your XenServer kiszolgálók or pools',
|
||||
homeWelcomeText: 'Hozzáadása your XCP-ng kiszolgálók or pools',
|
||||
|
||||
// Original text: "Some XenServers have been registered but are not connected"
|
||||
homeConnectServerText: 'Some XenServers have been registered but are not Kapcsolódva',
|
||||
// Original text: "Some XCP-ng hosts have been registered but are not connected"
|
||||
homeConnectServerText: 'Some XCP-ng hosts have been registered but are not connected',
|
||||
|
||||
// Original text: "Want some help?"
|
||||
homeHelp: 'Segítségre van szüksége?',
|
||||
|
||||
@@ -516,10 +516,10 @@ export default {
|
||||
homeWelcome: 'Benvenuti in Xen Orchestra!',
|
||||
|
||||
// Original text: 'Add your XCP-ng hosts or pools'
|
||||
homeWelcomeText: 'Aggiungi i tuoi hosts o pools XenServer',
|
||||
homeWelcomeText: 'Aggiungi i tuoi hosts o pools XCP-ng',
|
||||
|
||||
// Original text: 'Some XenServers have been registered but are not connected'
|
||||
homeConnectServerText: 'Alcuni XenServers sono stati registrati ma non sono collegati',
|
||||
// Original text: 'Some XCP-ng hosts have been registered but are not connected'
|
||||
homeConnectServerText: 'Alcuni XCP-ng hosts sono stati registrati ma non sono collegati',
|
||||
|
||||
// Original text: 'Want some help?'
|
||||
homeHelp: 'Vuoi un aiuto?',
|
||||
@@ -5671,9 +5671,6 @@ export default {
|
||||
// Original text: 'SSH key'
|
||||
recipeSshKeyLabel: 'Chiave SSH',
|
||||
|
||||
// Original text: 'Network CIDR'
|
||||
recipeNetworkCidr: 'Rete CIDR',
|
||||
|
||||
// Original text: 'Action/Event'
|
||||
auditActionEvent: 'Azione/Evento',
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ export default {
|
||||
// Original text: "Add your XCP-ng hosts or pools"
|
||||
homeWelcomeText: 'XenServer sunucu veya havuzunu ekle',
|
||||
|
||||
// Original text: "Some XenServers have been registered but are not connected"
|
||||
// Original text: "Some XCP-ng hosts have been registered but are not connected"
|
||||
homeConnectServerText: "Bazı XenServer'lar kayıtlı ama bağlı değil",
|
||||
|
||||
// Original text: "Want some help?"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user