Compare commits
13 Commits
fix_stats_
...
feat_dedup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
862d9a6a7f | ||
|
|
06cabcfb21 | ||
|
|
50f378ec1e | ||
|
|
506a6aad08 | ||
|
|
447112b583 | ||
|
|
b380e085d2 | ||
|
|
d752b1ed70 | ||
|
|
16f4fcfd04 | ||
|
|
69a0e0e563 | ||
|
|
456e4f213b | ||
|
|
a6d24a6dfa | ||
|
|
391c778515 | ||
|
|
4e125ede88 |
@@ -1,11 +1,8 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
arrowParens: 'avoid',
|
arrowParens: 'avoid',
|
||||||
jsxSingleQuote: true,
|
jsxSingleQuote: true,
|
||||||
semi: false,
|
semi: false,
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
trailingComma: 'es5',
|
|
||||||
|
|
||||||
// 2020-11-24: Requested by nraynaud and approved by the rest of the team
|
// 2020-11-24: Requested by nraynaud and approved by the rest of the team
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@vates/fuse-vhd",
|
"name": "@vates/fuse-vhd",
|
||||||
"version": "2.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"private": false,
|
"private": false,
|
||||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
|
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
|
||||||
|
|||||||
@@ -13,18 +13,18 @@
|
|||||||
"url": "https://vates.fr"
|
"url": "https://vates.fr"
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"version": "2.0.0",
|
"version": "1.2.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0"
|
"node": ">=14.0"
|
||||||
},
|
},
|
||||||
"main": "./index.mjs",
|
"main": "./index.mjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vates/async-each": "^1.0.0",
|
"@vates/async-each": "^1.0.0",
|
||||||
"@vates/read-chunk": "^1.2.0",
|
"@vates/read-chunk": "^1.1.1",
|
||||||
"@xen-orchestra/async-map": "^0.1.2",
|
"@xen-orchestra/async-map": "^0.1.2",
|
||||||
"@xen-orchestra/log": "^0.6.0",
|
"@xen-orchestra/log": "^0.6.0",
|
||||||
"promise-toolbox": "^0.21.0",
|
"promise-toolbox": "^0.21.0",
|
||||||
"xen-api": "^1.3.4"
|
"xen-api": "^1.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tap": "^16.3.0",
|
"tap": "^16.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@vates/node-vsphere-soap",
|
"name": "@vates/node-vsphere-soap",
|
||||||
"version": "2.0.0",
|
"version": "1.0.0",
|
||||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||||
"main": "lib/client.mjs",
|
"main": "lib/client.mjs",
|
||||||
"author": "reedog117",
|
"author": "reedog117",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const assert = require('assert')
|
const assert = require('assert')
|
||||||
const isUtf8 = require('isutf8')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a chunk of data from a stream.
|
* Read a chunk of data from a stream.
|
||||||
@@ -82,13 +81,6 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
|
|||||||
|
|
||||||
if (size !== undefined && chunk.length !== size) {
|
if (size !== undefined && chunk.length !== size) {
|
||||||
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
|
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
|
||||||
|
|
||||||
// Buffer.isUtf8 is too recent for now
|
|
||||||
// @todo : replace external package by Buffer.isUtf8 when the supported version of node reach 18
|
|
||||||
|
|
||||||
if (chunk.length < 1024 && isUtf8(chunk)) {
|
|
||||||
error.text = chunk.toString('utf8')
|
|
||||||
}
|
|
||||||
Object.defineProperties(error, {
|
Object.defineProperties(error, {
|
||||||
chunk: {
|
chunk: {
|
||||||
value: chunk,
|
value: chunk,
|
||||||
|
|||||||
@@ -102,37 +102,12 @@ describe('readChunkStrict', function () {
|
|||||||
assert.strictEqual(error.chunk, undefined)
|
assert.strictEqual(error.chunk, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws if stream ends with not enough data, utf8', async () => {
|
it('throws if stream ends with not enough data', async () => {
|
||||||
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
|
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
|
||||||
assert(error instanceof Error)
|
assert(error instanceof Error)
|
||||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
|
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
|
||||||
assert.strictEqual(error.text, 'foobar')
|
|
||||||
assert.deepEqual(error.chunk, Buffer.from('foobar'))
|
assert.deepEqual(error.chunk, Buffer.from('foobar'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws if stream ends with not enough data, non utf8 ', async () => {
|
|
||||||
const source = [Buffer.alloc(10, 128), Buffer.alloc(10, 128)]
|
|
||||||
const error = await rejectionOf(readChunkStrict(makeStream(source), 30))
|
|
||||||
assert(error instanceof Error)
|
|
||||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 20, expected: 30)')
|
|
||||||
assert.strictEqual(error.text, undefined)
|
|
||||||
assert.deepEqual(error.chunk, Buffer.concat(source))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws if stream ends with not enough data, utf8 , long data', async () => {
|
|
||||||
const source = Buffer.from('a'.repeat(1500))
|
|
||||||
const error = await rejectionOf(readChunkStrict(makeStream([source]), 2000))
|
|
||||||
assert(error instanceof Error)
|
|
||||||
assert.strictEqual(error.message, `stream has ended with not enough data (actual: 1500, expected: 2000)`)
|
|
||||||
assert.strictEqual(error.text, undefined)
|
|
||||||
assert.deepEqual(error.chunk, source)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('succeed', async () => {
|
|
||||||
const source = Buffer.from('a'.repeat(20))
|
|
||||||
const chunk = await readChunkStrict(makeStream([source]), 10)
|
|
||||||
assert.deepEqual(source.subarray(10), chunk)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('skip', function () {
|
describe('skip', function () {
|
||||||
@@ -159,16 +134,6 @@ describe('skip', function () {
|
|||||||
it('returns less size if stream ends', async () => {
|
it('returns less size if stream ends', async () => {
|
||||||
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
|
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('put back if it read too much', async () => {
|
|
||||||
let source = makeStream(['foo', 'bar'])
|
|
||||||
await skip(source, 1) // read part of data chunk
|
|
||||||
const chunk = (await readChunkStrict(source, 2)).toString('utf-8')
|
|
||||||
assert.strictEqual(chunk, 'oo')
|
|
||||||
|
|
||||||
source = makeStream(['foo', 'bar'])
|
|
||||||
assert.strictEqual(await skip(source, 3), 3) // read aligned with data chunk
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('skipStrict', function () {
|
describe('skipStrict', function () {
|
||||||
@@ -179,9 +144,4 @@ describe('skipStrict', function () {
|
|||||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
|
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
|
||||||
assert.deepEqual(error.bytesSkipped, 7)
|
assert.deepEqual(error.bytesSkipped, 7)
|
||||||
})
|
})
|
||||||
it('succeed', async () => {
|
|
||||||
const source = makeStream(['foo', 'bar', 'baz'])
|
|
||||||
const res = await skipStrict(source, 4)
|
|
||||||
assert.strictEqual(res, undefined)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||||
},
|
},
|
||||||
"version": "1.2.0",
|
"version": "1.1.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10"
|
"node": ">=8.10"
|
||||||
},
|
},
|
||||||
@@ -33,8 +33,5 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"test": "^3.2.1"
|
"test": "^3.2.1"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"isutf8": "^4.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xen-orchestra/async-map": "^0.1.2",
|
"@xen-orchestra/async-map": "^0.1.2",
|
||||||
"@xen-orchestra/backups": "^0.40.0",
|
"@xen-orchestra/backups": "^0.39.0",
|
||||||
"@xen-orchestra/fs": "^4.0.1",
|
"@xen-orchestra/fs": "^4.0.1",
|
||||||
"filenamify": "^6.0.0",
|
"filenamify": "^4.1.0",
|
||||||
"getopts": "^2.2.5",
|
"getopts": "^2.2.5",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
"promise-toolbox": "^0.21.0"
|
"promise-toolbox": "^0.21.0"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"postversion": "npm publish --access public"
|
"postversion": "npm publish --access public"
|
||||||
},
|
},
|
||||||
"version": "1.0.10",
|
"version": "1.0.9",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Vates SAS",
|
"name": "Vates SAS",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createLogger } from '@xen-orchestra/log'
|
|||||||
import { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } from 'vhd-lib'
|
import { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } from 'vhd-lib'
|
||||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||||
import { deduped } from '@vates/disposable/deduped.js'
|
import { deduped } from '@vates/disposable/deduped.js'
|
||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, normalize, resolve } from 'node:path'
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { mount } from '@vates/fuse-vhd'
|
import { mount } from '@vates/fuse-vhd'
|
||||||
import { readdir, lstat } from 'node:fs/promises'
|
import { readdir, lstat } from 'node:fs/promises'
|
||||||
@@ -18,7 +18,6 @@ import fromEvent from 'promise-toolbox/fromEvent'
|
|||||||
import groupBy from 'lodash/groupBy.js'
|
import groupBy from 'lodash/groupBy.js'
|
||||||
import pDefer from 'promise-toolbox/defer'
|
import pDefer from 'promise-toolbox/defer'
|
||||||
import pickBy from 'lodash/pickBy.js'
|
import pickBy from 'lodash/pickBy.js'
|
||||||
import tar from 'tar'
|
|
||||||
import zlib from 'zlib'
|
import zlib from 'zlib'
|
||||||
|
|
||||||
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
|
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
|
||||||
@@ -42,23 +41,20 @@ const compareTimestamp = (a, b) => a.timestamp - b.timestamp
|
|||||||
const noop = Function.prototype
|
const noop = Function.prototype
|
||||||
|
|
||||||
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
|
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
|
||||||
const makeRelative = path => resolve('/', path).slice(1)
|
|
||||||
const resolveSubpath = (root, path) => resolve(root, makeRelative(path))
|
|
||||||
|
|
||||||
async function addZipEntries(zip, realBasePath, virtualBasePath, relativePaths) {
|
const resolveSubpath = (root, path) => resolve(root, `.${resolve('/', path)}`)
|
||||||
for (const relativePath of relativePaths) {
|
|
||||||
const realPath = join(realBasePath, relativePath)
|
|
||||||
const virtualPath = join(virtualBasePath, relativePath)
|
|
||||||
|
|
||||||
const stats = await lstat(realPath)
|
async function addDirectory(files, realPath, metadataPath) {
|
||||||
const { mode, mtime } = stats
|
const stats = await lstat(realPath)
|
||||||
const opts = { mode, mtime }
|
if (stats.isDirectory()) {
|
||||||
if (stats.isDirectory()) {
|
await asyncMap(await readdir(realPath), file =>
|
||||||
zip.addEmptyDirectory(virtualPath, opts)
|
addDirectory(files, realPath + '/' + file, metadataPath + '/' + file)
|
||||||
await addZipEntries(zip, realPath, virtualPath, await readdir(realPath))
|
)
|
||||||
} else if (stats.isFile()) {
|
} else if (stats.isFile()) {
|
||||||
zip.addFile(realPath, virtualPath, opts)
|
files.push({
|
||||||
}
|
realPath,
|
||||||
|
metadataPath,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +182,17 @@ export class RemoteAdapter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async *_usePartitionFiles(diskId, partitionId, paths) {
|
||||||
|
const path = yield this.getPartition(diskId, partitionId)
|
||||||
|
|
||||||
|
const files = []
|
||||||
|
await asyncMap(paths, file =>
|
||||||
|
addDirectory(files, resolveSubpath(path, file), normalize('./' + file).replace(/\/+$/, ''))
|
||||||
|
)
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
// check if we will be allowed to merge a a vhd created in this adapter
|
// check if we will be allowed to merge a a vhd created in this adapter
|
||||||
// with the vhd at path `path`
|
// with the vhd at path `path`
|
||||||
async isMergeableParent(packedParentUid, path) {
|
async isMergeableParent(packedParentUid, path) {
|
||||||
@@ -202,24 +209,15 @@ export class RemoteAdapter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchPartitionFiles(diskId, partitionId, paths, format) {
|
fetchPartitionFiles(diskId, partitionId, paths) {
|
||||||
const { promise, reject, resolve } = pDefer()
|
const { promise, reject, resolve } = pDefer()
|
||||||
Disposable.use(
|
Disposable.use(
|
||||||
async function* () {
|
async function* () {
|
||||||
const path = yield this.getPartition(diskId, partitionId)
|
const files = yield this._usePartitionFiles(diskId, partitionId, paths)
|
||||||
let outputStream
|
const zip = new ZipFile()
|
||||||
|
files.forEach(({ realPath, metadataPath }) => zip.addFile(realPath, metadataPath))
|
||||||
if (format === 'tgz') {
|
zip.end()
|
||||||
outputStream = tar.c({ cwd: path, gzip: true }, paths.map(makeRelative))
|
const { outputStream } = zip
|
||||||
} else if (format === 'zip') {
|
|
||||||
const zip = new ZipFile()
|
|
||||||
await addZipEntries(zip, path, '', paths.map(makeRelative))
|
|
||||||
zip.end()
|
|
||||||
;({ outputStream } = zip)
|
|
||||||
} else {
|
|
||||||
throw new Error('unsupported format ' + format)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(outputStream)
|
resolve(outputStream)
|
||||||
await fromEvent(outputStream, 'end')
|
await fromEvent(outputStream, 'end')
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
@@ -662,13 +660,14 @@ export class RemoteAdapter {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
|
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency, dedup = false } = {}) {
|
||||||
const handler = this._handler
|
const handler = this._handler
|
||||||
if (this.useVhdDirectory()) {
|
if (this.useVhdDirectory()) {
|
||||||
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
||||||
const size = await createVhdDirectoryFromStream(handler, dataPath, input, {
|
const size = await createVhdDirectoryFromStream(handler, dataPath, input, {
|
||||||
concurrency: writeBlockConcurrency,
|
concurrency: writeBlockConcurrency,
|
||||||
compression: this.#getCompressionType(),
|
compression: this.#getCompressionType(),
|
||||||
|
dedup,
|
||||||
async validator() {
|
async validator() {
|
||||||
await input.task
|
await input.task
|
||||||
return validator.apply(this, arguments)
|
return validator.apply(this, arguments)
|
||||||
@@ -826,6 +825,8 @@ decorateMethodsWith(RemoteAdapter, {
|
|||||||
debounceResourceFactory,
|
debounceResourceFactory,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_usePartitionFiles: Disposable.factory,
|
||||||
|
|
||||||
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
|
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
|
||||||
|
|
||||||
getPartition: Disposable.factory,
|
getPartition: Disposable.factory,
|
||||||
|
|||||||
@@ -21,12 +21,7 @@ export class RestoreMetadataBackup {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const metadata = JSON.parse(await handler.readFile(join(backupId, 'metadata.json')))
|
const metadata = JSON.parse(await handler.readFile(join(backupId, 'metadata.json')))
|
||||||
const dataFileName = resolve(backupId, metadata.data ?? 'data.json')
|
return String(await handler.readFile(resolve(backupId, metadata.data ?? 'data.json')))
|
||||||
const data = await handler.readFile(dataFileName)
|
|
||||||
|
|
||||||
// if data is JSON, sent it as a plain string, otherwise, consider the data as binary and encode it
|
|
||||||
const isJson = dataFileName.endsWith('.json')
|
|
||||||
return isJson ? data.toString() : { encoding: 'base64', data: data.toString('base64') }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,19 +123,19 @@ export async function checkAliases(
|
|||||||
) {
|
) {
|
||||||
const aliasFound = []
|
const aliasFound = []
|
||||||
for (const alias of aliasPaths) {
|
for (const alias of aliasPaths) {
|
||||||
const target = await resolveVhdAlias(handler, alias)
|
let target
|
||||||
|
|
||||||
if (!isVhdFile(target)) {
|
|
||||||
logWarn('alias references non VHD target', { alias, target })
|
|
||||||
if (remove) {
|
|
||||||
logInfo('removing alias and non VHD target', { alias, target })
|
|
||||||
await handler.unlink(target)
|
|
||||||
await handler.unlink(alias)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
target = await resolveVhdAlias(handler, alias)
|
||||||
|
|
||||||
|
if (!isVhdFile(target)) {
|
||||||
|
logWarn('alias references non VHD target', { alias, target })
|
||||||
|
if (remove) {
|
||||||
|
logInfo('removing alias and non VHD target', { alias, target })
|
||||||
|
await handler.unlink(target)
|
||||||
|
await handler.unlink(alias)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
const { dispose } = await openVhd(handler, target)
|
const { dispose } = await openVhd(handler, target)
|
||||||
try {
|
try {
|
||||||
await dispose()
|
await dispose()
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export const TAG_BASE_DELTA = 'xo:base_delta'
|
|||||||
|
|
||||||
export const TAG_COPY_SRC = 'xo:copy_of'
|
export const TAG_COPY_SRC = 'xo:copy_of'
|
||||||
|
|
||||||
const TAG_BACKUP_SR = 'xo:backup:sr'
|
|
||||||
|
|
||||||
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
||||||
const resolveUuid = async (xapi, cache, uuid, type) => {
|
const resolveUuid = async (xapi, cache, uuid, type) => {
|
||||||
if (uuid == null) {
|
if (uuid == null) {
|
||||||
@@ -159,10 +157,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
|||||||
if (detectBase) {
|
if (detectBase) {
|
||||||
const remoteBaseVmUuid = vmRecord.other_config[TAG_BASE_DELTA]
|
const remoteBaseVmUuid = vmRecord.other_config[TAG_BASE_DELTA]
|
||||||
if (remoteBaseVmUuid) {
|
if (remoteBaseVmUuid) {
|
||||||
baseVm = find(
|
baseVm = find(xapi.objects.all, obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid)
|
||||||
xapi.objects.all,
|
|
||||||
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!baseVm) {
|
if (!baseVm) {
|
||||||
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
|
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const DEFAULT_XAPI_VM_SETTINGS = {
|
|||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
copyRetention: 0,
|
copyRetention: 0,
|
||||||
deleteFirst: false,
|
deleteFirst: false,
|
||||||
|
dedup: false,
|
||||||
diskPerVmConcurrency: 0, // not limited by default
|
diskPerVmConcurrency: 0, // not limited by default
|
||||||
exportRetention: 0,
|
exportRetention: 0,
|
||||||
fullInterval: 0,
|
fullInterval: 0,
|
||||||
|
|||||||
@@ -22,13 +22,7 @@ export class XoMetadataBackup {
|
|||||||
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
|
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
|
||||||
|
|
||||||
const data = job.xoMetadata
|
const data = job.xoMetadata
|
||||||
let dataBaseName = './data'
|
const dataBaseName = './data.json'
|
||||||
|
|
||||||
// JSON data is sent as plain string, binary data is sent as an object with `data` and `encoding properties
|
|
||||||
const isJson = typeof data === 'string'
|
|
||||||
if (isJson) {
|
|
||||||
dataBaseName += '.json'
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = JSON.stringify(
|
const metadata = JSON.stringify(
|
||||||
{
|
{
|
||||||
@@ -60,7 +54,7 @@ export class XoMetadataBackup {
|
|||||||
async () => {
|
async () => {
|
||||||
const handler = adapter.handler
|
const handler = adapter.handler
|
||||||
const dirMode = this._config.dirMode
|
const dirMode = this._config.dirMode
|
||||||
await handler.outputFile(dataFileName, isJson ? data : Buffer.from(data.data, data.encoding), { dirMode })
|
await handler.outputFile(dataFileName, data, { dirMode })
|
||||||
await handler.outputFile(metaDataFileName, metadata, {
|
await handler.outputFile(metaDataFileName, metadata, {
|
||||||
dirMode,
|
dirMode,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
|||||||
)
|
)
|
||||||
|
|
||||||
metadataContent = {
|
metadataContent = {
|
||||||
|
dedup: settings.dedup,
|
||||||
jobId,
|
jobId,
|
||||||
mode: job.mode,
|
mode: job.mode,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -208,6 +209,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
|||||||
// no checksum for VHDs, because they will be invalidated by
|
// no checksum for VHDs, because they will be invalidated by
|
||||||
// merges and chainings
|
// merges and chainings
|
||||||
checksum: false,
|
checksum: false,
|
||||||
|
dedup: settings.dedup,
|
||||||
validator: tmpPath => checkVhd(handler, tmpPath),
|
validator: tmpPath => checkVhd(handler, tmpPath),
|
||||||
writeBlockConcurrency: this._config.writeBlockConcurrency,
|
writeBlockConcurrency: this._config.writeBlockConcurrency,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const MixinXapiWriter = (BaseClass = Object) =>
|
|||||||
const vdiRefs = await xapi.VM_getDisks(baseVm.$ref)
|
const vdiRefs = await xapi.VM_getDisks(baseVm.$ref)
|
||||||
for (const vdiRef of vdiRefs) {
|
for (const vdiRef of vdiRefs) {
|
||||||
const vdi = xapi.getObject(vdiRef)
|
const vdi = xapi.getObject(vdiRef)
|
||||||
if (vdi.$SR.uuid !== this._healthCheckSr.uuid) {
|
if (vdi.$SR.uuid !== this._heathCheckSr.uuid) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,34 @@ When `useVhdDirectory` is enabled on the remote, the directory containing the VH
|
|||||||
└─ <uuid>.vhd
|
└─ <uuid>.vhd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### vhd directory with deduplication
|
||||||
|
|
||||||
|
the difference with non dedup mode is that a hash is computed of each vhd block. The hash is splited in 4 chars token and the data are stored in xo-block-store/{token1}/.../{token7}/{token8}.source.
|
||||||
|
Then a hard link is made from this source to the destination folder in <vdis>/<job UUID>/<VDI UUID>/blocks/{number}/{number}
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
<remote>
|
||||||
|
└─ xo-block-store
|
||||||
|
└─ {4 char}
|
||||||
|
└─ ...
|
||||||
|
└─ {char.source}
|
||||||
|
└─ xo-vm-backups
|
||||||
|
├─ index.json // TODO
|
||||||
|
└─ <VM UUID>
|
||||||
|
├─ cache.json.gz
|
||||||
|
├─ vdis
|
||||||
|
│ └─ <job UUID>
|
||||||
|
│ └─ <VDI UUID>
|
||||||
|
│ ├─ index.json // TODO
|
||||||
|
│ ├─ <YYYYMMDD>T<HHmmss>.alias.vhd // contains the relative path to a VHD directory
|
||||||
|
| └─ data
|
||||||
|
| ├─ <uuid>.vhd // VHD directory format is described in vhd-lib/Vhd/VhdDirectory.js
|
||||||
|
├─ <YYYYMMDD>T<HHmmss>.json // backup metadata
|
||||||
|
├─ <YYYYMMDD>T<HHmmss>.xva
|
||||||
|
└─ <YYYYMMDD>T<HHmmss>.xva.checksum
|
||||||
|
```
|
||||||
|
|
||||||
## Cache for a VM
|
## Cache for a VM
|
||||||
|
|
||||||
In a VM directory, if the file `cache.json.gz` exists, it contains the metadata for all the backups for this VM.
|
In a VM directory, if the file `cache.json.gz` exists, it contains the metadata for all the backups for this VM.
|
||||||
|
|||||||
23
@xen-orchestra/backups/docs/dedup.md
Normal file
23
@xen-orchestra/backups/docs/dedup.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Deduplication
|
||||||
|
|
||||||
|
- This this use a additionnal inode (or equivalent on the FS), for each different block in the xo-block-store`sub folder`
|
||||||
|
- This will not work well with immutabilty/object lock
|
||||||
|
- only dedup blocks of vhd directory
|
||||||
|
- prerequisite are : the fs must support hard link and extended attributes
|
||||||
|
- a key (full backup) does not take more space on te remote than a delta. It will take more inodes , and more time since we'll have to read all the blocks. T
|
||||||
|
|
||||||
|
When a new block is written to the remote, a hash is computed. If a file with this hash doesn't exists in xo-block-store` create it, then add the has as an extended attributes.
|
||||||
|
A link hard link, sharing data and extended attributes is then create to the destination
|
||||||
|
|
||||||
|
When deleting a block which has a hash extended attributes, a check is done on the xo-block-store. If there are no other link, then the block is deleted . The directory containing it stays
|
||||||
|
|
||||||
|
When merging block : the unlink method is called before overwriting an existing block
|
||||||
|
|
||||||
|
### troubleshooting
|
||||||
|
|
||||||
|
Since all the blocks are hard linked, you can convert a deduplicated remote to a non deduplicated one by deleting the xo-block-store directory
|
||||||
|
|
||||||
|
two new method has been added to the local fs handler :
|
||||||
|
|
||||||
|
- deduplicationGarbageCollector(), which should be called from the root of the FS : it will clean any block without other links, and any empty directory
|
||||||
|
- deduplicationStats() that will compute the number of blocks in store and how many times they are used
|
||||||
@@ -16,6 +16,7 @@ function formatVmBackup(backup) {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
id: backup.id,
|
id: backup.id,
|
||||||
|
dedup: backup.dedup,
|
||||||
jobId: backup.jobId,
|
jobId: backup.jobId,
|
||||||
mode: backup.mode,
|
mode: backup.mode,
|
||||||
scheduleId: backup.scheduleId,
|
scheduleId: backup.scheduleId,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||||
},
|
},
|
||||||
"version": "0.40.0",
|
"version": "0.39.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=14.18"
|
||||||
},
|
},
|
||||||
@@ -23,15 +23,15 @@
|
|||||||
"@vates/compose": "^2.1.0",
|
"@vates/compose": "^2.1.0",
|
||||||
"@vates/decorate-with": "^2.0.0",
|
"@vates/decorate-with": "^2.0.0",
|
||||||
"@vates/disposable": "^0.1.4",
|
"@vates/disposable": "^0.1.4",
|
||||||
"@vates/fuse-vhd": "^2.0.0",
|
"@vates/fuse-vhd": "^1.0.0",
|
||||||
"@vates/nbd-client": "^2.0.0",
|
"@vates/nbd-client": "^1.2.1",
|
||||||
"@vates/parse-duration": "^0.1.1",
|
"@vates/parse-duration": "^0.1.1",
|
||||||
"@xen-orchestra/async-map": "^0.1.2",
|
"@xen-orchestra/async-map": "^0.1.2",
|
||||||
"@xen-orchestra/fs": "^4.0.1",
|
"@xen-orchestra/fs": "^4.0.1",
|
||||||
"@xen-orchestra/log": "^0.6.0",
|
"@xen-orchestra/log": "^0.6.0",
|
||||||
"@xen-orchestra/template": "^0.1.0",
|
"@xen-orchestra/template": "^0.1.0",
|
||||||
"compare-versions": "^6.0.0",
|
"compare-versions": "^5.0.1",
|
||||||
"d3-time-format": "^4.1.0",
|
"d3-time-format": "^3.0.0",
|
||||||
"decorator-synchronized": "^0.6.0",
|
"decorator-synchronized": "^0.6.0",
|
||||||
"golike-defer": "^0.5.1",
|
"golike-defer": "^0.5.1",
|
||||||
"limit-concurrency-decorator": "^0.5.0",
|
"limit-concurrency-decorator": "^0.5.0",
|
||||||
@@ -40,10 +40,9 @@
|
|||||||
"parse-pairs": "^2.0.0",
|
"parse-pairs": "^2.0.0",
|
||||||
"promise-toolbox": "^0.21.0",
|
"promise-toolbox": "^0.21.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"tar": "^6.1.15",
|
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"vhd-lib": "^4.5.0",
|
"vhd-lib": "^4.5.0",
|
||||||
"xen-api": "^1.3.4",
|
"xen-api": "^1.3.3",
|
||||||
"yazl": "^2.5.1"
|
"yazl": "^2.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
"tmp": "^0.2.1"
|
"tmp": "^0.2.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@xen-orchestra/xapi": "^3.0.0"
|
"@xen-orchestra/xapi": "^2.2.1"
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"preferGlobal": true,
|
"preferGlobal": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"golike-defer": "^0.5.1",
|
"golike-defer": "^0.5.1",
|
||||||
"xen-api": "^1.3.4"
|
"xen-api": "^1.3.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postversion": "npm publish"
|
"postversion": "npm publish"
|
||||||
|
|||||||
@@ -29,11 +29,12 @@
|
|||||||
"@vates/async-each": "^1.0.0",
|
"@vates/async-each": "^1.0.0",
|
||||||
"@vates/coalesce-calls": "^0.1.0",
|
"@vates/coalesce-calls": "^0.1.0",
|
||||||
"@vates/decorate-with": "^2.0.0",
|
"@vates/decorate-with": "^2.0.0",
|
||||||
"@vates/read-chunk": "^1.2.0",
|
"@vates/read-chunk": "^1.1.1",
|
||||||
"@xen-orchestra/log": "^0.6.0",
|
"@xen-orchestra/log": "^0.6.0",
|
||||||
"bind-property-descriptor": "^2.0.0",
|
"bind-property-descriptor": "^2.0.0",
|
||||||
"decorator-synchronized": "^0.6.0",
|
"decorator-synchronized": "^0.6.0",
|
||||||
"execa": "^5.0.0",
|
"execa": "^5.0.0",
|
||||||
|
"fs-extended-attributes": "^1.0.1",
|
||||||
"fs-extra": "^11.1.0",
|
"fs-extra": "^11.1.0",
|
||||||
"get-stream": "^6.0.0",
|
"get-stream": "^6.0.0",
|
||||||
"limit-concurrency-decorator": "^0.5.0",
|
"limit-concurrency-decorator": "^0.5.0",
|
||||||
|
|||||||
@@ -268,9 +268,9 @@ export default class RemoteHandlerAbstract {
|
|||||||
await this._mktree(normalizePath(dir), { mode })
|
await this._mktree(normalizePath(dir), { mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
async outputFile(file, data, { dirMode, flags = 'wx' } = {}) {
|
async outputFile(file, data, { dedup = false, dirMode, flags = 'wx' } = {}) {
|
||||||
const encryptedData = this.#encryptor.encryptData(data)
|
const encryptedData = this.#encryptor.encryptData(data)
|
||||||
await this._outputFile(normalizePath(file), encryptedData, { dirMode, flags })
|
await this._outputFile(normalizePath(file), encryptedData, { dedup, dirMode, flags })
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(file, buffer, position) {
|
async read(file, buffer, position) {
|
||||||
@@ -319,8 +319,8 @@ export default class RemoteHandlerAbstract {
|
|||||||
await timeout.call(this._rmdir(normalizePath(dir)).catch(ignoreEnoent), this._timeout)
|
await timeout.call(this._rmdir(normalizePath(dir)).catch(ignoreEnoent), this._timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
async rmtree(dir) {
|
async rmtree(dir, { dedup } = {}) {
|
||||||
await this._rmtree(normalizePath(dir))
|
await this._rmtree(normalizePath(dir), { dedup })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asks the handler to sync the state of the effective remote with its'
|
// Asks the handler to sync the state of the effective remote with its'
|
||||||
@@ -397,6 +397,10 @@ export default class RemoteHandlerAbstract {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkSupport() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
async test() {
|
async test() {
|
||||||
const SIZE = 1024 * 1024 * 10
|
const SIZE = 1024 * 1024 * 10
|
||||||
const testFileName = normalizePath(`${Date.now()}.test`)
|
const testFileName = normalizePath(`${Date.now()}.test`)
|
||||||
@@ -437,14 +441,14 @@ export default class RemoteHandlerAbstract {
|
|||||||
await this._truncate(file, len)
|
await this._truncate(file, len)
|
||||||
}
|
}
|
||||||
|
|
||||||
async __unlink(file, { checksum = true } = {}) {
|
async __unlink(file, { checksum = true, dedup = false } = {}) {
|
||||||
file = normalizePath(file)
|
file = normalizePath(file)
|
||||||
|
|
||||||
if (checksum) {
|
if (checksum) {
|
||||||
ignoreErrors.call(this._unlink(checksumFile(file)))
|
ignoreErrors.call(this._unlink(checksumFile(file)))
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._unlink(file).catch(ignoreEnoent)
|
await this._unlink(file, { dedup }).catch(ignoreEnoent)
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(file, buffer, position) {
|
async write(file, buffer, position) {
|
||||||
@@ -560,17 +564,16 @@ export default class RemoteHandlerAbstract {
|
|||||||
throw new Error('Not implemented')
|
throw new Error('Not implemented')
|
||||||
}
|
}
|
||||||
|
|
||||||
async _outputFile(file, data, { dirMode, flags }) {
|
async _outputFile(file, data, { dirMode, flags, dedup = false }) {
|
||||||
try {
|
try {
|
||||||
return await this._writeFile(file, data, { flags })
|
return await this._writeFile(file, data, { dedup, flags })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== 'ENOENT') {
|
if (error.code !== 'ENOENT') {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._mktree(dirname(file), { mode: dirMode })
|
await this._mktree(dirname(file), { mode: dirMode })
|
||||||
return this._outputFile(file, data, { flags })
|
return this._outputFile(file, data, { dedup, flags })
|
||||||
}
|
}
|
||||||
|
|
||||||
async _outputStream(path, input, { dirMode, validator }) {
|
async _outputStream(path, input, { dirMode, validator }) {
|
||||||
@@ -613,7 +616,7 @@ export default class RemoteHandlerAbstract {
|
|||||||
throw new Error('Not implemented')
|
throw new Error('Not implemented')
|
||||||
}
|
}
|
||||||
|
|
||||||
async _rmtree(dir) {
|
async _rmtree(dir, { dedup } = {}) {
|
||||||
try {
|
try {
|
||||||
return await this._rmdir(dir)
|
return await this._rmdir(dir)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -624,7 +627,7 @@ export default class RemoteHandlerAbstract {
|
|||||||
|
|
||||||
const files = await this._list(dir)
|
const files = await this._list(dir)
|
||||||
await asyncEach(files, file =>
|
await asyncEach(files, file =>
|
||||||
this._unlink(`${dir}/${file}`).catch(error => {
|
this._unlink(`${dir}/${file}`, { dedup }).catch(error => {
|
||||||
// Unlink dir behavior is not consistent across platforms
|
// Unlink dir behavior is not consistent across platforms
|
||||||
// https://github.com/nodejs/node-v0.x-archive/issues/5791
|
// https://github.com/nodejs/node-v0.x-archive/issues/5791
|
||||||
if (error.code === 'EISDIR' || error.code === 'EPERM') {
|
if (error.code === 'EISDIR' || error.code === 'EPERM') {
|
||||||
@@ -639,7 +642,7 @@ export default class RemoteHandlerAbstract {
|
|||||||
// called to initialize the remote
|
// called to initialize the remote
|
||||||
async _sync() {}
|
async _sync() {}
|
||||||
|
|
||||||
async _unlink(file) {
|
async _unlink(file, opts) {
|
||||||
throw new Error('Not implemented')
|
throw new Error('Not implemented')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ describe('encryption', () => {
|
|||||||
// encrypt with a non default algorithm
|
// encrypt with a non default algorithm
|
||||||
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
|
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
|
||||||
|
|
||||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gcm"}`)
|
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gmc"}`)
|
||||||
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
|
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
|
||||||
|
|
||||||
// remote is now non empty : can't modify key anymore
|
// remote is now non empty : can't modify key anymore
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ try {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
export const getHandler = (remote, ...rest) => {
|
export const getHandler = (remote, ...rest) => {
|
||||||
const Handler = HANDLERS[parse(remote.url).type]
|
const { type } = parse(remote.url)
|
||||||
|
const Handler = HANDLERS[type]
|
||||||
if (!Handler) {
|
if (!Handler) {
|
||||||
throw new Error('Unhandled remote type')
|
throw new Error('Unhandled remote type')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import df from '@sindresorhus/df'
|
import df from '@sindresorhus/df'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
|
// import fsx from 'fs-extended-attributes'
|
||||||
import lockfile from 'proper-lockfile'
|
import lockfile from 'proper-lockfile'
|
||||||
import { createLogger } from '@xen-orchestra/log'
|
import { createLogger } from '@xen-orchestra/log'
|
||||||
import { fromEvent, retry } from 'promise-toolbox'
|
import { asyncEach } from '@vates/async-each'
|
||||||
|
import { fromEvent, fromCallback, ignoreErrors, retry } from 'promise-toolbox'
|
||||||
|
import { synchronized } from 'decorator-synchronized'
|
||||||
|
|
||||||
import RemoteHandlerAbstract from './abstract'
|
import RemoteHandlerAbstract from './abstract'
|
||||||
|
import { normalize as normalizePath } from './path'
|
||||||
|
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
const { info, warn } = createLogger('xo:fs:local')
|
const { info, warn } = createLogger('xo:fs:local')
|
||||||
|
|
||||||
@@ -37,6 +44,10 @@ export default class LocalHandler extends RemoteHandlerAbstract {
|
|||||||
#addSyncStackTrace
|
#addSyncStackTrace
|
||||||
#retriesOnEagain
|
#retriesOnEagain
|
||||||
|
|
||||||
|
#supportDedup
|
||||||
|
#dedupDirectory = '/xo-block-store'
|
||||||
|
#hashMethod = 'sha256'
|
||||||
|
#attributeKey = `user.hash.${this.#hashMethod}`
|
||||||
constructor(remote, opts = {}) {
|
constructor(remote, opts = {}) {
|
||||||
super(remote)
|
super(remote)
|
||||||
|
|
||||||
@@ -194,16 +205,267 @@ export default class LocalHandler extends RemoteHandlerAbstract {
|
|||||||
return this.#addSyncStackTrace(fs.truncate, this.getFilePath(file), len)
|
return this.#addSyncStackTrace(fs.truncate, this.getFilePath(file), len)
|
||||||
}
|
}
|
||||||
|
|
||||||
async _unlink(file) {
|
async #localUnlink(filePath) {
|
||||||
const filePath = this.getFilePath(file)
|
|
||||||
return await this.#addSyncStackTrace(retry, () => fs.unlink(filePath), this.#retriesOnEagain)
|
return await this.#addSyncStackTrace(retry, () => fs.unlink(filePath), this.#retriesOnEagain)
|
||||||
}
|
}
|
||||||
|
async _unlink(file, { dedup } = {}) {
|
||||||
|
const filePath = this.getFilePath(file)
|
||||||
|
let hash
|
||||||
|
// only try to read dedup source if we try to delete something deduplicated
|
||||||
|
if (dedup === true) {
|
||||||
|
try {
|
||||||
|
// get hash before deleting the file
|
||||||
|
hash = await this.#getExtendedAttribute(file, this.#attributeKey)
|
||||||
|
} catch (err) {
|
||||||
|
// whatever : fall back to normal delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete file in place
|
||||||
|
await this.#localUnlink(filePath)
|
||||||
|
|
||||||
|
// implies we are on a deduplicated file
|
||||||
|
if (hash !== undefined) {
|
||||||
|
const dedupPath = this.getFilePath(this.#computeDeduplicationPath(hash))
|
||||||
|
await this.#removeExtendedAttribute(file, this.#attributeKey)
|
||||||
|
try {
|
||||||
|
const { nlink } = await fs.stat(dedupPath)
|
||||||
|
// get the number of copy still using these data
|
||||||
|
// delete source if it's alone
|
||||||
|
if (nlink === 1) {
|
||||||
|
await this.#localUnlink(dedupPath)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// no problem if another process deleted the source or if we unlink directly the source file
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_writeFd(file, buffer, position) {
|
_writeFd(file, buffer, position) {
|
||||||
return this.#addSyncStackTrace(fs.write, file.fd, buffer, 0, buffer.length, position)
|
return this.#addSyncStackTrace(fs.write, file.fd, buffer, 0, buffer.length, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeFile(file, data, { flags }) {
|
#localWriteFile(file, data, { flags }) {
|
||||||
return this.#addSyncStackTrace(fs.writeFile, this.getFilePath(file), data, { flag: flags })
|
return this.#addSyncStackTrace(fs.writeFile, this.getFilePath(file), data, { flag: flags })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _writeFile(file, data, { flags, dedup }) {
|
||||||
|
if (dedup === true) {
|
||||||
|
// only compute support once , and only if needed
|
||||||
|
if (this.#supportDedup === undefined) {
|
||||||
|
const supported = await this.checkSupport()
|
||||||
|
this.#supportDedup = supported.hardLink === true && supported.extendedAttributes === true
|
||||||
|
}
|
||||||
|
if (this.#supportDedup) {
|
||||||
|
const hash = this.#hash(data)
|
||||||
|
// create the file (if not already present) in the store
|
||||||
|
const dedupPath = await this.#writeDeduplicationSource(hash, data)
|
||||||
|
// hard link to the target place
|
||||||
|
// this linked file will have the same extended attributes
|
||||||
|
// (used for unlink)
|
||||||
|
return this.#link(dedupPath, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback
|
||||||
|
return this.#localWriteFile(file, data, { flags })
|
||||||
|
}
|
||||||
|
|
||||||
|
#hash(data) {
|
||||||
|
return createHash(this.#hashMethod).update(data).digest('hex')
|
||||||
|
}
|
||||||
|
async #getExtendedAttribute(file, attributeName) {
|
||||||
|
try{
|
||||||
|
return this._readFile(file+attributeName)
|
||||||
|
}catch(err){
|
||||||
|
if(err.code === 'ENOENT'){
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async #setExtendedAttribute(file, attributeName, value) {
|
||||||
|
return this._writeFile(file+attributeName, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async #removeExtendedAttribute(file, attributeName){
|
||||||
|
return this._unlink(file+attributeName)
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
async #getExtendedAttribute(file, attributeName) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fsx.get(this.getFilePath(file), attributeName, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
} else {
|
||||||
|
// res is a buffer
|
||||||
|
// it is null if the file doesn't have this attribute
|
||||||
|
if (res !== null) {
|
||||||
|
resolve(res.toString('utf-8'))
|
||||||
|
}
|
||||||
|
resolve(undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async #setExtendedAttribute(file, attributeName, value) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fsx.set(this.getFilePath(file), attributeName, value, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
} else {
|
||||||
|
resolve(res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async #removeExtendedAttribute(file, attributeName){
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// create a hard link between to files
|
||||||
|
#link(source, dest) {
|
||||||
|
return fs.link(this.getFilePath(source), this.getFilePath(dest))
|
||||||
|
}
|
||||||
|
|
||||||
|
// split path to keep a sane number of file per directory
|
||||||
|
#computeDeduplicationPath(hash) {
|
||||||
|
assert.strictEqual(hash.length % 4, 0)
|
||||||
|
let path = this.#dedupDirectory
|
||||||
|
for (let i = 0; i < hash.length; i++) {
|
||||||
|
if (i % 4 === 0) {
|
||||||
|
path += '/'
|
||||||
|
}
|
||||||
|
path += hash[i]
|
||||||
|
}
|
||||||
|
path += '.source'
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async #writeDeduplicationSource(hash, data) {
|
||||||
|
const path = this.#computeDeduplicationPath(hash)
|
||||||
|
try {
|
||||||
|
// flags ensures it fails if it already exists
|
||||||
|
// _outputfile will create the directory tree
|
||||||
|
await this._outputFile(path, data, { flags: 'wx' })
|
||||||
|
} catch (error) {
|
||||||
|
// if it is alread present : not a problem
|
||||||
|
if (error.code === 'EEXIST') {
|
||||||
|
// it should already have the extended attributes, nothing more to do
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.#setExtendedAttribute(path, this.#attributeKey, hash)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// if a concurrent process deleted the dedup : recreate it
|
||||||
|
return this.#writeDeduplicationSource(path, hash)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete empty dirs
|
||||||
|
* delete file source thath don't have any more links
|
||||||
|
*
|
||||||
|
* @returns Promise
|
||||||
|
*/
|
||||||
|
|
||||||
|
async deduplicationGarbageCollector(dir = this.#dedupDirectory, alreadyVisited = false) {
|
||||||
|
try {
|
||||||
|
await this._rmdir(dir)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOTEMPTY') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the directory may not be empty after a first visit
|
||||||
|
if (alreadyVisited) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await this._list(dir)
|
||||||
|
await asyncEach(
|
||||||
|
files,
|
||||||
|
async file => {
|
||||||
|
const stat = await fs.stat(this.getFilePath(`${dir}/${file}`))
|
||||||
|
// have to check the stat to ensure we don't try to delete
|
||||||
|
// the directories : they don't have links
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return this.deduplicationGarbageCollector(`${dir}/${file}`)
|
||||||
|
}
|
||||||
|
if (stat.nlink === 1) {
|
||||||
|
return fs.unlink(this.getFilePath(`${dir}/${file}`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ concurrency: 2 }
|
||||||
|
) // since we do a recursive traveral with a deep tree)
|
||||||
|
return this.deduplicationGarbageCollector(dir, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deduplicationStats(dir = this.#dedupDirectory) {
|
||||||
|
let nbSourceBlocks = 0
|
||||||
|
let nbBlocks = 0
|
||||||
|
try {
|
||||||
|
const files = await this._list(dir)
|
||||||
|
await asyncEach(
|
||||||
|
files,
|
||||||
|
async file => {
|
||||||
|
const stat = await fs.stat(this.getFilePath(`${dir}/${file}`))
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const { nbSourceBlocks: nbSourceInChild, nbBlocks: nbBlockInChild } = await this.deduplicationStats(
|
||||||
|
`${dir}/${file}`
|
||||||
|
)
|
||||||
|
nbSourceBlocks += nbSourceInChild
|
||||||
|
nbBlocks += nbBlockInChild
|
||||||
|
} else {
|
||||||
|
nbSourceBlocks++
|
||||||
|
nbBlocks += stat.nlink - 1 // ignore current
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ concurrency: 2 }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { nbSourceBlocks, nbBlocks }
|
||||||
|
}
|
||||||
|
|
||||||
|
@synchronized()
|
||||||
|
async checkSupport() {
|
||||||
|
const supported = await super.checkSupport()
|
||||||
|
const sourceFileName = normalizePath(`${Date.now()}.sourcededup`)
|
||||||
|
const destFileName = normalizePath(`${Date.now()}.destdedup`)
|
||||||
|
try {
|
||||||
|
const SIZE = 1024 * 1024
|
||||||
|
const data = await fromCallback(randomBytes, SIZE)
|
||||||
|
const hash = this.#hash(data)
|
||||||
|
await this._outputFile(sourceFileName, data, { flags: 'wx', dedup: false })
|
||||||
|
await this.#setExtendedAttribute(sourceFileName, this.#attributeKey, hash)
|
||||||
|
await this.#link(sourceFileName, destFileName)
|
||||||
|
const linkedData = await this._readFile(destFileName)
|
||||||
|
const { nlink } = await fs.stat(this.getFilePath(destFileName))
|
||||||
|
// contains the right data and the link counter
|
||||||
|
supported.hardLink = nlink === 2 && linkedData.equals(data)
|
||||||
|
supported.extendedAttributes = hash === (await this.#getExtendedAttribute(sourceFileName, this.#attributeKey))
|
||||||
|
} catch (error) {
|
||||||
|
warn(`error while testing the dedup`, { error })
|
||||||
|
} finally {
|
||||||
|
ignoreErrors.call(this._unlink(sourceFileName))
|
||||||
|
ignoreErrors.call(this._unlink(destFileName))
|
||||||
|
}
|
||||||
|
return supported
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
@xen-orchestra/fs/src/local.test.js
Normal file
107
@xen-orchestra/fs/src/local.test.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { after, beforeEach, describe, it } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import { getSyncedHandler } from './index.js'
|
||||||
|
import { Disposable, pFromCallback } from 'promise-toolbox'
|
||||||
|
import tmp from 'tmp'
|
||||||
|
import execa from 'execa'
|
||||||
|
import { rimraf } from 'rimraf'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
// https://xkcd.com/221/
|
||||||
|
const data =
|
||||||
|
'H2GbLa0F2J4LHFLRwLP9zN4dGWJpdx1T6eGWra8BRlV9fBpRGtWIOSKXjU8y7fnxAWVGWpbYPYCwRigvxRSTcuaQsCtwvDNKMmFwYpsGMS14akgBD3EpOMPpKIRRySOsOeknpr48oopO1n9eq0PxGbOcY4Q9aojRu9rn1SMNyjq7YGzwVQEm6twA3etKGSYGvPJVTs2riXm7u6BhBh9VZtQDxQEy5ttkHiZUpgLi6QshSpMjL7dHco8k6gzGcxfpoyS5IzaQeXqDOeRjE6HNn27oUXpze5xRYolQhxA7IqdfzcYwWTqlaZb7UBUZoFCiFs5Y6vPlQVZ2Aw5YganLV1ZcIz78j6TAtXJAfXrDhksm9UteQul8RYT0Ur8AJRYgiGXOsXrWWBKm3CzZci6paLZ2jBmGfgVuBJHlvgFIjOHiVozjulGD4SwKQ2MNqUOylv89NTP1BsJuZ7MC6YCm5yix7FswoE7Y2NhDFqzEQvseRQFyz52AsfuqRY7NruKHlO7LOSI932che2WzxBAwy78Sk1eRHQLsZ37dLB4UkFFIq6TvyjJKznTMAcx9HDOSrFeke6KfsDB1A4W3BAxJk40oAcFMeM72Lg97sJExMJRz1m1nGQJEiGCcnll9G6PqEfHjoOhdDLgN2xewUyvbuRuKEXXxD1H6Tz1iWReyRGSagQNLXvqkKoHoxu3bvSi8nWrbtEY6K2eHLeF5bYubYGXc5VsfiCQNPEzQV4ECzaPdolRtbpRFMcB5aWK70Oew3HJkEcN7IkcXI9vlJKnFvFMqGOHKujd4Tyjhvru2UFh0dAkEwojNzz7W0XlASiXRneea9FgiJNLcrXNtBkvIgw6kRrgbXI6DPJdWDpm3fmWS8EpOICH3aTiXRLQUDZsReAaOsfau1FNtP4JKTQpG3b9rKkO5G7vZEWqTi69mtPGWmyOU47WL1ifJtlzGiFbZ30pcHMc0u4uopHwEQq6ZwM5S6NHvioxihhHQHO8JU2xvcjg5OcTEsXtMwIapD3re'
|
||||||
|
const hash = '09a3cd9e135114cb870a0b5cf0dfd3f4be994662d0c715b65bcfc5e3b635dd40'
|
||||||
|
const dataPath = 'xo-block-store/09a3/cd9e/1351/14cb/870a/0b5c/f0df/d3f4/be99/4662/d0c7/15b6/5bcf/c5e3/b635/dd40.source'
|
||||||
|
|
||||||
|
let dir
|
||||||
|
describe('dedup tests', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await pFromCallback(cb => tmp.dir(cb))
|
||||||
|
})
|
||||||
|
after(async () => {
|
||||||
|
await rimraf(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works in general case ', async () => {
|
||||||
|
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }, { dedup: true }), async handler => {
|
||||||
|
await handler.outputFile('in/a/sub/folder/file', data, { dedup: true })
|
||||||
|
assert.doesNotReject(handler.list('xo-block-store'))
|
||||||
|
assert.strictEqual((await handler.list('xo-block-store')).length, 1)
|
||||||
|
assert.strictEqual((await handler.list('in/a/sub/folder')).length, 1)
|
||||||
|
assert.strictEqual((await handler.readFile('in/a/sub/folder/file')).toString('utf-8'), data)
|
||||||
|
const value = (await execa('getfattr', ['-n', 'user.hash.sha256', '--only-value', dir + '/in/a/sub/folder/file']))
|
||||||
|
.stdout
|
||||||
|
assert.strictEqual(value, hash)
|
||||||
|
// the source file is created
|
||||||
|
assert.strictEqual((await handler.readFile(dataPath)).toString('utf-8'), data)
|
||||||
|
|
||||||
|
await handler.outputFile('in/anotherfolder/file', data, { dedup: true })
|
||||||
|
assert.strictEqual((await handler.list('in/anotherfolder')).length, 1)
|
||||||
|
assert.strictEqual((await handler.readFile('in/anotherfolder/file')).toString('utf-8'), data)
|
||||||
|
|
||||||
|
await handler.unlink('in/a/sub/folder/file', { dedup: true })
|
||||||
|
// source is still here
|
||||||
|
assert.strictEqual((await handler.readFile(dataPath)).toString('utf-8'), data)
|
||||||
|
assert.strictEqual((await handler.readFile('in/anotherfolder/file')).toString('utf-8'), data)
|
||||||
|
|
||||||
|
await handler.unlink('in/anotherfolder/file', { dedup: true })
|
||||||
|
// source should have been deleted
|
||||||
|
assert.strictEqual(
|
||||||
|
(
|
||||||
|
await handler.list(
|
||||||
|
'xo-block-store/09a3/cd9e/1351/14cb/870a/0b5c/f0df/d3f4/be99/4662/d0c7/15b6/5bcf/c5e3/b635'
|
||||||
|
)
|
||||||
|
).length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
assert.strictEqual((await handler.list('in/anotherfolder')).length, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('garbage collector an stats ', async () => {
|
||||||
|
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }, { dedup: true }), async handler => {
|
||||||
|
await handler.outputFile('in/anotherfolder/file', data, { dedup: true })
|
||||||
|
await handler.outputFile('in/anotherfolder/same', data, { dedup: true })
|
||||||
|
await handler.outputFile('in/a/sub/folder/file', randomBytes(1024), { dedup: true })
|
||||||
|
|
||||||
|
let stats = await handler.deduplicationStats()
|
||||||
|
assert.strictEqual(stats.nbBlocks, 3)
|
||||||
|
assert.strictEqual(stats.nbSourceBlocks, 2)
|
||||||
|
|
||||||
|
await fs.unlink(`${dir}/in/a/sub/folder/file`, { dedup: true })
|
||||||
|
assert.strictEqual((await handler.list('xo-block-store')).length, 2)
|
||||||
|
|
||||||
|
await handler.deduplicationGarbageCollector()
|
||||||
|
stats = await handler.deduplicationStats()
|
||||||
|
assert.strictEqual(stats.nbBlocks, 2)
|
||||||
|
assert.strictEqual(stats.nbSourceBlocks, 1)
|
||||||
|
|
||||||
|
assert.strictEqual((await handler.list('xo-block-store')).length, 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compute support', async () => {
|
||||||
|
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }, { dedup: true }), async handler => {
|
||||||
|
const supported = await handler.checkSupport()
|
||||||
|
assert.strictEqual(supported.hardLink, true, 'support hard link is not present in local fs')
|
||||||
|
assert.strictEqual(supported.extendedAttributes, true, 'support extended attributes is not present in local fs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles edge cases : source deleted', async () => {
|
||||||
|
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }, { dedup: true }), async handler => {
|
||||||
|
await handler.outputFile('in/a/sub/folder/edge', data, { dedup: true })
|
||||||
|
await handler.unlink(dataPath, { dedup: true })
|
||||||
|
// no error if source si already deleted
|
||||||
|
await assert.doesNotReject(() => handler.unlink('in/a/sub/folder/edge', { dedup: true }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('handles edge cases : non deduplicated file ', async () => {
|
||||||
|
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }, { dedup: true }), async handler => {
|
||||||
|
await handler.outputFile('in/a/sub/folder/edge', data, { dedup: false })
|
||||||
|
// no error if deleting a non dedup file with dedup flags
|
||||||
|
await assert.doesNotReject(() => handler.unlink('in/a/sub/folder/edge', { dedup: true }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -228,6 +228,11 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
async _writeFile(file, data, options) {
|
async _writeFile(file, data, options) {
|
||||||
|
if (options?.dedup ?? false) {
|
||||||
|
throw new Error(
|
||||||
|
"S3 remotes don't support deduplication from XO, please use the deduplication of your S3 provider if any"
|
||||||
|
)
|
||||||
|
}
|
||||||
return this.#s3.send(
|
return this.#s3.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
...this.#createParams(file),
|
...this.#createParams(file),
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
// Keeping this file to prevent applying the global monorepo config for now
|
// Keeping this file to prevent applying the global monorepo config for now
|
||||||
module.exports = {
|
module.exports = {};
|
||||||
trailingComma: "es5",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
## **next**
|
## **next**
|
||||||
|
|
||||||
## **0.1.2** (2023-07-28)
|
|
||||||
|
|
||||||
- Ability to export selected VMs as CSV file (PR [#6915](https://github.com/vatesfr/xen-orchestra/pull/6915))
|
- Ability to export selected VMs as CSV file (PR [#6915](https://github.com/vatesfr/xen-orchestra/pull/6915))
|
||||||
- [Pool/VMs] Ability to export selected VMs as JSON file (PR [#6911](https://github.com/vatesfr/xen-orchestra/pull/6911))
|
- [Pool/VMs] Ability to export selected VMs as JSON file (PR [#6911](https://github.com/vatesfr/xen-orchestra/pull/6911))
|
||||||
- Add Tasks to Pool Dashboard (PR [#6713](https://github.com/vatesfr/xen-orchestra/pull/6713))
|
|
||||||
|
|
||||||
## **0.1.1** (2023-07-03)
|
## **0.1.1** (2023-07-03)
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
<!-- TOC -->
|
|
||||||
|
|
||||||
- [XenApiCollection](#xenapicollection)
|
|
||||||
- [Get the collection](#get-the-collection)
|
|
||||||
- [Defer the subscription](#defer-the-subscription)
|
|
||||||
- [Create a dedicated collection](#create-a-dedicated-collection)
|
|
||||||
- [Alter the collection](#alter-the-collection)
|
|
||||||
_ [Example 1: Adding props to records](#example-1-adding-props-to-records)
|
|
||||||
_ [Example 2: Adding props to the collection](#example-2-adding-props-to-the-collection) \* [Example 3, filtering and sorting the collection](#example-3-filtering-and-sorting-the-collection)
|
|
||||||
<!-- TOC -->
|
|
||||||
|
|
||||||
# XenApiCollection
|
|
||||||
|
|
||||||
## Get the collection
|
|
||||||
|
|
||||||
To retrieve a collection, invoke `useXenApiCollection("VM")`.
|
|
||||||
|
|
||||||
By doing this, the current component will be automatically subscribed to the collection and will be updated when the
|
|
||||||
collection changes.
|
|
||||||
|
|
||||||
When the component is unmounted, the subscription will be automatically stopped.
|
|
||||||
|
|
||||||
## Defer the subscription
|
|
||||||
|
|
||||||
If you don't want to fetch the data of the collection when the component is mounted, you can pass `{ immediate: false }`
|
|
||||||
as options: `const { start, isStarted } = useXenApiCollection("VM", { immediate: false })`.
|
|
||||||
|
|
||||||
Then you subscribe to the collection by calling `start()`.
|
|
||||||
|
|
||||||
## Create a dedicated collection
|
|
||||||
|
|
||||||
It is recommended to create a dedicated collection composable for each type of record you want to use.
|
|
||||||
|
|
||||||
They are stored in `src/composables/xen-api-collection/*-collection.composable.ts`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/composables/xen-api-collection/console-collection.composable.ts
|
|
||||||
|
|
||||||
export const useConsoleCollection = () => useXenApiCollection("console");
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want to allow the user to defer the subscription, you can propagate the options to `useXenApiCollection`.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// console-collection.composable.ts
|
|
||||||
|
|
||||||
export const useConsoleCollection = <
|
|
||||||
Immediate extends boolean = true,
|
|
||||||
>(options?: {
|
|
||||||
immediate?: Immediate;
|
|
||||||
}) => useXenApiCollection("console", options);
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// MyComponent.vue
|
|
||||||
|
|
||||||
const collection = useConsoleCollection({ immediate: false });
|
|
||||||
|
|
||||||
setTimeout(() => collection.start(), 10000);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Alter the collection
|
|
||||||
|
|
||||||
You can alter the collection by overriding parts of it.
|
|
||||||
|
|
||||||
### Example 1: Adding props to records
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// xen-api.ts
|
|
||||||
|
|
||||||
export interface XenApiConsole extends XenApiRecord<"console"> {
|
|
||||||
// ... existing props
|
|
||||||
someProp: string;
|
|
||||||
someOtherProp: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// console-collection.composable.ts
|
|
||||||
|
|
||||||
export const useConsoleCollection = () => {
|
|
||||||
const collection = useXenApiCollection("console");
|
|
||||||
|
|
||||||
const records = computed(() => {
|
|
||||||
return collection.records.value.map((console) => ({
|
|
||||||
...console,
|
|
||||||
someProp: "Some value",
|
|
||||||
someOtherProp: 42,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
records,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const consoleCollection = useConsoleCollection();
|
|
||||||
|
|
||||||
consoleCollection.getByUuid("...").someProp; // "Some value"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 2: Adding props to the collection
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// vm-collection.composable.ts
|
|
||||||
|
|
||||||
export const useVmCollection = () => {
|
|
||||||
const collection = useXenApiCollection("VM");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
runningVms: computed(() =>
|
|
||||||
collection.records.value.filter(
|
|
||||||
(vm) => vm.power_state === POWER_STATE.RUNNING
|
|
||||||
)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 3, filtering and sorting the collection
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// vm-collection.composable.ts
|
|
||||||
|
|
||||||
export const useVmCollection = () => {
|
|
||||||
const collection = useXenApiCollection("VM");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
records: computed(() =>
|
|
||||||
collection.records.value
|
|
||||||
.filter(
|
|
||||||
(vm) =>
|
|
||||||
!vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain
|
|
||||||
)
|
|
||||||
.sort((vm1, vm2) => vm1.name_label.localeCompare(vm2.name_label))
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
144
@xen-orchestra/lite/docs/xen-api-record-stores.md
Normal file
144
@xen-orchestra/lite/docs/xen-api-record-stores.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Stores for XenApiRecord collections
|
||||||
|
|
||||||
|
All collections of `XenApiRecord` are stored inside the `xapiCollectionStore`.
|
||||||
|
|
||||||
|
To retrieve a collection, invoke `useXapiCollectionStore().get(type)`.
|
||||||
|
|
||||||
|
## Accessing a collection
|
||||||
|
|
||||||
|
In order to use a collection, you'll need to subscribe to it.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const consoleStore = useXapiCollectionStore().get("console");
|
||||||
|
const { records, getByUuid /* ... */ } = consoleStore.subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deferred subscription
|
||||||
|
|
||||||
|
If you wish to initialize the subscription on demand, you can pass `{ immediate: false }` as options to `subscribe()`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const consoleStore = useXapiCollectionStore().get("console");
|
||||||
|
const { records, start, isStarted /* ... */ } = consoleStore.subscribe({
|
||||||
|
immediate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Later, you can then use start() to initialize the subscription.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create a dedicated store for a collection
|
||||||
|
|
||||||
|
To create a dedicated store for a specific `XenApiRecord`, simply return the collection from the XAPI Collection Store:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const useConsoleStore = defineStore("console", () =>
|
||||||
|
useXapiCollectionStore().get("console")
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extending the base Subscription
|
||||||
|
|
||||||
|
To extend the base Subscription, you'll need to override the `subscribe` method.
|
||||||
|
|
||||||
|
For that, you can use the `createSubscribe<XenApiRecord, Extensions>((options) => { /* ... */})` helper.
|
||||||
|
|
||||||
|
### Define the extensions
|
||||||
|
|
||||||
|
Subscription extensions are defined as `(object | [object, RequiredOptions])[]`.
|
||||||
|
|
||||||
|
When using a tuple (`[object, RequiredOptions]`), the corresponding `object` type will be added to the subscription if
|
||||||
|
the `RequiredOptions` for that tuple are present in the options passed to `subscribe`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Always present extension
|
||||||
|
type DefaultExtension = {
|
||||||
|
propA: string;
|
||||||
|
propB: ComputedRef<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Conditional extension 1
|
||||||
|
type FirstConditionalExtension = [
|
||||||
|
{ propC: ComputedRef<string> }, // <- This signature will be added
|
||||||
|
{ optC: string } // <- if this condition is met
|
||||||
|
];
|
||||||
|
|
||||||
|
// Conditional extension 2
|
||||||
|
type SecondConditionalExtension = [
|
||||||
|
{ propD: () => void }, // <- This signature will be added
|
||||||
|
{ optD: number } // <- if this condition is met
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create the extensions array
|
||||||
|
type Extensions = [
|
||||||
|
DefaultExtension,
|
||||||
|
FirstConditionalExtension,
|
||||||
|
SecondConditionalExtension
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Define the subscription
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const useConsoleStore = defineStore("console", () => {
|
||||||
|
const consoleCollection = useXapiCollectionStore().get("console");
|
||||||
|
|
||||||
|
const subscribe = createSubscribe<XenApiConsole, Extensions>((options) => {
|
||||||
|
const originalSubscription = consoleCollection.subscribe(options);
|
||||||
|
|
||||||
|
const extendedSubscription = {
|
||||||
|
propA: "Some string",
|
||||||
|
propB: computed(() => 42),
|
||||||
|
};
|
||||||
|
|
||||||
|
const propCSubscription = options?.optC !== undefined && {
|
||||||
|
propC: computed(() => "Some other string"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const propDSubscription = options?.optD !== undefined && {
|
||||||
|
propD: () => console.log("Hello"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...originalSubscription,
|
||||||
|
...extendedSubscription,
|
||||||
|
...propCSubscription,
|
||||||
|
...propDSubscription,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...consoleCollection,
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated `subscribe` method will then automatically have the following `options` signature:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Options = {
|
||||||
|
immediate?: false;
|
||||||
|
optC?: string;
|
||||||
|
optD?: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use the subscription
|
||||||
|
|
||||||
|
In each case, all the default properties (`records`, `getByUuid`, etc.) will be present.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const store = useConsoleStore();
|
||||||
|
|
||||||
|
// No options (propA and propB will be present)
|
||||||
|
const subscription = store.subscribe();
|
||||||
|
|
||||||
|
// optC option (propA, propB and propC will be present)
|
||||||
|
const subscription = store.subscribe({ optC: "Hello" });
|
||||||
|
|
||||||
|
// optD option (propA, propB and propD will be present)
|
||||||
|
const subscription = store.subscribe({ optD: 12 });
|
||||||
|
|
||||||
|
// optC and optD options (propA, propB, propC and propD will be present)
|
||||||
|
const subscription = store.subscribe({ optC: "Hello", optD: 12 });
|
||||||
|
```
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>XO Lite</title>
|
<title>Vite App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xen-orchestra/lite",
|
"name": "@xen-orchestra/lite",
|
||||||
"version": "0.1.2",
|
"version": "0.1.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||||
"build": "run-p type-check build-only",
|
"build": "run-p type-check build-only",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"@types/marked": "^4.0.8",
|
"@types/marked": "^4.0.8",
|
||||||
"@vueuse/core": "^10.1.2",
|
"@vueuse/core": "^10.1.2",
|
||||||
"@vueuse/math": "^10.1.2",
|
"@vueuse/math": "^10.1.2",
|
||||||
"complex-matcher": "^0.7.1",
|
"complex-matcher": "^0.7.0",
|
||||||
"d3-time-format": "^4.1.0",
|
"d3-time-format": "^4.1.0",
|
||||||
"decorator-synchronized": "^0.6.0",
|
"decorator-synchronized": "^0.6.0",
|
||||||
"echarts": "^5.3.3",
|
"echarts": "^5.3.3",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"@rushstack/eslint-patch": "^1.1.0",
|
"@rushstack/eslint-patch": "^1.1.0",
|
||||||
"@types/node": "^16.11.41",
|
"@types/node": "^16.11.41",
|
||||||
"@vitejs/plugin-vue": "^4.2.3",
|
"@vitejs/plugin-vue": "^4.2.3",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
"@vue/eslint-config-prettier": "^7.0.0",
|
||||||
"@vue/eslint-config-typescript": "^11.0.0",
|
"@vue/eslint-config-typescript": "^11.0.0",
|
||||||
"@vue/tsconfig": "^0.1.3",
|
"@vue/tsconfig": "^0.1.3",
|
||||||
"eslint-plugin-vue": "^9.0.0",
|
"eslint-plugin-vue": "^9.0.0",
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
<AppLogin />
|
<AppLogin />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<AppHeader v-if="uiStore.hasUi" />
|
<AppHeader />
|
||||||
<div style="display: flex">
|
<div style="display: flex">
|
||||||
<AppNavigation v-if="uiStore.hasUi" />
|
<AppNavigation />
|
||||||
<main class="main" :class="{ 'no-ui': !uiStore.hasUi }">
|
<main class="main">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +23,7 @@ import AppNavigation from "@/components/AppNavigation.vue";
|
|||||||
import AppTooltips from "@/components/AppTooltips.vue";
|
import AppTooltips from "@/components/AppTooltips.vue";
|
||||||
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
|
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
|
||||||
import { useChartTheme } from "@/composables/chart-theme.composable";
|
import { useChartTheme } from "@/composables/chart-theme.composable";
|
||||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
import { usePoolStore } from "@/stores/pool.store";
|
||||||
import { useUiStore } from "@/stores/ui.store";
|
import { useUiStore } from "@/stores/ui.store";
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||||
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
|
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
|
||||||
@@ -41,10 +41,10 @@ if (link == null) {
|
|||||||
}
|
}
|
||||||
link.href = favicon;
|
link.href = favicon;
|
||||||
|
|
||||||
|
document.title = "XO Lite";
|
||||||
|
|
||||||
const xenApiStore = useXenApiStore();
|
const xenApiStore = useXenApiStore();
|
||||||
|
const { pool } = usePoolStore().subscribe();
|
||||||
const { pool } = usePoolCollection();
|
|
||||||
|
|
||||||
useChartTheme();
|
useChartTheme();
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
|
|
||||||
@@ -92,9 +92,5 @@ whenever(
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: calc(100vh - 8rem);
|
height: calc(100vh - 8rem);
|
||||||
background-color: var(--background-color-secondary);
|
background-color: var(--background-color-secondary);
|
||||||
|
|
||||||
&.no-ui {
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 63 KiB |
@@ -24,7 +24,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { onMounted, ref, watch } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
@@ -34,7 +33,6 @@ import UiButton from "@/components/ui/UiButton.vue";
|
|||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
usePageTitleStore().setTitle(t("login"));
|
|
||||||
const xenApiStore = useXenApiStore();
|
const xenApiStore = useXenApiStore();
|
||||||
const { isConnecting } = storeToRefs(xenApiStore);
|
const { isConnecting } = storeToRefs(xenApiStore);
|
||||||
const login = ref("root");
|
const login = ref("root");
|
||||||
@@ -64,7 +62,7 @@ async function handleSubmit() {
|
|||||||
isInvalidPassword.value = true;
|
isInvalidPassword.value = true;
|
||||||
error.value = t("password-invalid");
|
error.value = t("password-invalid");
|
||||||
} else {
|
} else {
|
||||||
error.value = t("error-occurred");
|
error.value = t("error-occured");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script
|
<script
|
||||||
generic="T extends XenApiRecord<RawObjectType>, I extends T['uuid']"
|
generic="T extends XenApiRecord<string>, I extends T['uuid']"
|
||||||
lang="ts"
|
lang="ts"
|
||||||
setup
|
setup
|
||||||
>
|
>
|
||||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||||
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
|
import type { XenApiRecord } from "@/libs/xen-api";
|
||||||
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
|
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
|
||||||
import VncClient from "@novnc/novnc/core/rfb";
|
|
||||||
import { promiseTimeout } from "@vueuse/shared";
|
|
||||||
import { fibonacci } from "iterable-backoff";
|
import { fibonacci } from "iterable-backoff";
|
||||||
import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
|
import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
|
||||||
|
import VncClient from "@novnc/novnc/core/rfb";
|
||||||
|
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||||
|
import { promiseTimeout } from "@vueuse/shared";
|
||||||
|
|
||||||
const N_TOTAL_TRIES = 8;
|
const N_TOTAL_TRIES = 8;
|
||||||
const FIBONACCI_MS_ARRAY: number[] = Array.from(
|
const FIBONACCI_MS_ARRAY: number[] = Array.from(
|
||||||
|
|||||||
@@ -27,14 +27,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||||
import UiModal from "@/components/ui/UiModal.vue";
|
import UiModal from "@/components/ui/UiModal.vue";
|
||||||
import UiButton from "@/components/ui/UiButton.vue";
|
import UiButton from "@/components/ui/UiButton.vue";
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { difference } from "lodash-es";
|
import { difference } from "lodash-es";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
|
||||||
const { records: hosts } = useHostCollection();
|
const { records: hosts } = useHostStore().subscribe();
|
||||||
const unreachableHostsUrls = ref<Set<string>>(new Set());
|
const unreachableHostsUrls = ref<Set<string>>(new Set());
|
||||||
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
|
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
|
||||||
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
|
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</AppMenu>
|
</AppMenu>
|
||||||
</UiTabBar>
|
</UiTabBar>
|
||||||
|
|
||||||
<div :class="{ 'full-width': fullWidthComponent }" class="tabs">
|
<div class="tabs">
|
||||||
<UiCard v-if="selectedTab === TAB.NONE" class="tab-content">
|
<UiCard v-if="selectedTab === TAB.NONE" class="tab-content">
|
||||||
<i>No configuration defined</i>
|
<i>No configuration defined</i>
|
||||||
</UiCard>
|
</UiCard>
|
||||||
@@ -102,11 +102,11 @@ import StorySettingParams from "@/components/component-story/StorySettingParams.
|
|||||||
import StorySlotParams from "@/components/component-story/StorySlotParams.vue";
|
import StorySlotParams from "@/components/component-story/StorySlotParams.vue";
|
||||||
import AppMenu from "@/components/menu/AppMenu.vue";
|
import AppMenu from "@/components/menu/AppMenu.vue";
|
||||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
|
||||||
import UiButton from "@/components/ui/UiButton.vue";
|
import UiButton from "@/components/ui/UiButton.vue";
|
||||||
import UiCard from "@/components/ui/UiCard.vue";
|
import UiCard from "@/components/ui/UiCard.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||||
|
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||||
import UiTab from "@/components/ui/UiTab.vue";
|
import UiTab from "@/components/ui/UiTab.vue";
|
||||||
import UiTabBar from "@/components/ui/UiTabBar.vue";
|
import UiTabBar from "@/components/ui/UiTabBar.vue";
|
||||||
import {
|
import {
|
||||||
@@ -140,7 +140,6 @@ const props = defineProps<{
|
|||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
fullWidthComponent?: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
enum TAB {
|
enum TAB {
|
||||||
@@ -330,10 +329,6 @@ const applyPreset = (preset: {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
&.full-width {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
@@ -2,80 +2,32 @@
|
|||||||
<RouterLink :to="{ name: 'story' }">
|
<RouterLink :to="{ name: 'story' }">
|
||||||
<UiTitle type="h4">Stories</UiTitle>
|
<UiTitle type="h4">Stories</UiTitle>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<StoryMenuTree
|
<ul class="links">
|
||||||
:tree="tree"
|
<li v-for="route in routes" :key="route.name">
|
||||||
@toggle-directory="toggleDirectory"
|
<RouterLink class="link" :to="route">
|
||||||
:opened-directories="openedDirectories"
|
{{ route.meta.storyTitle }}
|
||||||
/>
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import StoryMenuTree from "@/components/component-story/StoryMenuTree.vue";
|
import { useRouter } from "vue-router";
|
||||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||||
import { type RouteRecordNormalized, useRoute, useRouter } from "vue-router";
|
|
||||||
import { ref } from "vue";
|
|
||||||
|
|
||||||
const { getRoutes } = useRouter();
|
const { getRoutes } = useRouter();
|
||||||
|
|
||||||
const routes = getRoutes().filter((route) => route.meta.isStory);
|
const routes = getRoutes().filter((route) => route.meta.isStory);
|
||||||
|
|
||||||
export type StoryTree = Map<
|
|
||||||
string,
|
|
||||||
{ path: string; directory: string; children: StoryTree }
|
|
||||||
>;
|
|
||||||
|
|
||||||
function createTree(routes: RouteRecordNormalized[]) {
|
|
||||||
const tree: StoryTree = new Map();
|
|
||||||
|
|
||||||
for (const route of routes) {
|
|
||||||
const parts = route.path.slice(7).split("/");
|
|
||||||
let currentNode = tree;
|
|
||||||
let currentPath = "";
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
||||||
if (!currentNode.has(part)) {
|
|
||||||
currentNode.set(part, {
|
|
||||||
children: new Map(),
|
|
||||||
path: route.path,
|
|
||||||
directory: currentPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
currentNode = currentNode.get(part)!.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tree;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = createTree(routes);
|
|
||||||
|
|
||||||
const currentRoute = useRoute();
|
|
||||||
|
|
||||||
const getDefaultOpenedDirectories = (): Set<string> => {
|
|
||||||
if (!currentRoute.meta.isStory) {
|
|
||||||
return new Set<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
const openedDirectories = new Set<string>();
|
|
||||||
const parts = currentRoute.path.split("/");
|
|
||||||
let currentPath = "";
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
||||||
openedDirectories.add(currentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return openedDirectories;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openedDirectories = ref(getDefaultOpenedDirectories());
|
|
||||||
|
|
||||||
const toggleDirectory = (directory: string) => {
|
|
||||||
if (openedDirectories.value.has(directory)) {
|
|
||||||
openedDirectories.value.delete(directory);
|
|
||||||
} else {
|
|
||||||
openedDirectories.value.add(directory);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="postcss" scoped>
|
||||||
|
.links {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ul class="story-menu-tree">
|
|
||||||
<li v-for="[key, node] in tree" :key="key">
|
|
||||||
<span
|
|
||||||
v-if="node.children.size > 0"
|
|
||||||
class="directory"
|
|
||||||
@click="emit('toggle-directory', node.directory)"
|
|
||||||
>
|
|
||||||
<UiIcon
|
|
||||||
:icon="isOpen(node.directory) ? faFolderOpen : faFolderClosed"
|
|
||||||
/>
|
|
||||||
{{ formatName(key) }}
|
|
||||||
</span>
|
|
||||||
<RouterLink v-else :to="node.path" class="link">
|
|
||||||
<UiIcon :icon="faFile" />
|
|
||||||
{{ formatName(key) }}
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<StoryMenuTree
|
|
||||||
v-if="isOpen(node.directory)"
|
|
||||||
:tree="node.children"
|
|
||||||
@toggle-directory="emit('toggle-directory', $event)"
|
|
||||||
:opened-directories="openedDirectories"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import type { StoryTree } from "@/components/component-story/StoryMenu.vue";
|
|
||||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
|
||||||
import {
|
|
||||||
faFile,
|
|
||||||
faFolderClosed,
|
|
||||||
faFolderOpen,
|
|
||||||
} from "@fortawesome/free-regular-svg-icons";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
tree: StoryTree;
|
|
||||||
openedDirectories: Set<string>;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: "toggle-directory", directory: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const isOpen = (directory: string) => props.openedDirectories.has(directory);
|
|
||||||
|
|
||||||
const formatName = (name: string) => {
|
|
||||||
const parts = name.split("-");
|
|
||||||
return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
|
||||||
.story-menu-tree {
|
|
||||||
padding-left: 1rem;
|
|
||||||
|
|
||||||
.story-menu-tree {
|
|
||||||
padding-left: 2.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link,
|
|
||||||
.directory {
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
import InfraAction from "@/components/infra/InfraAction.vue";
|
import InfraAction from "@/components/infra/InfraAction.vue";
|
||||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||||
import InfraVmList from "@/components/infra/InfraVmList.vue";
|
import InfraVmList from "@/components/infra/InfraVmList.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
|
||||||
import { vTooltip } from "@/directives/tooltip.directive";
|
import { vTooltip } from "@/directives/tooltip.directive";
|
||||||
import type { XenApiHost } from "@/libs/xen-api";
|
import type { XenApiHost } from "@/libs/xen-api";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
import { usePoolStore } from "@/stores/pool.store";
|
||||||
import { useUiStore } from "@/stores/ui.store";
|
import { useUiStore } from "@/stores/ui.store";
|
||||||
import {
|
import {
|
||||||
faAngleDown,
|
faAngleDown,
|
||||||
@@ -46,10 +46,11 @@ const props = defineProps<{
|
|||||||
hostOpaqueRef: XenApiHost["$ref"];
|
hostOpaqueRef: XenApiHost["$ref"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { getByOpaqueRef } = useHostCollection();
|
const { getByOpaqueRef } = useHostStore().subscribe();
|
||||||
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
|
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
|
||||||
|
|
||||||
const { pool } = usePoolCollection();
|
const { pool } = usePoolStore().subscribe();
|
||||||
|
|
||||||
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
|
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
|
||||||
|
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
|
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
|
||||||
const { records: hosts, isReady, hasError } = useHostCollection();
|
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ import InfraHostList from "@/components/infra/InfraHostList.vue";
|
|||||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||||
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
||||||
import InfraVmList from "@/components/infra/InfraVmList.vue";
|
import InfraVmList from "@/components/infra/InfraVmList.vue";
|
||||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
import { usePoolStore } from "@/stores/pool.store";
|
||||||
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
|
||||||
const { isReady, hasError, pool } = usePoolCollection();
|
const { isReady, hasError, pool } = usePoolStore().subscribe();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
import InfraAction from "@/components/infra/InfraAction.vue";
|
import InfraAction from "@/components/infra/InfraAction.vue";
|
||||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import type { XenApiVm } from "@/libs/xen-api";
|
import type { XenApiVm } from "@/libs/xen-api";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useIntersectionObserver } from "@vueuse/core";
|
import { useIntersectionObserver } from "@vueuse/core";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
@@ -29,7 +29,7 @@ const props = defineProps<{
|
|||||||
vmOpaqueRef: XenApiVm["$ref"];
|
vmOpaqueRef: XenApiVm["$ref"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { getByOpaqueRef } = useVmCollection();
|
const { getByOpaqueRef } = useVmStore().subscribe();
|
||||||
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
|
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
|
||||||
const rootElement = ref();
|
const rootElement = ref();
|
||||||
const isVisible = ref(false);
|
const isVisible = ref(false);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
||||||
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
|
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import type { XenApiHost } from "@/libs/xen-api";
|
import type { XenApiHost } from "@/libs/xen-api";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ const props = defineProps<{
|
|||||||
hostOpaqueRef?: XenApiHost["$ref"];
|
hostOpaqueRef?: XenApiHost["$ref"];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { isReady, recordsByHostRef, hasError } = useVmCollection();
|
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
|
||||||
|
|
||||||
const vms = computed(() =>
|
const vms = computed(() =>
|
||||||
recordsByHostRef.value.get(
|
recordsByHostRef.value.get(
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||||
import TitleBar from "@/components/TitleBar.vue";
|
import TitleBar from "@/components/TitleBar.vue";
|
||||||
|
import { usePoolStore } from "@/stores/pool.store";
|
||||||
|
|
||||||
const { pool } = usePoolCollection();
|
const { pool } = usePoolStore().subscribe();
|
||||||
|
|
||||||
const name = computed(() => pool.value?.name_label ?? "...");
|
const name = computed(() => pool.value?.name_label ?? "...");
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import RouterTab from "@/components/RouterTab.vue";
|
import RouterTab from "@/components/RouterTab.vue";
|
||||||
import UiTabBar from "@/components/ui/UiTabBar.vue";
|
import UiTabBar from "@/components/ui/UiTabBar.vue";
|
||||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
import { usePoolStore } from "@/stores/pool.store";
|
||||||
|
|
||||||
const { pool, isReady } = usePoolCollection();
|
const { pool, isReady } = usePoolStore().subscribe();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ import UiCard from "@/components/ui/UiCard.vue";
|
|||||||
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
|
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
|
||||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { useVmMetricsCollection } from "@/composables/xen-api-collection/vm-metrics-collection.composable";
|
|
||||||
import { percent } from "@/libs/utils";
|
import { percent } from "@/libs/utils";
|
||||||
import { POWER_STATE } from "@/libs/xen-api";
|
import { POWER_STATE } from "@/libs/xen-api";
|
||||||
|
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
import { useVmMetricsStore } from "@/stores/vm-metrics.store";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { logicAnd } from "@vueuse/math";
|
import { logicAnd } from "@vueuse/math";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
@@ -51,16 +52,18 @@ const {
|
|||||||
hasError: hostStoreHasError,
|
hasError: hostStoreHasError,
|
||||||
isReady: isHostStoreReady,
|
isReady: isHostStoreReady,
|
||||||
runningHosts,
|
runningHosts,
|
||||||
} = useHostCollection();
|
} = useHostStore().subscribe({
|
||||||
|
hostMetricsSubscription: useHostMetricsStore().subscribe(),
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hasError: vmStoreHasError,
|
hasError: vmStoreHasError,
|
||||||
isReady: isVmStoreReady,
|
isReady: isVmStoreReady,
|
||||||
records: vms,
|
records: vms,
|
||||||
} = useVmCollection();
|
} = useVmStore().subscribe();
|
||||||
|
|
||||||
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
|
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
|
||||||
useVmMetricsCollection();
|
useVmMetricsStore().subscribe();
|
||||||
|
|
||||||
const nPCpu = computed(() =>
|
const nPCpu = computed(() =>
|
||||||
runningHosts.value.reduce(
|
runningHosts.value.reduce(
|
||||||
|
|||||||
@@ -11,20 +11,20 @@
|
|||||||
</UiCard>
|
</UiCard>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { vTooltip } from "@/directives/tooltip.directive";
|
import { vTooltip } from "@/directives/tooltip.directive";
|
||||||
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
|
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
|
||||||
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
|
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
|
||||||
import UiCard from "@/components/ui/UiCard.vue";
|
import UiCard from "@/components/ui/UiCard.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { computed, inject, type ComputedRef } from "vue";
|
import { computed, inject, type ComputedRef } from "vue";
|
||||||
import type { Stat } from "@/composables/fetch-stats.composable";
|
import type { Stat } from "@/composables/fetch-stats.composable";
|
||||||
import type { HostStats, VmStats } from "@/libs/xapi-stats";
|
import type { HostStats, VmStats } from "@/libs/xapi-stats";
|
||||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||||
|
|
||||||
const { hasError: hasVmError } = useVmCollection();
|
const { hasError: hasVmError } = useVmStore().subscribe();
|
||||||
const { hasError: hasHostError } = useHostCollection();
|
const { hasError: hasHostError } = useHostStore().subscribe();
|
||||||
|
|
||||||
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
|
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
|
||||||
"vmStats",
|
"vmStats",
|
||||||
|
|||||||
@@ -12,21 +12,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { vTooltip } from "@/directives/tooltip.directive";
|
import { vTooltip } from "@/directives/tooltip.directive";
|
||||||
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
|
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
|
||||||
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
|
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
|
||||||
import UiCard from "@/components/ui/UiCard.vue";
|
import UiCard from "@/components/ui/UiCard.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { computed, inject } from "vue";
|
import { computed, inject } from "vue";
|
||||||
import type { ComputedRef } from "vue";
|
import type { ComputedRef } from "vue";
|
||||||
import type { HostStats, VmStats } from "@/libs/xapi-stats";
|
import type { HostStats, VmStats } from "@/libs/xapi-stats";
|
||||||
import type { Stat } from "@/composables/fetch-stats.composable";
|
import type { Stat } from "@/composables/fetch-stats.composable";
|
||||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||||
|
|
||||||
const { hasError: hasVmError } = useVmCollection();
|
const { hasError: hasVmError } = useVmStore().subscribe();
|
||||||
const { hasError: hasHostError } = useHostCollection();
|
const { hasError: hasHostError } = useHostStore().subscribe();
|
||||||
|
|
||||||
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
|
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
|
||||||
"vmStats",
|
"vmStats",
|
||||||
|
|||||||
@@ -26,21 +26,22 @@ import UiCard from "@/components/ui/UiCard.vue";
|
|||||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import UiSeparator from "@/components/ui/UiSeparator.vue";
|
import UiSeparator from "@/components/ui/UiSeparator.vue";
|
||||||
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
|
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isReady: isVmReady,
|
isReady: isVmReady,
|
||||||
records: vms,
|
records: vms,
|
||||||
hasError: hasVmError,
|
hasError: hasVmError,
|
||||||
} = useVmCollection();
|
runningVms,
|
||||||
|
} = useVmStore().subscribe();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isReady: isHostMetricsReady,
|
isReady: isHostMetricsReady,
|
||||||
records: hostMetrics,
|
records: hostMetrics,
|
||||||
hasError: hasHostMetricsError,
|
hasError: hasHostMetricsError,
|
||||||
} = useHostMetricsCollection();
|
} = useHostMetricsStore().subscribe();
|
||||||
|
|
||||||
const hasError = computed(() => hasVmError.value || hasHostMetricsError.value);
|
const hasError = computed(() => hasVmError.value || hasHostMetricsError.value);
|
||||||
|
|
||||||
@@ -54,7 +55,5 @@ const activeHostsCount = computed(
|
|||||||
|
|
||||||
const totalVmsCount = computed(() => vms.value.length);
|
const totalVmsCount = computed(() => vms.value.length);
|
||||||
|
|
||||||
const activeVmsCount = computed(
|
const activeVmsCount = computed(() => runningVms.value.length);
|
||||||
() => vms.value.filter((vm) => vm.power_state === "Running").length
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
|
|||||||
import UiCard from "@/components/ui/UiCard.vue";
|
import UiCard from "@/components/ui/UiCard.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import UsageBar from "@/components/UsageBar.vue";
|
import UsageBar from "@/components/UsageBar.vue";
|
||||||
import { useSrCollection } from "@/composables/xen-api-collection/sr-collection.composable";
|
import { useSrStore } from "@/stores/storage.store";
|
||||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const { records: srs, isReady, hasError } = useSrCollection();
|
const { records: srs, isReady, hasError } = useSrStore().subscribe();
|
||||||
|
|
||||||
const data = computed<{
|
const data = computed<{
|
||||||
result: { id: string; label: string; value: number }[];
|
result: { id: string; label: string; value: number }[];
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UiCard>
|
|
||||||
<UiCardTitle :count="pendingTasks.length">{{ $t("tasks") }}</UiCardTitle>
|
|
||||||
<TasksTable :pending-tasks="pendingTasks" />
|
|
||||||
</UiCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import TasksTable from "@/components/tasks/TasksTable.vue";
|
|
||||||
import UiCard from "@/components/ui/UiCard.vue";
|
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
|
||||||
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
|
|
||||||
|
|
||||||
const { pendingTasks } = useTaskCollection();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="postcss" scoped></style>
|
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
import NoDataError from "@/components/NoDataError.vue";
|
import NoDataError from "@/components/NoDataError.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import UsageBar from "@/components/UsageBar.vue";
|
import UsageBar from "@/components/UsageBar.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { getAvgCpuUsage } from "@/libs/utils";
|
import { getAvgCpuUsage } from "@/libs/utils";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
import { IK_HOST_STATS } from "@/types/injection-keys";
|
import { IK_HOST_STATS } from "@/types/injection-keys";
|
||||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||||
import { computed, type ComputedRef, inject } from "vue";
|
import { computed, type ComputedRef, inject } from "vue";
|
||||||
|
|
||||||
const { hasError } = useHostCollection();
|
const { hasError } = useHostStore().subscribe();
|
||||||
|
|
||||||
const stats = inject(
|
const stats = inject(
|
||||||
IK_HOST_STATS,
|
IK_HOST_STATS,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import type { HostStats } from "@/libs/xapi-stats";
|
import type { HostStats } from "@/libs/xapi-stats";
|
||||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||||
import { sumBy } from "lodash-es";
|
import { sumBy } from "lodash-es";
|
||||||
@@ -29,7 +29,7 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
||||||
|
|
||||||
const { records: hosts } = useHostCollection();
|
const { records: hosts } = useHostStore().subscribe();
|
||||||
|
|
||||||
const customMaxValue = computed(
|
const customMaxValue = computed(
|
||||||
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
|
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
|
||||||
|
|||||||
@@ -12,13 +12,13 @@
|
|||||||
import NoDataError from "@/components/NoDataError.vue";
|
import NoDataError from "@/components/NoDataError.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import UsageBar from "@/components/UsageBar.vue";
|
import UsageBar from "@/components/UsageBar.vue";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { getAvgCpuUsage } from "@/libs/utils";
|
import { getAvgCpuUsage } from "@/libs/utils";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { IK_VM_STATS } from "@/types/injection-keys";
|
import { IK_VM_STATS } from "@/types/injection-keys";
|
||||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||||
import { computed, type ComputedRef, inject } from "vue";
|
import { computed, type ComputedRef, inject } from "vue";
|
||||||
|
|
||||||
const { hasError } = useVmCollection();
|
const { hasError } = useVmStore().subscribe();
|
||||||
|
|
||||||
const stats = inject(
|
const stats = inject(
|
||||||
IK_VM_STATS,
|
IK_VM_STATS,
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { IK_HOST_STATS } from "@/types/injection-keys";
|
import { IK_HOST_STATS } from "@/types/injection-keys";
|
||||||
import { type ComputedRef, computed, inject } from "vue";
|
import { type ComputedRef, computed, inject } from "vue";
|
||||||
import UsageBar from "@/components/UsageBar.vue";
|
import UsageBar from "@/components/UsageBar.vue";
|
||||||
import { formatSize, parseRamUsage } from "@/libs/utils";
|
import { formatSize, parseRamUsage } from "@/libs/utils";
|
||||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||||
import NoDataError from "@/components/NoDataError.vue";
|
import NoDataError from "@/components/NoDataError.vue";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
|
||||||
const { hasError } = useHostCollection();
|
const { hasError } = useHostStore().subscribe();
|
||||||
|
|
||||||
const stats = inject(
|
const stats = inject(
|
||||||
IK_HOST_STATS,
|
IK_HOST_STATS,
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
|
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
import { formatSize, getHostMemory } from "@/libs/utils";
|
||||||
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
|
|
||||||
import { formatSize } from "@/libs/utils";
|
|
||||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||||
|
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||||
import { sumBy } from "lodash-es";
|
import { sumBy } from "lodash-es";
|
||||||
@@ -31,22 +31,27 @@ const LinearChart = defineAsyncComponent(
|
|||||||
() => import("@/components/charts/LinearChart.vue")
|
() => import("@/components/charts/LinearChart.vue")
|
||||||
);
|
);
|
||||||
|
|
||||||
const { runningHosts } = useHostCollection();
|
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||||
const { getHostMemory } = useHostMetricsCollection();
|
|
||||||
|
const hostStore = useHostStore();
|
||||||
|
const { runningHosts } = hostStore.subscribe({ hostMetricsSubscription });
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
||||||
|
|
||||||
const customMaxValue = computed(() =>
|
const customMaxValue = computed(() =>
|
||||||
sumBy(runningHosts.value, (host) => getHostMemory(host)?.size ?? 0)
|
sumBy(
|
||||||
|
runningHosts.value,
|
||||||
|
(host) => getHostMemory(host, hostMetricsSubscription)?.size ?? 0
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentData = computed(() => {
|
const currentData = computed(() => {
|
||||||
let size = 0,
|
let size = 0,
|
||||||
usage = 0;
|
usage = 0;
|
||||||
runningHosts.value.forEach((host) => {
|
runningHosts.value.forEach((host) => {
|
||||||
const hostMemory = getHostMemory(host);
|
const hostMemory = getHostMemory(host, hostMetricsSubscription);
|
||||||
size += hostMemory?.size ?? 0;
|
size += hostMemory?.size ?? 0;
|
||||||
usage += hostMemory?.usage ?? 0;
|
usage += hostMemory?.usage ?? 0;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,13 +12,13 @@
|
|||||||
import NoDataError from "@/components/NoDataError.vue";
|
import NoDataError from "@/components/NoDataError.vue";
|
||||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||||
import UsageBar from "@/components/UsageBar.vue";
|
import UsageBar from "@/components/UsageBar.vue";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { formatSize, parseRamUsage } from "@/libs/utils";
|
import { formatSize, parseRamUsage } from "@/libs/utils";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { IK_VM_STATS } from "@/types/injection-keys";
|
import { IK_VM_STATS } from "@/types/injection-keys";
|
||||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||||
import { computed, type ComputedRef, inject } from "vue";
|
import { computed, type ComputedRef, inject } from "vue";
|
||||||
|
|
||||||
const { hasError } = useVmCollection();
|
const { hasError } = useVmStore().subscribe();
|
||||||
|
|
||||||
const stats = inject(
|
const stats = inject(
|
||||||
IK_VM_STATS,
|
IK_VM_STATS,
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import RelativeTime from "@/components/RelativeTime.vue";
|
import RelativeTime from "@/components/RelativeTime.vue";
|
||||||
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
|
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { parseDateTime } from "@/libs/utils";
|
import { parseDateTime } from "@/libs/utils";
|
||||||
import type { XenApiTask } from "@/libs/xen-api";
|
import type { XenApiTask } from "@/libs/xen-api";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -44,7 +44,7 @@ const props = defineProps<{
|
|||||||
task: XenApiTask;
|
task: XenApiTask;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { getByOpaqueRef: getHost } = useHostCollection();
|
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
|
||||||
|
|
||||||
const createdAt = computed(() => parseDateTime(props.task.created));
|
const createdAt = computed(() => parseDateTime(props.task.created));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<UiTable :color="hasError ? 'error' : undefined" class="tasks-table">
|
<UiTable class="tasks-table" :color="hasError ? 'error' : undefined">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $t("name") }}</th>
|
<th>{{ $t("name") }}</th>
|
||||||
@@ -20,9 +20,6 @@
|
|||||||
<UiSpinner class="loader" />
|
<UiSpinner class="loader" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else-if="!hasTasks">
|
|
||||||
<td class="no-tasks" colspan="5">{{ $t("no-tasks") }}</td>
|
|
||||||
</tr>
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<TaskRow
|
<TaskRow
|
||||||
v-for="task in pendingTasks"
|
v-for="task in pendingTasks"
|
||||||
@@ -38,35 +35,20 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import TaskRow from "@/components/tasks/TaskRow.vue";
|
import TaskRow from "@/components/tasks/TaskRow.vue";
|
||||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
|
||||||
import UiTable from "@/components/ui/UiTable.vue";
|
import UiTable from "@/components/ui/UiTable.vue";
|
||||||
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
|
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||||
|
import { useTaskStore } from "@/stores/task.store";
|
||||||
import type { XenApiTask } from "@/libs/xen-api";
|
import type { XenApiTask } from "@/libs/xen-api";
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
pendingTasks: XenApiTask[];
|
pendingTasks: XenApiTask[];
|
||||||
finishedTasks?: XenApiTask[];
|
finishedTasks: XenApiTask[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { hasError, isFetching } = useTaskCollection();
|
const { hasError, isFetching } = useTaskStore().subscribe();
|
||||||
|
|
||||||
const hasTasks = computed(
|
|
||||||
() => props.pendingTasks.length > 0 || (props.finishedTasks?.length ?? 0) > 0
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
.tasks-table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-tasks {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-blue-scale-300);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
td[colspan="5"] {
|
td[colspan="5"] {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
class="left"
|
class="left"
|
||||||
>
|
>
|
||||||
<slot>{{ left }}</slot>
|
<slot>{{ left }}</slot>
|
||||||
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
|
|
||||||
</component>
|
</component>
|
||||||
<component
|
<component
|
||||||
:is="subtitle ? 'h6' : 'h5'"
|
:is="subtitle ? 'h6' : 'h5'"
|
||||||
@@ -19,17 +18,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
defineProps<{
|
||||||
|
subtitle?: boolean;
|
||||||
withDefaults(
|
left?: string;
|
||||||
defineProps<{
|
right?: string;
|
||||||
subtitle?: boolean;
|
}>();
|
||||||
left?: string;
|
|
||||||
right?: string;
|
|
||||||
count?: number;
|
|
||||||
}>(),
|
|
||||||
{ count: 0 }
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
@@ -62,9 +55,6 @@ withDefaults(
|
|||||||
font-size: var(--section-title-left-size);
|
font-size: var(--section-title-left-size);
|
||||||
font-weight: var(--section-title-left-weight);
|
font-weight: var(--section-title-left-weight);
|
||||||
color: var(--section-title-left-color);
|
color: var(--section-title-left-color);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
@@ -72,8 +62,4 @@ withDefaults(
|
|||||||
font-weight: var(--section-title-right-weight);
|
font-weight: var(--section-title-right-weight);
|
||||||
color: var(--section-title-right-color);
|
color: var(--section-title-right-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<li class="ui-resource">
|
|
||||||
<UiIcon :icon="icon" class="icon" />
|
|
||||||
<div class="separator" />
|
|
||||||
<div class="label">{{ label }}</div>
|
|
||||||
<div class="count">{{ count }}</div>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
|
||||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
icon: IconDefinition;
|
|
||||||
label: string;
|
|
||||||
count: string | number;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
|
||||||
.ui-resource {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: var(--color-extra-blue-base);
|
|
||||||
font-size: 3.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
height: 4.5rem;
|
|
||||||
width: 0;
|
|
||||||
border-left: 0.1rem solid var(--color-extra-blue-base);
|
|
||||||
background-color: var(--color-extra-blue-base);
|
|
||||||
margin: 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
font-weight: 400;
|
|
||||||
margin-left: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ul class="ui-resources">
|
|
||||||
<slot />
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
|
||||||
.ui-resources {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem 5.4rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -12,9 +12,10 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { vTooltip } from "@/directives/tooltip.directive";
|
import { vTooltip } from "@/directives/tooltip.directive";
|
||||||
|
import { isOperationsPending } from "@/libs/utils";
|
||||||
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
|
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||||
import { faCopy } from "@fortawesome/free-solid-svg-icons";
|
import { faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
@@ -23,7 +24,7 @@ const props = defineProps<{
|
|||||||
selectedRefs: XenApiVm["$ref"][];
|
selectedRefs: XenApiVm["$ref"][];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { getByOpaqueRef, isOperationPending } = useVmCollection();
|
const { getByOpaqueRef } = useVmStore().subscribe();
|
||||||
|
|
||||||
const selectedVms = computed(() =>
|
const selectedVms = computed(() =>
|
||||||
props.selectedRefs
|
props.selectedRefs
|
||||||
@@ -38,7 +39,7 @@ const areAllSelectedVmsHalted = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const areSomeSelectedVmsCloning = computed(() =>
|
const areSomeSelectedVmsCloning = computed(() =>
|
||||||
selectedVms.value.some((vm) => isOperationPending(vm, VM_OPERATION.CLONE))
|
selectedVms.value.some((vm) => isOperationsPending(vm, VM_OPERATION.CLONE))
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
|
|||||||
@@ -35,11 +35,11 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { POWER_STATE } from "@/libs/xen-api";
|
import { POWER_STATE } from "@/libs/xen-api";
|
||||||
import UiButton from "@/components/ui/UiButton.vue";
|
import UiButton from "@/components/ui/UiButton.vue";
|
||||||
import UiModal from "@/components/ui/UiModal.vue";
|
import UiModal from "@/components/ui/UiModal.vue";
|
||||||
import useModal from "@/composables/modal.composable";
|
import useModal from "@/composables/modal.composable";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||||
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
@@ -51,7 +51,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const xenApi = useXenApiStore().getXapi();
|
const xenApi = useXenApiStore().getXapi();
|
||||||
const { getByOpaqueRef: getVm } = useVmCollection();
|
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
|
||||||
const {
|
const {
|
||||||
open: openDeleteModal,
|
open: openDeleteModal,
|
||||||
close: closeDeleteModal,
|
close: closeDeleteModal,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
|
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
|
||||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||||
@@ -37,6 +36,7 @@ import {
|
|||||||
faFileCsv,
|
faFileCsv,
|
||||||
faFileExport,
|
faFileExport,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { vTooltip } from "@/directives/tooltip.directive";
|
import { vTooltip } from "@/directives/tooltip.directive";
|
||||||
import type { XenApiVm } from "@/libs/xen-api";
|
import type { XenApiVm } from "@/libs/xen-api";
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ const props = defineProps<{
|
|||||||
vmRefs: XenApiVm["$ref"][];
|
vmRefs: XenApiVm["$ref"][];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { getByOpaqueRef: getVm } = useVmCollection();
|
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
|
||||||
const vms = computed(() =>
|
const vms = computed(() =>
|
||||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,12 +95,13 @@
|
|||||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
import { isHostRunning, isOperationsPending } from "@/libs/utils";
|
||||||
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
|
|
||||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
|
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
|
||||||
import { POWER_STATE, VM_OPERATION } from "@/libs/xen-api";
|
import { POWER_STATE, VM_OPERATION } from "@/libs/xen-api";
|
||||||
|
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||||
|
import { useHostStore } from "@/stores/host.store";
|
||||||
|
import { usePoolStore } from "@/stores/pool.store";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||||
import {
|
import {
|
||||||
faCirclePlay,
|
faCirclePlay,
|
||||||
@@ -120,12 +121,12 @@ const props = defineProps<{
|
|||||||
vmRefs: XenApiVm["$ref"][];
|
vmRefs: XenApiVm["$ref"][];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { getByOpaqueRef: getVm, isOperationPending } = useVmCollection();
|
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
|
||||||
const { records: hosts } = useHostCollection();
|
const { records: hosts } = useHostStore().subscribe();
|
||||||
const { pool } = usePoolCollection();
|
const { pool } = usePoolStore().subscribe();
|
||||||
const { isHostRunning } = useHostMetricsCollection();
|
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||||
|
|
||||||
const vms = computed(() =>
|
const vms = computed<XenApiVm[]>(() =>
|
||||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ const areVmsPaused = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const areOperationsPending = (operation: VM_OPERATION | VM_OPERATION[]) =>
|
const areOperationsPending = (operation: VM_OPERATION | VM_OPERATION[]) =>
|
||||||
vms.value.some((vm) => isOperationPending(vm, operation));
|
vms.value.some((vm) => isOperationsPending(vm, operation));
|
||||||
|
|
||||||
const areVmsBusyToStart = computed(() =>
|
const areVmsBusyToStart = computed(() =>
|
||||||
areOperationsPending(VM_OPERATION.START)
|
areOperationsPending(VM_OPERATION.START)
|
||||||
@@ -179,7 +180,9 @@ const areVmsBusyToForceShutdown = computed(() =>
|
|||||||
areOperationsPending(VM_OPERATION.HARD_SHUTDOWN)
|
areOperationsPending(VM_OPERATION.HARD_SHUTDOWN)
|
||||||
);
|
);
|
||||||
const getHostState = (host: XenApiHost) =>
|
const getHostState = (host: XenApiHost) =>
|
||||||
isHostRunning(host) ? POWER_STATE.RUNNING : POWER_STATE.HALTED;
|
isHostRunning(host, hostMetricsSubscription)
|
||||||
|
? POWER_STATE.RUNNING
|
||||||
|
: POWER_STATE.HALTED;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="postcss" scoped>
|
<style lang="postcss" scoped>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import AppMenu from "@/components/menu/AppMenu.vue";
|
|||||||
import TitleBar from "@/components/TitleBar.vue";
|
import TitleBar from "@/components/TitleBar.vue";
|
||||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||||
import UiButton from "@/components/ui/UiButton.vue";
|
import UiButton from "@/components/ui/UiButton.vue";
|
||||||
|
import { useVmStore } from "@/stores/vm.store";
|
||||||
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
||||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
|
||||||
import type { XenApiVm } from "@/libs/xen-api";
|
import type { XenApiVm } from "@/libs/xen-api";
|
||||||
import {
|
import {
|
||||||
faAngleDown,
|
faAngleDown,
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
const { getByUuid: getVmByUuid } = useVmCollection();
|
const { getByUuid: getVmByUuid } = useVmStore().subscribe();
|
||||||
const { currentRoute } = useRouter();
|
const { currentRoute } = useRouter();
|
||||||
|
|
||||||
const vm = computed(() =>
|
const vm = computed(() =>
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export type Stat<T> = {
|
|||||||
pausable: Pausable;
|
pausable: Pausable;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetStats<
|
type GetStats<
|
||||||
T extends XenApiHost | XenApiVm,
|
T extends XenApiHost | XenApiVm,
|
||||||
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
|
S extends HostStats | VmStats
|
||||||
> = (
|
> = (
|
||||||
uuid: T["uuid"],
|
uuid: T["uuid"],
|
||||||
granularity: GRANULARITY,
|
granularity: GRANULARITY,
|
||||||
@@ -29,7 +29,7 @@ export type GetStats<
|
|||||||
|
|
||||||
export type FetchedStats<
|
export type FetchedStats<
|
||||||
T extends XenApiHost | XenApiVm,
|
T extends XenApiHost | XenApiVm,
|
||||||
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
|
S extends HostStats | VmStats
|
||||||
> = {
|
> = {
|
||||||
register: (object: T) => void;
|
register: (object: T) => void;
|
||||||
unregister: (object: T) => void;
|
unregister: (object: T) => void;
|
||||||
@@ -40,7 +40,7 @@ export type FetchedStats<
|
|||||||
|
|
||||||
export default function useFetchStats<
|
export default function useFetchStats<
|
||||||
T extends XenApiHost | XenApiVm,
|
T extends XenApiHost | XenApiVm,
|
||||||
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
|
S extends HostStats | VmStats
|
||||||
>(getStats: GetStats<T, S>, granularity: GRANULARITY): FetchedStats<T, S> {
|
>(getStats: GetStats<T, S>, granularity: GRANULARITY): FetchedStats<T, S> {
|
||||||
const stats = ref<Map<string, Stat<S>>>(new Map());
|
const stats = ref<Map<string, Stat<S>>>(new Map());
|
||||||
const timestamp = ref<number[]>([0, 0]);
|
const timestamp = ref<number[]>([0, 0]);
|
||||||
@@ -108,7 +108,7 @@ export default function useFetchStats<
|
|||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
unregister,
|
unregister,
|
||||||
stats: computed(() => Array.from(stats.value.values()) as Stat<S>[]),
|
stats: computed<Stat<S>[]>(() => Array.from(stats.value.values())),
|
||||||
timestampStart: computed(() => timestamp.value[0]),
|
timestampStart: computed(() => timestamp.value[0]),
|
||||||
timestampEnd: computed(() => timestamp.value[1]),
|
timestampEnd: computed(() => timestamp.value[1]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { RawObjectType } from "@/libs/xen-api";
|
|
||||||
import { getXenApiCollection } from "@/libs/xen-api-collection";
|
|
||||||
import type {
|
|
||||||
RawTypeToRecord,
|
|
||||||
XenApiBaseCollection,
|
|
||||||
XenApiCollectionManager,
|
|
||||||
} from "@/types/xen-api-collection";
|
|
||||||
import { tryOnUnmounted } from "@vueuse/core";
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
export const useXenApiCollection = <
|
|
||||||
ObjectType extends RawObjectType,
|
|
||||||
Record extends RawTypeToRecord<ObjectType>,
|
|
||||||
Immediate extends boolean,
|
|
||||||
>(
|
|
||||||
type: ObjectType,
|
|
||||||
options?: { immediate?: Immediate }
|
|
||||||
): XenApiBaseCollection<Record, Immediate> => {
|
|
||||||
const baseCollection = getXenApiCollection(type);
|
|
||||||
const isDeferred = options?.immediate === false;
|
|
||||||
|
|
||||||
const id = Symbol();
|
|
||||||
|
|
||||||
const collection = {
|
|
||||||
records: baseCollection.records,
|
|
||||||
isFetching: baseCollection.isFetching,
|
|
||||||
isReloading: baseCollection.isReloading,
|
|
||||||
isReady: baseCollection.isReady,
|
|
||||||
hasError: baseCollection.hasError,
|
|
||||||
hasUuid: baseCollection.hasUuid.bind(baseCollection),
|
|
||||||
getByUuid: baseCollection.getByUuid.bind(baseCollection),
|
|
||||||
getByOpaqueRef: baseCollection.getByOpaqueRef.bind(baseCollection),
|
|
||||||
};
|
|
||||||
|
|
||||||
tryOnUnmounted(() => baseCollection.unsubscribe(id));
|
|
||||||
|
|
||||||
if (isDeferred) {
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
start: () => baseCollection.subscribe(id),
|
|
||||||
isStarted: computed(() => baseCollection.hasSubscriptions.value),
|
|
||||||
} as XenApiBaseCollection<Record, false>;
|
|
||||||
}
|
|
||||||
|
|
||||||
baseCollection.subscribe(id);
|
|
||||||
return collection as XenApiBaseCollection<Record, Immediate>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useXenApiCollectionManager = <
|
|
||||||
ObjectType extends RawObjectType,
|
|
||||||
Record extends RawTypeToRecord<ObjectType>,
|
|
||||||
>(
|
|
||||||
type: ObjectType
|
|
||||||
): XenApiCollectionManager<Record> => {
|
|
||||||
const collection = getXenApiCollection(type);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasSubscriptions: collection.hasSubscriptions,
|
|
||||||
add: collection.add.bind(collection),
|
|
||||||
remove: collection.remove.bind(collection),
|
|
||||||
update: collection.update.bind(collection),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
|
|
||||||
export const useConsoleCollection = () => useXenApiCollection("console");
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { GetStats } from "@/composables/fetch-stats.composable";
|
|
||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
|
|
||||||
import type { XenApiHost } from "@/libs/xen-api";
|
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
export const useHostCollection = () => {
|
|
||||||
const collection = useXenApiCollection("host");
|
|
||||||
const hostMetricsCollection = useHostMetricsCollection();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
runningHosts: computed(() =>
|
|
||||||
collection.records.value.filter((host) =>
|
|
||||||
hostMetricsCollection.isHostRunning(host)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
getStats: ((
|
|
||||||
hostUuid,
|
|
||||||
granularity,
|
|
||||||
ignoreExpired = false,
|
|
||||||
{ abortSignal }
|
|
||||||
) => {
|
|
||||||
const xenApiStore = useXenApiStore();
|
|
||||||
const host = collection.getByUuid(hostUuid);
|
|
||||||
|
|
||||||
if (host === undefined) {
|
|
||||||
throw new Error(`Host ${hostUuid} could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const xapiStats = xenApiStore.isConnected
|
|
||||||
? xenApiStore.getXapiStats()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return xapiStats?._getAndUpdateStats({
|
|
||||||
abortSignal,
|
|
||||||
host,
|
|
||||||
ignoreExpired,
|
|
||||||
uuid: host.uuid,
|
|
||||||
granularity,
|
|
||||||
});
|
|
||||||
}) as GetStats<XenApiHost>,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
import type { XenApiHost } from "@/libs/xen-api";
|
|
||||||
|
|
||||||
export const useHostMetricsCollection = () => {
|
|
||||||
const collection = useXenApiCollection("host_metrics");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
getHostMemory: (host: XenApiHost) => {
|
|
||||||
const hostMetrics = collection.getByOpaqueRef(host.metrics);
|
|
||||||
|
|
||||||
if (hostMetrics !== undefined) {
|
|
||||||
const total = +hostMetrics.memory_total;
|
|
||||||
return {
|
|
||||||
usage: total - +hostMetrics.memory_free,
|
|
||||||
size: total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isHostRunning: (host: XenApiHost) => {
|
|
||||||
return collection.getByOpaqueRef(host.metrics)?.live === true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
import type { XenApiPool } from "@/libs/xen-api";
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
export const usePoolCollection = () => {
|
|
||||||
const poolCollection = useXenApiCollection("pool");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...poolCollection,
|
|
||||||
pool: computed<XenApiPool | undefined>(
|
|
||||||
() => poolCollection.records.value[0]
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
|
|
||||||
export const useSrCollection = () => useXenApiCollection("SR");
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
|
|
||||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
|
||||||
import useCollectionSorter from "@/composables/collection-sorter.composable";
|
|
||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
import useFilteredCollection from "@/composables/filtered-collection.composable";
|
|
||||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
|
||||||
import type { XenApiTask } from "@/libs/xen-api";
|
|
||||||
|
|
||||||
export const useTaskCollection = () => {
|
|
||||||
const collection = useXenApiCollection("task");
|
|
||||||
|
|
||||||
const { compareFn } = useCollectionSorter<XenApiTask>({
|
|
||||||
initialSorts: ["-created"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { predicate } = useCollectionFilter({
|
|
||||||
initialFilters: [
|
|
||||||
"!name_label:|(SR.scan host.call_plugin)",
|
|
||||||
"status:pending",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedTasks = useSortedCollection(collection.records, compareFn);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
pendingTasks: useFilteredCollection<XenApiTask>(sortedTasks, predicate),
|
|
||||||
finishedTasks: useArrayRemovedItemsHistory(
|
|
||||||
sortedTasks,
|
|
||||||
(task) => task.uuid,
|
|
||||||
{
|
|
||||||
limit: 50,
|
|
||||||
onRemove: (tasks) =>
|
|
||||||
tasks.map((task) => ({
|
|
||||||
...task,
|
|
||||||
finished: new Date().toISOString(),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import type { GetStats } from "@/composables/fetch-stats.composable";
|
|
||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
|
||||||
import { sortRecordsByNameLabel } from "@/libs/utils";
|
|
||||||
import type { VmStats } from "@/libs/xapi-stats";
|
|
||||||
import {
|
|
||||||
POWER_STATE,
|
|
||||||
VM_OPERATION,
|
|
||||||
type XenApiHost,
|
|
||||||
type XenApiVm,
|
|
||||||
} from "@/libs/xen-api";
|
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
|
||||||
import { castArray } from "lodash-es";
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
export const useVmCollection = () => {
|
|
||||||
const collection = useXenApiCollection("VM");
|
|
||||||
const hostCollection = useHostCollection();
|
|
||||||
const xenApiStore = useXenApiStore();
|
|
||||||
|
|
||||||
const records = computed(() =>
|
|
||||||
collection.records.value
|
|
||||||
.filter(
|
|
||||||
(vm) => !vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain
|
|
||||||
)
|
|
||||||
.sort(sortRecordsByNameLabel)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...collection,
|
|
||||||
records,
|
|
||||||
isOperationPending: (
|
|
||||||
vm: XenApiVm,
|
|
||||||
operations: VM_OPERATION[] | VM_OPERATION
|
|
||||||
) => {
|
|
||||||
const currentOperations = Object.values(vm.current_operations);
|
|
||||||
return castArray(operations).some((operation) =>
|
|
||||||
currentOperations.includes(operation)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
runningVms: computed(() =>
|
|
||||||
records.value.filter((vm) => vm.power_state === POWER_STATE.RUNNING)
|
|
||||||
),
|
|
||||||
recordsByHostRef: computed(() => {
|
|
||||||
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
|
|
||||||
|
|
||||||
records.value.forEach((vm) => {
|
|
||||||
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
|
|
||||||
vmsByHostOpaqueRef.set(vm.resident_on, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
|
|
||||||
});
|
|
||||||
|
|
||||||
return vmsByHostOpaqueRef;
|
|
||||||
}),
|
|
||||||
getStats: ((id, granularity, ignoreExpired = false, { abortSignal }) => {
|
|
||||||
if (!xenApiStore.isConnected) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vm = collection.getByUuid(id);
|
|
||||||
|
|
||||||
if (vm === undefined) {
|
|
||||||
throw new Error(`VM ${id} could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = hostCollection.getByOpaqueRef(vm.resident_on);
|
|
||||||
|
|
||||||
if (host === undefined) {
|
|
||||||
throw new Error(`VM ${id} is halted or host could not be found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return xenApiStore.getXapiStats()._getAndUpdateStats<VmStats>({
|
|
||||||
abortSignal,
|
|
||||||
host,
|
|
||||||
ignoreExpired,
|
|
||||||
uuid: vm.uuid,
|
|
||||||
granularity,
|
|
||||||
});
|
|
||||||
}) as GetStats<XenApiVm>,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
|
||||||
|
|
||||||
export const useVmMetricsCollection = () => useXenApiCollection("VM_metrics");
|
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import type {
|
import type {
|
||||||
RawXenApiRecord,
|
RawXenApiRecord,
|
||||||
|
XenApiHost,
|
||||||
|
XenApiHostMetrics,
|
||||||
XenApiRecord,
|
XenApiRecord,
|
||||||
RawObjectType,
|
XenApiVm,
|
||||||
|
VM_OPERATION,
|
||||||
} from "@/libs/xen-api";
|
} from "@/libs/xen-api";
|
||||||
import type { Filter } from "@/types/filter";
|
import type { Filter } from "@/types/filter";
|
||||||
|
import type { Subscription } from "@/types/xapi-collection";
|
||||||
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
||||||
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
|
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { utcParse } from "d3-time-format";
|
import { utcParse } from "d3-time-format";
|
||||||
import humanFormat from "human-format";
|
import humanFormat from "human-format";
|
||||||
import { find, forEach, round, size, sum } from "lodash-es";
|
import { castArray, find, forEach, round, size, sum } from "lodash-es";
|
||||||
|
|
||||||
export function sortRecordsByNameLabel(
|
export function sortRecordsByNameLabel(
|
||||||
record1: { name_label: string },
|
record1: { name_label: string },
|
||||||
@@ -17,7 +21,14 @@ export function sortRecordsByNameLabel(
|
|||||||
const label1 = record1.name_label.toLocaleLowerCase();
|
const label1 = record1.name_label.toLocaleLowerCase();
|
||||||
const label2 = record2.name_label.toLocaleLowerCase();
|
const label2 = record2.name_label.toLocaleLowerCase();
|
||||||
|
|
||||||
return label1.localeCompare(label2);
|
switch (true) {
|
||||||
|
case label1 < label2:
|
||||||
|
return -1;
|
||||||
|
case label1 > label2:
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeRegExp(string: string) {
|
export function escapeRegExp(string: string) {
|
||||||
@@ -103,7 +114,29 @@ export function getStatsLength(stats?: object | any[]) {
|
|||||||
return size(find(stats, (stat) => stat != null));
|
return size(find(stats, (stat) => stat != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildXoObject = <T extends XenApiRecord<RawObjectType>>(
|
export function isHostRunning(
|
||||||
|
host: XenApiHost,
|
||||||
|
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
|
||||||
|
) {
|
||||||
|
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHostMemory(
|
||||||
|
host: XenApiHost,
|
||||||
|
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
|
||||||
|
) {
|
||||||
|
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
|
||||||
|
|
||||||
|
if (hostMetrics !== undefined) {
|
||||||
|
const total = +hostMetrics.memory_total;
|
||||||
|
return {
|
||||||
|
usage: total - +hostMetrics.memory_free,
|
||||||
|
size: total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildXoObject = <T extends XenApiRecord<string>>(
|
||||||
record: RawXenApiRecord<T>,
|
record: RawXenApiRecord<T>,
|
||||||
params: { opaqueRef: T["$ref"] }
|
params: { opaqueRef: T["$ref"] }
|
||||||
) => {
|
) => {
|
||||||
@@ -149,3 +182,13 @@ export function parseRamUsage(
|
|||||||
|
|
||||||
export const getFirst = <T>(value: T | T[]): T | undefined =>
|
export const getFirst = <T>(value: T | T[]): T | undefined =>
|
||||||
Array.isArray(value) ? value[0] : value;
|
Array.isArray(value) ? value[0] : value;
|
||||||
|
|
||||||
|
export const isOperationsPending = (
|
||||||
|
obj: XenApiVm,
|
||||||
|
operations: VM_OPERATION[] | VM_OPERATION
|
||||||
|
) => {
|
||||||
|
const currentOperations = Object.values(obj.current_operations);
|
||||||
|
return castArray(operations).some((operation) =>
|
||||||
|
currentOperations.includes(operation)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
|
|
||||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
|
||||||
import type { RawTypeToRecord } from "@/types/xen-api-collection";
|
|
||||||
import { whenever } from "@vueuse/core";
|
|
||||||
import { computed, reactive } from "vue";
|
|
||||||
|
|
||||||
const collections = new Map<RawObjectType, XenApiCollection<any>>();
|
|
||||||
|
|
||||||
export const getXenApiCollection = <
|
|
||||||
ObjectType extends RawObjectType,
|
|
||||||
Record extends RawTypeToRecord<ObjectType> = RawTypeToRecord<ObjectType>,
|
|
||||||
>(
|
|
||||||
type: ObjectType
|
|
||||||
) => {
|
|
||||||
if (!collections.has(type)) {
|
|
||||||
collections.set(type, new XenApiCollection(type));
|
|
||||||
}
|
|
||||||
|
|
||||||
return collections.get(type)! as XenApiCollection<Record>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class XenApiCollection<Record extends XenApiRecord<any>> {
|
|
||||||
private state = reactive({
|
|
||||||
isReady: false,
|
|
||||||
isFetching: false,
|
|
||||||
lastError: undefined as string | undefined,
|
|
||||||
subscriptions: new Set<symbol>(),
|
|
||||||
recordsByOpaqueRef: new Map<Record["$ref"], Record>(),
|
|
||||||
recordsByUuid: new Map<Record["uuid"], Record>(),
|
|
||||||
});
|
|
||||||
|
|
||||||
isReady = computed(() => this.state.isReady);
|
|
||||||
|
|
||||||
isFetching = computed(() => this.state.isFetching);
|
|
||||||
|
|
||||||
isReloading = computed(() => this.state.isReady && this.state.isFetching);
|
|
||||||
|
|
||||||
lastError = computed(() => this.state.lastError);
|
|
||||||
|
|
||||||
hasError = computed(() => this.state.lastError !== undefined);
|
|
||||||
|
|
||||||
hasSubscriptions = computed(() => this.state.subscriptions.size > 0);
|
|
||||||
|
|
||||||
records = computed(() => Array.from(this.state.recordsByOpaqueRef.values()));
|
|
||||||
|
|
||||||
subscribe(id: symbol) {
|
|
||||||
this.state.subscriptions.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe(id: symbol) {
|
|
||||||
this.state.subscriptions.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(private type: RawObjectType) {
|
|
||||||
const xenApiStore = useXenApiStore();
|
|
||||||
|
|
||||||
whenever(
|
|
||||||
() => xenApiStore.isConnected && this.hasSubscriptions.value,
|
|
||||||
() => this.fetchAll(xenApiStore)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getByOpaqueRef(opaqueRef: Record["$ref"]) {
|
|
||||||
return this.state.recordsByOpaqueRef.get(opaqueRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
getByUuid(uuid: Record["uuid"]) {
|
|
||||||
return this.state.recordsByUuid.get(uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasUuid(uuid: Record["uuid"]) {
|
|
||||||
return this.state.recordsByUuid.has(uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
add(record: Record) {
|
|
||||||
this.state.recordsByOpaqueRef.set(record.$ref, record);
|
|
||||||
this.state.recordsByUuid.set(record.uuid, record);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(record: Record) {
|
|
||||||
this.state.recordsByOpaqueRef.set(record.$ref, record);
|
|
||||||
this.state.recordsByUuid.set(record.uuid, record);
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(opaqueRef: Record["$ref"]) {
|
|
||||||
if (!this.state.recordsByOpaqueRef.has(opaqueRef)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = this.state.recordsByOpaqueRef.get(opaqueRef)!;
|
|
||||||
this.state.recordsByOpaqueRef.delete(opaqueRef);
|
|
||||||
this.state.recordsByUuid.delete(record.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchAll(xenApiStore: ReturnType<typeof useXenApiStore>) {
|
|
||||||
try {
|
|
||||||
this.state.isFetching = true;
|
|
||||||
this.state.lastError = undefined;
|
|
||||||
const records = await xenApiStore
|
|
||||||
.getXapi()
|
|
||||||
.loadRecords<any, Record>(this.type);
|
|
||||||
this.state.recordsByOpaqueRef.clear();
|
|
||||||
this.state.recordsByUuid.clear();
|
|
||||||
records.forEach((record) => this.add(record));
|
|
||||||
this.state.isReady = true;
|
|
||||||
} catch {
|
|
||||||
this.state.lastError = `[${this.type}] Failed to fetch records`;
|
|
||||||
} finally {
|
|
||||||
this.state.isFetching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { buildXoObject, parseDateTime } from "@/libs/utils";
|
import { buildXoObject, parseDateTime } from "@/libs/utils";
|
||||||
import type { RawTypeToRecord } from "@/types/xen-api-collection";
|
|
||||||
import { JSONRPCClient } from "json-rpc-2.0";
|
import { JSONRPCClient } from "json-rpc-2.0";
|
||||||
import { castArray } from "lodash-es";
|
import { castArray } from "lodash-es";
|
||||||
|
|
||||||
@@ -91,17 +90,14 @@ export enum VM_OPERATION {
|
|||||||
|
|
||||||
declare const __brand: unique symbol;
|
declare const __brand: unique symbol;
|
||||||
|
|
||||||
export interface XenApiRecord<Name extends RawObjectType> {
|
export interface XenApiRecord<Name extends string> {
|
||||||
$ref: string & { [__brand]: `${Name}Ref` };
|
$ref: string & { [__brand]: `${Name}Ref` };
|
||||||
uuid: string & { [__brand]: `${Name}Uuid` };
|
uuid: string & { [__brand]: `${Name}Uuid` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RawXenApiRecord<T extends XenApiRecord<RawObjectType>> = Omit<
|
export type RawXenApiRecord<T extends XenApiRecord<string>> = Omit<T, "$ref">;
|
||||||
T,
|
|
||||||
"$ref"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface XenApiPool extends XenApiRecord<"pool"> {
|
export interface XenApiPool extends XenApiRecord<"Pool"> {
|
||||||
cpu_info: {
|
cpu_info: {
|
||||||
cpu_count: string;
|
cpu_count: string;
|
||||||
};
|
};
|
||||||
@@ -109,7 +105,7 @@ export interface XenApiPool extends XenApiRecord<"pool"> {
|
|||||||
name_label: string;
|
name_label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XenApiHost extends XenApiRecord<"host"> {
|
export interface XenApiHost extends XenApiRecord<"Host"> {
|
||||||
address: string;
|
address: string;
|
||||||
name_label: string;
|
name_label: string;
|
||||||
metrics: XenApiHostMetrics["$ref"];
|
metrics: XenApiHostMetrics["$ref"];
|
||||||
@@ -118,13 +114,13 @@ export interface XenApiHost extends XenApiRecord<"host"> {
|
|||||||
software_version: { product_version: string };
|
software_version: { product_version: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XenApiSr extends XenApiRecord<"SR"> {
|
export interface XenApiSr extends XenApiRecord<"Sr"> {
|
||||||
name_label: string;
|
name_label: string;
|
||||||
physical_size: number;
|
physical_size: number;
|
||||||
physical_utilisation: number;
|
physical_utilisation: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XenApiVm extends XenApiRecord<"VM"> {
|
export interface XenApiVm extends XenApiRecord<"Vm"> {
|
||||||
current_operations: Record<string, VM_OPERATION>;
|
current_operations: Record<string, VM_OPERATION>;
|
||||||
guest_metrics: string;
|
guest_metrics: string;
|
||||||
metrics: XenApiVmMetrics["$ref"];
|
metrics: XenApiVmMetrics["$ref"];
|
||||||
@@ -139,24 +135,24 @@ export interface XenApiVm extends XenApiRecord<"VM"> {
|
|||||||
VCPUs_at_startup: number;
|
VCPUs_at_startup: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XenApiConsole extends XenApiRecord<"console"> {
|
export interface XenApiConsole extends XenApiRecord<"Console"> {
|
||||||
protocol: string;
|
protocol: string;
|
||||||
location: string;
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XenApiHostMetrics extends XenApiRecord<"host_metrics"> {
|
export interface XenApiHostMetrics extends XenApiRecord<"HostMetrics"> {
|
||||||
live: boolean;
|
live: boolean;
|
||||||
memory_free: number;
|
memory_free: number;
|
||||||
memory_total: number;
|
memory_total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XenApiVmMetrics extends XenApiRecord<"VM_metrics"> {
|
export interface XenApiVmMetrics extends XenApiRecord<"VmMetrics"> {
|
||||||
VCPUs_number: number;
|
VCPUs_number: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type XenApiVmGuestMetrics = XenApiRecord<"VM_guest_metrics">;
|
export type XenApiVmGuestMetrics = XenApiRecord<"VmGuestMetrics">;
|
||||||
|
|
||||||
export interface XenApiTask extends XenApiRecord<"task"> {
|
export interface XenApiTask extends XenApiRecord<"Task"> {
|
||||||
name_label: string;
|
name_label: string;
|
||||||
resident_on: XenApiHost["$ref"];
|
resident_on: XenApiHost["$ref"];
|
||||||
created: string;
|
created: string;
|
||||||
@@ -165,22 +161,17 @@ export interface XenApiTask extends XenApiRecord<"task"> {
|
|||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XenApiMessage<T extends RawObjectType = RawObjectType>
|
export interface XenApiMessage extends XenApiRecord<"Message"> {
|
||||||
extends XenApiRecord<"message"> {
|
|
||||||
body: string;
|
|
||||||
cls: T;
|
|
||||||
name: string;
|
name: string;
|
||||||
obj_uuid: RawTypeToRecord<T>["uuid"];
|
cls: RawObjectType;
|
||||||
priority: number;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WatchCallbackResult = {
|
type WatchCallbackResult = {
|
||||||
id: string;
|
id: string;
|
||||||
class: ObjectType;
|
class: ObjectType;
|
||||||
operation: "add" | "mod" | "del";
|
operation: "add" | "mod" | "del";
|
||||||
ref: XenApiRecord<RawObjectType>["$ref"];
|
ref: XenApiRecord<string>["$ref"];
|
||||||
snapshot: RawXenApiRecord<XenApiRecord<RawObjectType>>;
|
snapshot: RawXenApiRecord<XenApiRecord<string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WatchCallback = (results: WatchCallbackResult[]) => void;
|
type WatchCallback = (results: WatchCallbackResult[]) => void;
|
||||||
@@ -293,17 +284,16 @@ export default class XenApi {
|
|||||||
return fetch(url, { signal: abortSignal });
|
return fetch(url, { signal: abortSignal });
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRecords<
|
async loadRecords<T extends XenApiRecord<string>>(
|
||||||
T extends RawObjectType,
|
type: RawObjectType
|
||||||
R extends RawTypeToRecord<T> = RawTypeToRecord<T>,
|
): Promise<T[]> {
|
||||||
>(type: T): Promise<R[]> {
|
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
|
||||||
const result = await this.#call<{ [key: string]: R }>(
|
|
||||||
`${type}.get_all_records`,
|
`${type}.get_all_records`,
|
||||||
[this.sessionId]
|
[this.sessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return Object.entries(result).map(([opaqueRef, record]) =>
|
return Object.entries(result).map(([opaqueRef, record]) =>
|
||||||
buildXoObject(record, { opaqueRef: opaqueRef as R["$ref"] })
|
buildXoObject(record, { opaqueRef: opaqueRef as T["$ref"] })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"community": "Community",
|
"community": "Community",
|
||||||
"community-name": "{name} community",
|
"community-name": "{name} community",
|
||||||
"console": "Console",
|
"console": "Console",
|
||||||
"console-unavailable": "Console unavailable",
|
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"cpu-provisioning": "CPU provisioning",
|
"cpu-provisioning": "CPU provisioning",
|
||||||
"cpu-usage": "CPU usage",
|
"cpu-usage": "CPU usage",
|
||||||
@@ -33,7 +32,7 @@
|
|||||||
"do-you-have-needs": "You have needs and/or expectations? Let us know",
|
"do-you-have-needs": "You have needs and/or expectations? Let us know",
|
||||||
"edit-config": "Edit config",
|
"edit-config": "Edit config",
|
||||||
"error-no-data": "Error, can't collect data.",
|
"error-no-data": "Error, can't collect data.",
|
||||||
"error-occurred": "An error has occurred",
|
"error-occured": "An error has occurred",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"export-table-to": "Export table to {type}",
|
"export-table-to": "Export table to {type}",
|
||||||
"export-vms": "Export VMs",
|
"export-vms": "Export VMs",
|
||||||
@@ -78,11 +77,8 @@
|
|||||||
"news": "News",
|
"news": "News",
|
||||||
"news-name": "{name} news",
|
"news-name": "{name} news",
|
||||||
"new-features-are-coming": "New features are coming soon!",
|
"new-features-are-coming": "New features are coming soon!",
|
||||||
"no-tasks": "No tasks",
|
|
||||||
"not-found": "Not found",
|
|
||||||
"object": "Object",
|
"object": "Object",
|
||||||
"object-not-found": "Object {id} can't be found…",
|
"object-not-found": "Object {id} can't be found…",
|
||||||
"open-in-new-window": "Open in new window",
|
|
||||||
"or": "Or",
|
"or": "Or",
|
||||||
"page-not-found": "This page is not to be found…",
|
"page-not-found": "This page is not to be found…",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
@@ -91,7 +87,6 @@
|
|||||||
"please-confirm": "Please confirm",
|
"please-confirm": "Please confirm",
|
||||||
"pool-cpu-usage": "Pool CPU Usage",
|
"pool-cpu-usage": "Pool CPU Usage",
|
||||||
"pool-ram-usage": "Pool RAM Usage",
|
"pool-ram-usage": "Pool RAM Usage",
|
||||||
"power-on-for-console": "Power on your VM to access its console",
|
|
||||||
"power-state": "Power state",
|
"power-state": "Power state",
|
||||||
"property": "Property",
|
"property": "Property",
|
||||||
"ram-usage": "RAM usage",
|
"ram-usage": "RAM usage",
|
||||||
@@ -115,6 +110,7 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"shutdown": "Shutdown",
|
"shutdown": "Shutdown",
|
||||||
"snapshot": "Snapshot",
|
"snapshot": "Snapshot",
|
||||||
|
"selected-vms-in-execution": "Some selected VMs are running",
|
||||||
"sort-by": "Sort by",
|
"sort-by": "Sort by",
|
||||||
"stacked-cpu-usage": "Stacked CPU usage",
|
"stacked-cpu-usage": "Stacked CPU usage",
|
||||||
"stacked-ram-usage": "Stacked RAM usage",
|
"stacked-ram-usage": "Stacked RAM usage",
|
||||||
@@ -131,6 +127,7 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"task": {
|
"task": {
|
||||||
"estimated-end": "Estimated end",
|
"estimated-end": "Estimated end",
|
||||||
|
"page-title": "Tasks | (1) Tasks | ({n}) Tasks",
|
||||||
"progress": "Progress",
|
"progress": "Progress",
|
||||||
"started": "Started"
|
"started": "Started"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"community": "Communauté",
|
"community": "Communauté",
|
||||||
"community-name": "Communauté {name}",
|
"community-name": "Communauté {name}",
|
||||||
"console": "Console",
|
"console": "Console",
|
||||||
"console-unavailable": "Console indisponible",
|
|
||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
"cpu-provisioning": "Provisionnement CPU",
|
"cpu-provisioning": "Provisionnement CPU",
|
||||||
"cpu-usage": "Utilisation CPU",
|
"cpu-usage": "Utilisation CPU",
|
||||||
@@ -33,7 +32,7 @@
|
|||||||
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
|
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
|
||||||
"edit-config": "Modifier config",
|
"edit-config": "Modifier config",
|
||||||
"error-no-data": "Erreur, impossible de collecter les données.",
|
"error-no-data": "Erreur, impossible de collecter les données.",
|
||||||
"error-occurred": "Une erreur est survenue",
|
"error-occured": "Une erreur est survenue",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
"export-table-to": "Exporter le tableau en {type}",
|
"export-table-to": "Exporter le tableau en {type}",
|
||||||
"export-vms": "Exporter les VMs",
|
"export-vms": "Exporter les VMs",
|
||||||
@@ -78,11 +77,8 @@
|
|||||||
"news": "Actualités",
|
"news": "Actualités",
|
||||||
"news-name": "Actualités {name}",
|
"news-name": "Actualités {name}",
|
||||||
"new-features-are-coming": "De nouvelles fonctionnalités arrivent bientôt !",
|
"new-features-are-coming": "De nouvelles fonctionnalités arrivent bientôt !",
|
||||||
"no-tasks": "Aucune tâche",
|
|
||||||
"not-found": "Non trouvé",
|
|
||||||
"object": "Objet",
|
"object": "Objet",
|
||||||
"object-not-found": "L'objet {id} est introuvable…",
|
"object-not-found": "L'objet {id} est introuvable…",
|
||||||
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
|
|
||||||
"or": "Ou",
|
"or": "Ou",
|
||||||
"page-not-found": "Cette page est introuvable…",
|
"page-not-found": "Cette page est introuvable…",
|
||||||
"password": "Mot de passe",
|
"password": "Mot de passe",
|
||||||
@@ -91,7 +87,6 @@
|
|||||||
"please-confirm": "Veuillez confirmer",
|
"please-confirm": "Veuillez confirmer",
|
||||||
"pool-cpu-usage": "Utilisation CPU du Pool",
|
"pool-cpu-usage": "Utilisation CPU du Pool",
|
||||||
"pool-ram-usage": "Utilisation RAM du Pool",
|
"pool-ram-usage": "Utilisation RAM du Pool",
|
||||||
"power-on-for-console": "Allumez votre VM pour accéder à sa console",
|
|
||||||
"power-state": "État d'alimentation",
|
"power-state": "État d'alimentation",
|
||||||
"property": "Propriété",
|
"property": "Propriété",
|
||||||
"ram-usage": "Utilisation de la RAM",
|
"ram-usage": "Utilisation de la RAM",
|
||||||
@@ -115,6 +110,7 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"shutdown": "Arrêter",
|
"shutdown": "Arrêter",
|
||||||
"snapshot": "Instantané",
|
"snapshot": "Instantané",
|
||||||
|
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
|
||||||
"sort-by": "Trier par",
|
"sort-by": "Trier par",
|
||||||
"stacked-cpu-usage": "Utilisation CPU empilée",
|
"stacked-cpu-usage": "Utilisation CPU empilée",
|
||||||
"stacked-ram-usage": "Utilisation RAM empilée",
|
"stacked-ram-usage": "Utilisation RAM empilée",
|
||||||
@@ -131,6 +127,7 @@
|
|||||||
"system": "Système",
|
"system": "Système",
|
||||||
"task": {
|
"task": {
|
||||||
"estimated-end": "Fin estimée",
|
"estimated-end": "Fin estimée",
|
||||||
|
"page-title": "Tâches | (1) Tâches | ({n}) Tâches",
|
||||||
"progress": "Progression",
|
"progress": "Progression",
|
||||||
"started": "Démarré"
|
"started": "Démarré"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
name: "not-found",
|
name: "notFound",
|
||||||
component: () => import("@/views/PageNotFoundView.vue"),
|
component: () => import("@/views/PageNotFoundView.vue"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import type { RouteRecordRaw } from "vue-router";
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
|
||||||
const componentLoaders = import.meta.glob("@/stories/**/*.story.vue");
|
const componentLoaders = import.meta.glob("@/stories/*.story.vue");
|
||||||
const docLoaders = import.meta.glob("@/stories/**/*.story.md", { as: "raw" });
|
const docLoaders = import.meta.glob("@/stories/*.story.md", { as: "raw" });
|
||||||
|
|
||||||
const children: RouteRecordRaw[] = Object.entries(componentLoaders).map(
|
const children: RouteRecordRaw[] = Object.entries(componentLoaders).map(
|
||||||
([path, componentLoader]) => {
|
([path, componentLoader]) => {
|
||||||
const basePath = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
|
const basename = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
|
||||||
const docPath = path.replace(/\.vue$/, ".md");
|
const docPath = path.replace(/\.vue$/, ".md");
|
||||||
const routeName = `story-${basePath}`;
|
const routeName = `story-${basename}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: routeName,
|
name: routeName,
|
||||||
path: basePath,
|
path: basename,
|
||||||
component: componentLoader,
|
component: componentLoader,
|
||||||
meta: {
|
meta: {
|
||||||
isStory: true,
|
isStory: true,
|
||||||
storyTitle: basePathToStoryTitle(basePath),
|
storyTitle: basenameToStoryTitle(basename),
|
||||||
storyMdLoader: docLoaders[docPath],
|
storyMdLoader: docLoaders[docPath],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -46,10 +46,8 @@ export default {
|
|||||||
* Basename: `my-component`
|
* Basename: `my-component`
|
||||||
* Page title: `My Component`
|
* Page title: `My Component`
|
||||||
*/
|
*/
|
||||||
function basePathToStoryTitle(basePath: string) {
|
function basenameToStoryTitle(basename: string) {
|
||||||
return basePath
|
return basename
|
||||||
.split("/")
|
|
||||||
.pop()!
|
|
||||||
.split("-")
|
.split("-")
|
||||||
.map((s) => `${s.charAt(0).toUpperCase()}${s.substring(1)}`)
|
.map((s) => `${s.charAt(0).toUpperCase()}${s.substring(1)}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|||||||
31
@xen-orchestra/lite/src/stores/alarm.store.ts
Normal file
31
@xen-orchestra/lite/src/stores/alarm.store.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { XenApiMessage } from "@/libs/xen-api";
|
||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { createSubscribe } from "@/types/xapi-collection";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
export const useAlarmStore = defineStore("alarm", () => {
|
||||||
|
const messageCollection = useXapiCollectionStore().get("message");
|
||||||
|
|
||||||
|
const subscribe = createSubscribe<XenApiMessage, []>((options) => {
|
||||||
|
const originalSubscription = messageCollection.subscribe(options);
|
||||||
|
|
||||||
|
const extendedSubscription = {
|
||||||
|
records: computed(() =>
|
||||||
|
originalSubscription.records.value.filter(
|
||||||
|
(record) => record.name === "alarm"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...originalSubscription,
|
||||||
|
...extendedSubscription,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...messageCollection,
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
});
|
||||||
6
@xen-orchestra/lite/src/stores/console.store.ts
Normal file
6
@xen-orchestra/lite/src/stores/console.store.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useConsoleStore = defineStore("console", () =>
|
||||||
|
useXapiCollectionStore().get("console")
|
||||||
|
);
|
||||||
6
@xen-orchestra/lite/src/stores/host-metrics.store.ts
Normal file
6
@xen-orchestra/lite/src/stores/host-metrics.store.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useHostMetricsStore = defineStore("host-metrics", () =>
|
||||||
|
useXapiCollectionStore().get("host_metrics")
|
||||||
|
);
|
||||||
91
@xen-orchestra/lite/src/stores/host.store.ts
Normal file
91
@xen-orchestra/lite/src/stores/host.store.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { isHostRunning, sortRecordsByNameLabel } from "@/libs/utils";
|
||||||
|
import type {
|
||||||
|
GRANULARITY,
|
||||||
|
HostStats,
|
||||||
|
XapiStatsResponse,
|
||||||
|
} from "@/libs/xapi-stats";
|
||||||
|
import type { XenApiHost, XenApiHostMetrics } from "@/libs/xen-api";
|
||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||||
|
import type { Subscription } from "@/types/xapi-collection";
|
||||||
|
import { createSubscribe } from "@/types/xapi-collection";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, type ComputedRef } from "vue";
|
||||||
|
|
||||||
|
type GetStats = (
|
||||||
|
hostUuid: XenApiHost["uuid"],
|
||||||
|
granularity: GRANULARITY,
|
||||||
|
ignoreExpired: boolean,
|
||||||
|
opts: { abortSignal?: AbortSignal }
|
||||||
|
) => Promise<XapiStatsResponse<HostStats> | undefined> | undefined;
|
||||||
|
|
||||||
|
type GetStatsExtension = {
|
||||||
|
getStats: GetStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunningHostsExtension = [
|
||||||
|
{ runningHosts: ComputedRef<XenApiHost[]> },
|
||||||
|
{ hostMetricsSubscription: Subscription<XenApiHostMetrics, any> }
|
||||||
|
];
|
||||||
|
|
||||||
|
type Extensions = [GetStatsExtension, RunningHostsExtension];
|
||||||
|
|
||||||
|
export const useHostStore = defineStore("host", () => {
|
||||||
|
const xenApiStore = useXenApiStore();
|
||||||
|
const hostCollection = useXapiCollectionStore().get("host");
|
||||||
|
|
||||||
|
hostCollection.setSort(sortRecordsByNameLabel);
|
||||||
|
|
||||||
|
const subscribe = createSubscribe<XenApiHost, Extensions>((options) => {
|
||||||
|
const originalSubscription = hostCollection.subscribe(options);
|
||||||
|
|
||||||
|
const getStats: GetStats = (
|
||||||
|
hostUuid,
|
||||||
|
granularity,
|
||||||
|
ignoreExpired = false,
|
||||||
|
{ abortSignal }
|
||||||
|
) => {
|
||||||
|
const host = originalSubscription.getByUuid(hostUuid);
|
||||||
|
|
||||||
|
if (host === undefined) {
|
||||||
|
throw new Error(`Host ${hostUuid} could not be found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xapiStats = xenApiStore.isConnected
|
||||||
|
? xenApiStore.getXapiStats()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return xapiStats?._getAndUpdateStats<HostStats>({
|
||||||
|
abortSignal,
|
||||||
|
host,
|
||||||
|
ignoreExpired,
|
||||||
|
uuid: host.uuid,
|
||||||
|
granularity,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const extendedSubscription = {
|
||||||
|
getStats,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostMetricsSubscription = options?.hostMetricsSubscription;
|
||||||
|
|
||||||
|
const runningHostsSubscription = hostMetricsSubscription !== undefined && {
|
||||||
|
runningHosts: computed(() =>
|
||||||
|
originalSubscription.records.value.filter((host) =>
|
||||||
|
isHostRunning(host, hostMetricsSubscription)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...originalSubscription,
|
||||||
|
...extendedSubscription,
|
||||||
|
...runningHostsSubscription,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...hostCollection,
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { useTitle } from "@vueuse/core";
|
|
||||||
import { defineStore } from "pinia";
|
|
||||||
import {
|
|
||||||
computed,
|
|
||||||
type MaybeRefOrGetter,
|
|
||||||
onBeforeUnmount,
|
|
||||||
reactive,
|
|
||||||
toRef,
|
|
||||||
watch,
|
|
||||||
} from "vue";
|
|
||||||
|
|
||||||
const PAGE_TITLE_SUFFIX = "XO Lite";
|
|
||||||
|
|
||||||
interface PageTitleConfig {
|
|
||||||
object: { name_label: string } | undefined;
|
|
||||||
title: string | undefined;
|
|
||||||
count: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePageTitleStore = defineStore("page-title", () => {
|
|
||||||
const pageTitleConfig = reactive<PageTitleConfig>({
|
|
||||||
count: undefined,
|
|
||||||
title: undefined,
|
|
||||||
object: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const generatedPageTitle = computed(() => {
|
|
||||||
const { object, title, count } = pageTitleConfig;
|
|
||||||
const parts = [];
|
|
||||||
|
|
||||||
if (count !== undefined && count > 0) {
|
|
||||||
parts.push(`(${count})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title !== undefined && object !== undefined) {
|
|
||||||
parts.push(`${title} - ${object.name_label}`);
|
|
||||||
} else if (title !== undefined) {
|
|
||||||
parts.push(title);
|
|
||||||
} else if (object !== undefined) {
|
|
||||||
parts.push(object.name_label);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(" ");
|
|
||||||
});
|
|
||||||
|
|
||||||
useTitle(generatedPageTitle, {
|
|
||||||
titleTemplate: computed(() =>
|
|
||||||
generatedPageTitle.value === undefined
|
|
||||||
? PAGE_TITLE_SUFFIX
|
|
||||||
: `%s - ${PAGE_TITLE_SUFFIX}`
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const setPageTitleConfig = <T extends keyof PageTitleConfig>(
|
|
||||||
configKey: T,
|
|
||||||
value: MaybeRefOrGetter<PageTitleConfig[T]>
|
|
||||||
) => {
|
|
||||||
const stop = watch(
|
|
||||||
toRef(value),
|
|
||||||
(newValue) =>
|
|
||||||
(pageTitleConfig[configKey] = newValue as PageTitleConfig[T]),
|
|
||||||
{
|
|
||||||
immediate: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stop();
|
|
||||||
pageTitleConfig[configKey] = undefined;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const setObject = (
|
|
||||||
object: MaybeRefOrGetter<{ name_label: string } | undefined>
|
|
||||||
) => setPageTitleConfig("object", object);
|
|
||||||
|
|
||||||
const setTitle = (title: MaybeRefOrGetter<string | undefined>) =>
|
|
||||||
setPageTitleConfig("title", title);
|
|
||||||
|
|
||||||
const setCount = (count: MaybeRefOrGetter<number | undefined>) =>
|
|
||||||
setPageTitleConfig("count", count);
|
|
||||||
|
|
||||||
return {
|
|
||||||
setObject,
|
|
||||||
setTitle,
|
|
||||||
setCount,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
34
@xen-orchestra/lite/src/stores/pool.store.ts
Normal file
34
@xen-orchestra/lite/src/stores/pool.store.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getFirst } from "@/libs/utils";
|
||||||
|
import type { XenApiPool } from "@/libs/xen-api";
|
||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { createSubscribe } from "@/types/xapi-collection";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, type ComputedRef } from "vue";
|
||||||
|
|
||||||
|
type PoolExtension = {
|
||||||
|
pool: ComputedRef<XenApiPool | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Extensions = [PoolExtension];
|
||||||
|
|
||||||
|
export const usePoolStore = defineStore("pool", () => {
|
||||||
|
const poolCollection = useXapiCollectionStore().get("pool");
|
||||||
|
|
||||||
|
const subscribe = createSubscribe<XenApiPool, Extensions>((options) => {
|
||||||
|
const originalSubscription = poolCollection.subscribe(options);
|
||||||
|
|
||||||
|
const extendedSubscription = {
|
||||||
|
pool: computed(() => getFirst(originalSubscription.records.value)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...originalSubscription,
|
||||||
|
...extendedSubscription,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...poolCollection,
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
});
|
||||||
6
@xen-orchestra/lite/src/stores/storage.store.ts
Normal file
6
@xen-orchestra/lite/src/stores/storage.store.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useSrStore = defineStore("SR", () =>
|
||||||
|
useXapiCollectionStore().get("SR")
|
||||||
|
);
|
||||||
6
@xen-orchestra/lite/src/stores/task.store.ts
Normal file
6
@xen-orchestra/lite/src/stores/task.store.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useTaskStore = defineStore("task", () =>
|
||||||
|
useXapiCollectionStore().get("task")
|
||||||
|
);
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useBreakpoints, useColorMode } from "@vueuse/core";
|
import { useBreakpoints, useColorMode } from "@vueuse/core";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
|
|
||||||
export const useUiStore = defineStore("ui", () => {
|
export const useUiStore = defineStore("ui", () => {
|
||||||
const currentHostOpaqueRef = ref();
|
const currentHostOpaqueRef = ref();
|
||||||
@@ -14,14 +13,10 @@ export const useUiStore = defineStore("ui", () => {
|
|||||||
|
|
||||||
const isMobile = computed(() => !isDesktop.value);
|
const isMobile = computed(() => !isDesktop.value);
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const hasUi = computed(() => route.query.ui !== "0");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colorMode,
|
colorMode,
|
||||||
currentHostOpaqueRef,
|
currentHostOpaqueRef,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
isMobile,
|
isMobile,
|
||||||
hasUi,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
6
@xen-orchestra/lite/src/stores/vm-guest-metrics.store.ts
Normal file
6
@xen-orchestra/lite/src/stores/vm-guest-metrics.store.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useVmGuestMetricsStore = defineStore("vm-guest-metrics", () =>
|
||||||
|
useXapiCollectionStore().get("VM_guest_metrics")
|
||||||
|
);
|
||||||
6
@xen-orchestra/lite/src/stores/vm-metrics.store.ts
Normal file
6
@xen-orchestra/lite/src/stores/vm-metrics.store.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useVmMetricsStore = defineStore("vm-metrics", () =>
|
||||||
|
useXapiCollectionStore().get("VM_metrics")
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user