Compare commits

..

1 Commits

Author SHA1 Message Date
Florent Beauchamp
7baa5ac804 feat(@xen-orchestra/fs): impement null writer for performance testing 2023-03-28 10:04:07 +02:00
274 changed files with 3508 additions and 12912 deletions

View File

@@ -28,7 +28,7 @@ module.exports = {
},
},
{
files: ['*.{integ,spec,test}.{,c,m}js'],
files: ['*.{spec,test}.{,c,m}js'],
rules: {
'n/no-unpublished-require': 'off',
'n/no-unpublished-import': 'off',

View File

@@ -1,32 +0,0 @@
```js
import diff from '@vates/diff'
diff('foo bar baz', 'Foo qux')
// → [ 0, 'F', 4, 'qux', 7, '' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains `F`
// - at position 4, it contains `qux`
// - at position 7, it ends
diff('Foo qux', 'foo bar baz')
// → [ 0, 'f', 4, 'bar', 7, ' baz' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains f`
// - at position 4, it contains `bar`
// - at position 7, it contains `baz`
// works with all collections that supports
// - `.length`
// - `collection[index]`
// - `.slice(start, end)`
//
// which includes:
// - arrays
// - strings
// - `Buffer`
// - `TypedArray`
diff([0, 1, 2], [3, 4])
// → [ 0, [ 3, 4 ], 2, [] ]
```

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,65 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/diff
[![Package Version](https://badgen.net/npm/v/@vates/diff)](https://npmjs.org/package/@vates/diff) ![License](https://badgen.net/npm/license/@vates/diff) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/diff)](https://bundlephobia.com/result?p=@vates/diff) [![Node compatibility](https://badgen.net/npm/node/@vates/diff)](https://npmjs.org/package/@vates/diff)
> Computes differences between two arrays, buffers or strings
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/diff):
```sh
npm install --save @vates/diff
```
## Usage
```js
import diff from '@vates/diff'
diff('foo bar baz', 'Foo qux')
// → [ 0, 'F', 4, 'qux', 7, '' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains `F`
// - at position 4, it contains `qux`
// - at position 7, it ends
diff('Foo qux', 'foo bar baz')
// → [ 0, 'f', 4, 'bar', 7, ' baz' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains f`
// - at position 4, it contains `bar`
// - at position 7, it contains `baz`
// works with all collections that supports
// - `.length`
// - `collection[index]`
// - `.slice(start, end)`
//
// which includes:
// - arrays
// - strings
// - `Buffer`
// - `TypedArray`
diff([0, 1, 2], [3, 4])
// → [ 0, [ 3, 4 ], 2, [] ]
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,37 +0,0 @@
'use strict'
/**
* Compare two data arrays, buffers or strings and invoke the provided callback function for each difference.
*
* @template {Array|Buffer|string} T
* @param {Array|Buffer|string} data1 - The first data array or buffer to compare.
* @param {T} data2 - The second data array or buffer to compare.
* @param {(index: number, diff: T) => void} [cb] - The callback function to invoke for each difference. If not provided, an array of differences will be returned.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided, otherwise undefined.
*/
module.exports = function diff(data1, data2, cb) {
let result
if (cb === undefined) {
result = []
cb = result.push.bind(result)
}
const n1 = data1.length
const n2 = data2.length
const n = Math.min(n1, n2)
for (let i = 0; i < n; ++i) {
if (data1[i] !== data2[i]) {
let j = i + 1
while (j < n && data1[j] !== data2[j]) {
++j
}
cb(i, data2.slice(i, j))
i = j
}
}
if (n1 !== n2) {
cb(n, n1 < n2 ? data2.slice(n) : data2.slice(0, 0))
}
return result
}

View File

@@ -1,51 +0,0 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('test')
const diff = require('./index.js')
test('data of equal length', function () {
const data1 = 'foo bar baz'
const data2 = 'baz bar foo'
assert.deepEqual(diff(data1, data2), [0, 'baz', 8, 'foo'])
})
test('data1 is longer', function () {
const data1 = 'foo bar'
const data2 = 'foo'
assert.deepEqual(diff(data1, data2), [3, ''])
})
test('data2 is longer', function () {
const data1 = 'foo'
const data2 = 'foo bar'
assert.deepEqual(diff(data1, data2), [3, ' bar'])
})
test('with arrays', function () {
const data1 = 'foo bar baz'.split('')
const data2 = 'baz bar foo'.split('')
assert.deepEqual(diff(data1, data2), [0, 'baz'.split(''), 8, 'foo'.split('')])
})
test('with buffers', function () {
const data1 = Buffer.from('foo bar baz')
const data2 = Buffer.from('baz bar foo')
assert.deepEqual(diff(data1, data2), [0, Buffer.from('baz'), 8, Buffer.from('foo')])
})
test('cb param', function () {
const data1 = 'foo bar baz'
const data2 = 'baz bar foo'
const calls = []
const cb = (...args) => calls.push(args)
diff(data1, data2, cb)
assert.deepEqual(calls, [
[0, 'baz'],
[8, 'foo'],
])
})

View File

@@ -1,36 +0,0 @@
{
"private": false,
"name": "@vates/diff",
"description": "Computes differences between two arrays, buffers or strings",
"keywords": [
"array",
"binary",
"buffer",
"diff",
"differences",
"string"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/diff",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/diff",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.3.0"
}
}

View File

@@ -21,7 +21,7 @@
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.4.0"
"vhd-lib": "^4.2.1"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -18,11 +18,8 @@ const {
OPTS_MAGIC,
NBD_CMD_DISC,
} = require('./constants.js')
const { fromCallback, pRetry, pDelay, pTimeout } = require('promise-toolbox')
const { fromCallback } = require('promise-toolbox')
const { readChunkStrict } = require('@vates/read-chunk')
const { createLogger } = require('@xen-orchestra/log')
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
@@ -35,34 +32,18 @@ module.exports = class NbdClient {
#exportName
#exportSize
#waitBeforeReconnect
#readAhead
#readBlockRetries
#reconnectRetry
#connectTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listenner waiting for a response
#nextCommandQueryId = BigInt(0)
#commandQueryBacklog // map of command waiting for an response queryId => { size/*in byte*/, resolve, reject}
#connected = false
#reconnectingPromise
constructor(
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
{ connectTimeout = 6e4, waitBeforeReconnect = 1e3, readAhead = 10, readBlockRetries = 5, reconnectRetry = 5 } = {}
) {
constructor({ address, port = NBD_DEFAULT_PORT, exportname, cert }) {
this.#serverAddress = address
this.#serverPort = port
this.#exportName = exportname
this.#serverCert = cert
this.#waitBeforeReconnect = waitBeforeReconnect
this.#readAhead = readAhead
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
}
get exportSize() {
@@ -97,55 +78,24 @@ module.exports = class NbdClient {
})
}
async #connect() {
// first we connect to the server without tls, and then we upgrade the connection
async connect() {
// first we connect to the serve without tls, and then we upgrade the connection
// to tls during the handshake
await this.#unsecureConnect()
await this.#handshake()
this.#connected = true
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async connect() {
return pTimeout.call(this.#connect(), this.#connectTimeout)
}
async disconnect() {
if (!this.#connected) {
return
}
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
await this.#write(buffer)
await this.#serverSocket.destroy()
this.#serverSocket = undefined
this.#connected = false
}
#clearReconnectPromise = () => {
this.#reconnectingPromise = undefined
}
async #reconnect() {
await this.disconnect().catch(() => {})
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
await this.connect()
}
async reconnect() {
// we need to ensure reconnections do not occur in parallel
if (this.#reconnectingPromise === undefined) {
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
tries: this.#reconnectRetry,
})
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
}
return this.#reconnectingPromise
}
// we can use individual read/write from the socket here since there is no concurrency
@@ -223,6 +173,7 @@ module.exports = class NbdClient {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
await this.disconnect()
}
async #readBlockResponse() {
@@ -230,6 +181,7 @@ module.exports = class NbdClient {
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const magic = await this.#readInt32()
@@ -254,8 +206,7 @@ module.exports = class NbdClient {
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
await this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
@@ -266,11 +217,6 @@ module.exports = class NbdClient {
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
@@ -285,67 +231,19 @@ module.exports = class NbdClient {
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.index = index
error.size = size
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
size,
resolve,
reject: decoratedReject,
reject,
})
// really send the command to the server
this.#write(buffer).catch(decoratedReject)
this.#write(buffer).catch(reject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
async *readBlocks(indexGenerator) {
// default : read all blocks
if (indexGenerator === undefined) {
const exportSize = this.#exportSize
const chunkSize = 2 * 1024 * 1024
indexGenerator = function* () {
const nbBlocks = Math.ceil(exportSize / chunkSize)
for (let index = 0; index < nbBlocks; index++) {
yield { index, size: chunkSize }
}
}
}
const readAhead = []
const readAheadMaxLength = this.#readAhead
const makeReadBlockPromise = (index, size) => {
const promise = pRetry(() => this.readBlock(index, size), {
tries: this.#readBlockRetries,
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
},
})
// error is handled during unshift
promise.catch(() => {})
return promise
}
// read all blocks, but try to keep readAheadMaxLength promise waiting ahead
for (const { index, size } of indexGenerator()) {
// stack readAheadMaxLength promises before starting to handle the results
if (readAhead.length === readAheadMaxLength) {
// any error will stop reading blocks
yield readAhead.shift()
}
readAhead.push(makeReadBlockPromise(index, size))
}
while (readAhead.length > 0) {
yield readAhead.shift()
}
}
}

View File

@@ -13,17 +13,16 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.2.0",
"version": "1.0.1",
"engines": {
"node": ">=14.0"
},
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.1.1",
"@vates/read-chunk": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.3.1"
"xen-api": "^1.2.7"
},
"devDependencies": {
"tap": "^16.3.0",
@@ -31,6 +30,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap --lines 70 --functions 36 --branches 54 --statements 69 *.integ.js"
"test-integration": "tap *.spec.js"
}
}

View File

@@ -24,25 +24,3 @@ import { readChunkStrict } from '@vates/read-chunk'
const chunk = await readChunkStrict(stream, 1024)
```
### `skip(stream, size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
import { skip } from '@vates/read-chunk'
const bytesSkipped = await skip(stream, 2 * 1024 * 1024 * 1024)
```
### `skipStrict(stream, size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
import { skipStrict } from '@vates/read-chunk'
await skipStrict(stream, 2 * 1024 * 1024 * 1024)
```

View File

@@ -43,28 +43,6 @@ import { readChunkStrict } from '@vates/read-chunk'
const chunk = await readChunkStrict(stream, 1024)
```
### `skip(stream, size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
import { skip } from '@vates/read-chunk'
const bytesSkipped = await skip(stream, 2 * 1024 * 1024 * 1024)
```
### `skipStrict(stream, size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
import { skipStrict } from '@vates/read-chunk'
await skipStrict(stream, 2 * 1024 * 1024 * 1024)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -1,36 +1,18 @@
'use strict'
const assert = require('assert')
/**
* Read a chunk of data from a stream.
*
* The returned promise is rejected if there is an error while reading the stream.
*
* For streams in object mode, the returned promise resolves to a single object read from the stream.
*
* For streams in binary mode, the returned promise resolves to a Buffer or a string if an encoding has been specified using the `stream.setEncoding()` method.
*
* If `size` bytes are not available to be read, `null` will be returned *unless* the stream has ended, in which case all of the data remaining will be returned.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
* @returns {Promise<Buffer|string|unknown|null>} - A Promise that resolves to the read chunk if available, or null if end of stream is reached.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer|null>} - A Promise that resolves to a Buffer of up to size bytes if available, or null if end of stream is reached. The Promise is rejected if there is an error while reading from the stream.
*/
const readChunk = (stream, size) =>
stream.errored != null
? Promise.reject(stream.errored)
: stream.closed || stream.readableEnded
stream.closed || stream.readableEnded
? Promise.resolve(null)
: size === 0
? Promise.resolve(Buffer.alloc(0))
: new Promise((resolve, reject) => {
if (size !== undefined) {
assert(size > 0)
// per Node documentation:
// > The size argument must be less than or equal to 1 GiB.
assert(size < 1073741824)
}
function onEnd() {
resolve(null)
removeListeners()
@@ -61,17 +43,9 @@ exports.readChunk = readChunk
/**
* Read a chunk of data from a stream.
*
* The returned promise is rejected if there is an error while reading the stream.
*
* For streams in object mode, the returned promise resolves to a single object read from the stream.
*
* For streams in binary mode, the returned promise resolves to a Buffer or a string if an encoding has been specified using the `stream.setEncoding()` method.
*
* If `size` bytes are not available to be read, the returned promise is rejected.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
* @returns {Promise<Buffer|string|unknown>} - A Promise that resolves to the read chunk.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer>} - A Promise that resolves to a Buffer of size bytes. The Promise is rejected if there is an error while reading from the stream.
*/
exports.readChunkStrict = async function readChunkStrict(stream, size) {
const chunk = await readChunk(stream, size)
@@ -80,7 +54,7 @@ exports.readChunkStrict = async function readChunkStrict(stream, 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')
Object.defineProperties(error, {
chunk: {
value: chunk,
@@ -91,69 +65,3 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
return chunk
}
/**
* Skips a given number of bytes from a readable stream.
*
* @param {Readable} stream - A readable stream to skip bytes from.
* @param {number} size - The number of bytes to skip.
* @returns {Promise<number>} A Promise that resolves to the number of bytes actually skipped. If the end of the stream is reached before all bytes are skipped, the Promise resolves to the number of bytes that were skipped before the end of the stream was reached. The Promise is rejected if there is an error while reading from the stream.
*/
async function skip(stream, size) {
return stream.errored != null
? Promise.reject(stream.errored)
: size === 0 || stream.closed || stream.readableEnded
? Promise.resolve(0)
: new Promise((resolve, reject) => {
let left = size
function onEnd() {
resolve(size - left)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read()
left -= data === null ? 0 : data.length
if (left > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (left < 0) {
stream.unshift(data.slice(left))
}
resolve(size)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
}
exports.skip = skip
/**
* Skips a given number of bytes from a stream.
*
* @param {Readable} stream - A readable stream to skip bytes from.
* @param {number} size - The number of bytes to skip.
* @returns {Promise<void>} - A Promise that resolves when the exact number of bytes have been skipped. The Promise is rejected if there is an error while reading from the stream or the stream ends before the exact number of bytes have been skipped.
*/
exports.skipStrict = async function skipStrict(stream, size) {
const bytesSkipped = await skip(stream, size)
if (bytesSkipped !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${bytesSkipped}, expected: ${size})`)
error.bytesSkipped = bytesSkipped
throw error
}
}

View File

@@ -5,58 +5,12 @@ const assert = require('node:assert').strict
const { Readable } = require('stream')
const { readChunk, readChunkStrict, skip, skipStrict } = require('./')
const { readChunk, readChunkStrict } = require('./')
const makeStream = it => Readable.from(it, { objectMode: false })
makeStream.obj = Readable.from
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
const makeErrorTests = fn => {
it('rejects if the stream errors', async () => {
const error = new Error()
const stream = makeStream([])
const pError = rejectionOf(fn(stream, 10))
stream.destroy(error)
assert.strict(await pError, error)
})
// only supported for Node >= 18
if (process.versions.node.split('.')[0] >= 18) {
it('rejects if the stream has already errored', async () => {
const error = new Error()
const stream = makeStream([])
await new Promise(resolve => {
stream.once('error', resolve).destroy(error)
})
assert.strict(await rejectionOf(fn(stream, 10)), error)
})
}
}
describe('readChunk', () => {
it('rejects if size is less than or equal to 0', async () => {
const error = await rejectionOf(readChunk(makeStream([]), 0))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
it('rejects if size is greater than or equal to 1 GiB', async () => {
const error = await rejectionOf(readChunk(makeStream([]), 1024 * 1024 * 1024))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
makeErrorTests(readChunk)
it('returns null if stream is empty', async () => {
assert.strictEqual(await readChunk(makeStream([])), null)
})
@@ -84,6 +38,10 @@ describe('readChunk', () => {
it('returns less data if stream ends', async () => {
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 10), Buffer.from('foobar'))
})
it('returns an empty buffer if the specified size is 0', async () => {
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 0), Buffer.alloc(0))
})
})
describe('with object stream', () => {
@@ -94,6 +52,14 @@ describe('readChunk', () => {
})
})
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
describe('readChunkStrict', function () {
it('throws if stream is empty', async () => {
const error = await rejectionOf(readChunkStrict(makeStream([])))
@@ -105,43 +71,7 @@ describe('readChunkStrict', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
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')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
})
describe('skip', function () {
makeErrorTests(skip)
it('returns 0 if size is 0', async () => {
assert.strictEqual(await skip(makeStream(['foo']), 0), 0)
})
it('returns 0 if the stream is already ended', async () => {
const stream = await makeStream([])
await readChunk(stream)
assert.strictEqual(await skip(stream, 10), 0)
})
it('skips a number of bytes', async () => {
const stream = makeStream('foo bar')
assert.strictEqual(await skip(stream, 4), 4)
assert.deepEqual(await readChunk(stream, 4), Buffer.from('bar'))
})
it('returns less size if stream ends', async () => {
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
})
})
describe('skipStrict', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(skipStrict(makeStream('foo bar'), 10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
})

View File

@@ -19,7 +19,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "1.1.1",
"version": "1.0.1",
"engines": {
"node": ">=8.10"
},

View File

@@ -1,42 +0,0 @@
```js
import StreamReader from '@vates/stream-reader'
const reader = new StreamReader(stream)
```
### `.read([size])`
- returns the next available chunk of data
- like `stream.read()`, a number of bytes can be specified
- returns with less data than expected if stream has ended
- returns `null` if the stream has ended and no data has been read
```js
const chunk = await reader.read(512)
```
### `.readStrict([size])`
Similar behavior to `readChunk` but throws if the stream ended before the requested data could be read.
```js
const chunk = await reader.readStrict(512)
```
### `.skip(size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
const bytesSkipped = await reader.skip(2 * 1024 * 1024 * 1024)
```
### `.skipStrict(size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
await reader.skipStrict(2 * 1024 * 1024 * 1024)
```

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,75 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/stream-reader
[![Package Version](https://badgen.net/npm/v/@vates/stream-reader)](https://npmjs.org/package/@vates/stream-reader) ![License](https://badgen.net/npm/license/@vates/stream-reader) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/stream-reader)](https://bundlephobia.com/result?p=@vates/stream-reader) [![Node compatibility](https://badgen.net/npm/node/@vates/stream-reader)](https://npmjs.org/package/@vates/stream-reader)
> Efficiently reads and skips chunks of a given size in a stream
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/stream-reader):
```sh
npm install --save @vates/stream-reader
```
## Usage
```js
import StreamReader from '@vates/stream-reader'
const reader = new StreamReader(stream)
```
### `.read([size])`
- returns the next available chunk of data
- like `stream.read()`, a number of bytes can be specified
- returns with less data than expected if stream has ended
- returns `null` if the stream has ended and no data has been read
```js
const chunk = await reader.read(512)
```
### `.readStrict([size])`
Similar behavior to `readChunk` but throws if the stream ended before the requested data could be read.
```js
const chunk = await reader.readStrict(512)
```
### `.skip(size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
const bytesSkipped = await reader.skip(2 * 1024 * 1024 * 1024)
```
### `.skipStrict(size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
await reader.skipStrict(2 * 1024 * 1024 * 1024)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,123 +0,0 @@
'use strict'
const assert = require('node:assert')
const { finished, Readable } = require('node:stream')
const noop = Function.prototype
// Inspired by https://github.com/nodejs/node/blob/85705a47958c9ae5dbaa1f57456db19bdefdc494/lib/internal/streams/readable.js#L1107
class StreamReader {
#ended = false
#error
#executor = resolve => {
this.#resolve = resolve
}
#stream
#resolve = noop
constructor(stream) {
stream = typeof stream.pipe === 'function' ? stream : Readable.from(stream)
this.#stream = stream
stream.on('readable', () => this.#resolve())
finished(stream, { writable: false }, error => {
this.#error = error
this.#ended = true
this.#resolve()
})
}
async read(size) {
if (size !== undefined) {
assert(size > 0)
}
do {
if (this.#ended) {
if (this.#error) {
throw this.#error
}
return null
}
const value = this.#stream.read(size)
if (value !== null) {
return value
}
await new Promise(this.#executor)
} while (true)
}
async readStrict(size) {
const chunk = await this.read(size)
if (chunk === null) {
throw new Error('stream has ended without data')
}
if (size !== undefined && chunk.length !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
Object.defineProperties(error, {
chunk: {
value: chunk,
},
})
throw error
}
return chunk
}
async skip(size) {
if (size === 0) {
return size
}
let toSkip = size
do {
if (this.#ended) {
if (this.#error) {
throw this.#error
}
return size - toSkip
}
const data = this.#stream.read()
if (data !== null) {
toSkip -= data === null ? 0 : data.length
if (toSkip > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (toSkip < 0) {
this.#stream.unshift(data.slice(toSkip))
}
return size
}
}
await new Promise(this.#executor)
} while (true)
}
async skipStrict(size) {
const bytesSkipped = await this.skip(size)
if (bytesSkipped !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${bytesSkipped}, expected: ${size})`)
error.bytesSkipped = bytesSkipped
throw error
}
}
}
StreamReader.prototype[Symbol.asyncIterator] = async function* asyncIterator() {
let chunk
while ((chunk = await this.read()) !== null) {
yield chunk
}
}
module.exports = StreamReader

View File

@@ -1,141 +0,0 @@
'use strict'
const { describe, it } = require('test')
const assert = require('node:assert').strict
const { Readable } = require('stream')
const StreamReader = require('./index.js')
const makeStream = it => Readable.from(it, { objectMode: false })
makeStream.obj = Readable.from
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
const makeErrorTests = method => {
it('rejects if the stream errors', async () => {
const error = new Error()
const stream = makeStream([])
const pError = rejectionOf(new StreamReader(stream)[method](10))
stream.destroy(error)
assert.strict(await pError, error)
})
it('rejects if the stream has already errored', async () => {
const error = new Error()
const stream = makeStream([])
await new Promise(resolve => {
stream.once('error', resolve).destroy(error)
})
assert.strict(await rejectionOf(new StreamReader(stream)[method](10)), error)
})
}
describe('read()', () => {
it('rejects if size is less than or equal to 0', async () => {
const error = await rejectionOf(new StreamReader(makeStream([])).read(0))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
it('returns null if stream is empty', async () => {
assert.strictEqual(await new StreamReader(makeStream([])).read(), null)
})
makeErrorTests('read')
it('returns null if the stream is already ended', async () => {
const reader = new StreamReader(makeStream([]))
await reader.read()
assert.strictEqual(await reader.read(), null)
})
describe('with binary stream', () => {
it('returns the first chunk of data', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(), Buffer.from('foo'))
})
it('returns a chunk of the specified size (smaller than first)', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(2), Buffer.from('fo'))
})
it('returns a chunk of the specified size (larger than first)', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(4), Buffer.from('foob'))
})
it('returns less data if stream ends', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(10), Buffer.from('foobar'))
})
})
describe('with object stream', () => {
it('returns the first chunk of data verbatim', async () => {
const chunks = [{}, {}]
assert.strictEqual(await new StreamReader(makeStream.obj(chunks)).read(), chunks[0])
})
})
})
describe('readStrict()', function () {
it('throws if stream is empty', async () => {
const error = await rejectionOf(new StreamReader(makeStream([])).readStrict())
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended without data')
assert.strictEqual(error.chunk, undefined)
})
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(new StreamReader(makeStream(['foo', 'bar'])).readStrict(10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
})
describe('skip()', function () {
makeErrorTests('skip')
it('returns 0 if size is 0', async () => {
assert.strictEqual(await new StreamReader(makeStream(['foo'])).skip(0), 0)
})
it('returns 0 if the stream is already ended', async () => {
const reader = new StreamReader(makeStream([]))
await reader.read()
assert.strictEqual(await reader.skip(10), 0)
})
it('skips a number of bytes', async () => {
const reader = new StreamReader(makeStream('foo bar'))
assert.strictEqual(await reader.skip(4), 4)
assert.deepEqual(await reader.read(4), Buffer.from('bar'))
})
it('returns less size if stream ends', async () => {
assert.deepEqual(await new StreamReader(makeStream('foo bar')).skip(10), 7)
})
})
describe('skipStrict()', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(new StreamReader(makeStream('foo bar')).skipStrict(10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
})

View File

@@ -1,39 +0,0 @@
{
"private": false,
"name": "@vates/stream-reader",
"description": "Efficiently reads and skips chunks of a given size in a stream",
"keywords": [
"async",
"chunk",
"data",
"node",
"promise",
"read",
"reader",
"skip",
"stream"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/stream-reader",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/stream-reader",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.3.0"
}
}

View File

@@ -2,12 +2,7 @@
import { Task } from '@vates/task'
const task = new Task({
// data in this object will be sent along the *start* event
//
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task',
},
name: 'my task',
// if defined, a new detached task is created
//
@@ -30,19 +25,8 @@ const task = new Task({
// this field is settable once before being observed
task.id
// contains the current status of the task
//
// possible statuses are:
// - pending
// - success
// - failure
// - aborted
task.status
// Triggers the abort signal associated to the task.
//
// This simply requests the task to abort, it will be up to the task to handle or not this signal.
task.abort(reason)
await task.abort()
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
@@ -50,11 +34,7 @@ const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
```
Inside a task:
```js
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
@@ -72,43 +52,3 @@ Task.warning(message, data)
// - progress
Task.set(property, value)
```
### `combineEvents`
Create a consolidated log from individual events.
It can be used directly as an `onProgress` callback:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({
// This function is called each time a root task starts.
//
// It will be called for as many times as there are tasks created with this `onProgress` function.
onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks,
// and will be mutated in real-time to reflect the changes of the task.
},
// This function is called each time a root task ends.
onRootTaskEnd(taskLog) {},
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})
Task.run({ data: { name: 'my task' }, onProgress }, asyncFn)
```
It can also be fed event logs directly:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({ onRootTaskStart, onRootTaskEnd, onTaskUpdate })
eventLogs.forEach(onProgress)
```

View File

@@ -18,12 +18,7 @@ npm install --save @vates/task
import { Task } from '@vates/task'
const task = new Task({
// data in this object will be sent along the *start* event
//
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task',
},
name: 'my task',
// if defined, a new detached task is created
//
@@ -46,19 +41,8 @@ const task = new Task({
// this field is settable once before being observed
task.id
// contains the current status of the task
//
// possible statuses are:
// - pending
// - success
// - failure
// - aborted
task.status
// Triggers the abort signal associated to the task.
//
// This simply requests the task to abort, it will be up to the task to handle or not this signal.
task.abort(reason)
await task.abort()
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
@@ -66,11 +50,7 @@ const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
```
Inside a task:
```js
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
@@ -89,46 +69,6 @@ Task.warning(message, data)
Task.set(property, value)
```
### `combineEvents`
Create a consolidated log from individual events.
It can be used directly as an `onProgress` callback:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({
// This function is called each time a root task starts.
//
// It will be called for as many times as there are tasks created with this `onProgress` function.
onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks,
// and will be mutated in real-time to reflect the changes of the task.
},
// This function is called each time a root task ends.
onRootTaskEnd(taskLog) {},
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})
Task.run({ data: { name: 'my task' }, onProgress }, asyncFn)
```
It can also be fed event logs directly:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({ onRootTaskStart, onRootTaskEnd, onTaskUpdate })
eventLogs.forEach(onProgress)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -1,77 +0,0 @@
'use strict'
const assert = require('node:assert').strict
const noop = Function.prototype
function omit(source, keys, target = { __proto__: null }) {
for (const key of Object.keys(source)) {
if (!keys.has(key)) {
target[key] = source[key]
}
}
return target
}
const IGNORED_START_PROPS = new Set([
'end',
'infos',
'properties',
'result',
'status',
'tasks',
'timestamp',
'type',
'warnings',
])
exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noop, onTaskUpdate = noop }) {
const taskLogs = new Map()
return function onProgress(event) {
const { id, type } = event
let taskLog
if (type === 'start') {
taskLog = omit(event, IGNORED_START_PROPS)
taskLog.start = event.timestamp
taskLog.status = 'pending'
taskLogs.set(id, taskLog)
const { parentId } = event
if (parentId === undefined) {
Object.defineProperty(taskLog, '$root', { value: taskLog })
// start of a root task
onRootTaskStart(taskLog)
} else {
// start of a subtask
const parent = taskLogs.get(parentId)
assert.notEqual(parent, undefined)
// inject a (non-enumerable) reference to the parent and the root task
Object.defineProperties(taskLog, { $parent: { value: parent }, $root: { value: parent.$root } })
;(parent.tasks ?? (parent.tasks = [])).push(taskLog)
}
} else {
taskLog = taskLogs.get(id)
assert.notEqual(taskLog, undefined)
if (type === 'info' || type === 'warning') {
const key = type + 's'
const { data, message } = event
;(taskLog[key] ?? (taskLog[key] = [])).push({ data, message })
} else if (type === 'property') {
;(taskLog.properties ?? (taskLog.properties = { __proto__: null }))[event.name] = event.value
} else if (type === 'end') {
taskLog.end = event.timestamp
taskLog.result = event.result
taskLog.status = event.status
}
if (type === 'end' && taskLog.$root === taskLog) {
onRootTaskEnd(taskLog)
}
}
onTaskUpdate(taskLog)
}
}

View File

@@ -1,67 +0,0 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { makeOnProgress } = require('./combineEvents.js')
const { Task } = require('./index.js')
describe('makeOnProgress()', function () {
it('works', async function () {
const events = []
let log
const task = new Task({
data: { name: 'task' },
onProgress: makeOnProgress({
onRootTaskStart(log_) {
assert.equal(log, undefined)
log = log_
events.push('onRootTaskStart')
},
onRootTaskEnd(log_) {
assert.equal(log_, log)
events.push('onRootTaskEnd')
},
onTaskUpdate(log_) {
assert.equal(log_.$root, log)
events.push('onTaskUpdate')
},
}),
})
assert.equal(events.length, 0)
await task.run(async () => {
assert.equal(events[0], 'onRootTaskStart')
assert.equal(events[1], 'onTaskUpdate')
assert.equal(log.name, 'task')
Task.set('progress', 0)
assert.equal(events[2], 'onTaskUpdate')
assert.equal(log.properties.progress, 0)
Task.info('foo', {})
assert.equal(events[3], 'onTaskUpdate')
assert.deepEqual(log.infos, [{ data: {}, message: 'foo' }])
await Task.run({ data: { name: 'subtask' } }, () => {
assert.equal(events[4], 'onTaskUpdate')
assert.equal(log.tasks[0].name, 'subtask')
Task.warning('bar', {})
assert.equal(events[5], 'onTaskUpdate')
assert.deepEqual(log.tasks[0].warnings, [{ data: {}, message: 'bar' }])
})
assert.equal(events[6], 'onTaskUpdate')
assert.equal(log.tasks[0].status, 'success')
Task.set('progress', 100)
assert.equal(events[7], 'onTaskUpdate')
assert.equal(log.properties.progress, 100)
})
assert.equal(events[8], 'onRootTaskEnd')
assert.equal(events[9], 'onTaskUpdate')
assert.equal(log.status, 'success')
})
})

View File

@@ -11,15 +11,13 @@ function define(object, property, value) {
const noop = Function.prototype
const ABORTED = 'aborted'
const ABORTING = 'aborting'
const FAILURE = 'failure'
const PENDING = 'pending'
const SUCCESS = 'success'
exports.STATUS = { ABORTED, FAILURE, PENDING, SUCCESS }
// stored in the global context so that various versions of the library can interact.
const asyncStorageKey = '@vates/task@0'
const asyncStorage = global[asyncStorageKey] ?? (global[asyncStorageKey] = new AsyncLocalStorage())
exports.STATUS = { ABORTED, ABORTING, FAILURE, PENDING, SUCCESS }
const asyncStorage = new AsyncLocalStorage()
const getTask = () => asyncStorage.getStore()
exports.Task = class Task {
@@ -68,6 +66,7 @@ exports.Task = class Task {
#abortController = new AbortController()
#onProgress
#parent
get id() {
return (this.id = Math.random().toString(36).slice(2))
@@ -83,14 +82,16 @@ exports.Task = class Task {
return this.#status
}
constructor({ data = {}, onProgress } = {}) {
this.#startData = data
constructor({ name, onProgress }) {
this.#startData = { name }
if (onProgress !== undefined) {
this.#onProgress = onProgress
} else {
const parent = getTask()
if (parent !== undefined) {
this.#parent = parent
const { signal } = parent.#abortController
signal.addEventListener('abort', () => {
this.#abortController.abort(signal.reason)
@@ -105,12 +106,8 @@ exports.Task = class Task {
const { signal } = this.#abortController
signal.addEventListener('abort', () => {
if (this.status === PENDING && !this.#running) {
this.#maybeStart()
const status = ABORTED
this.#status = status
this.#emit('end', { result: signal.reason, status })
if (this.status === PENDING) {
this.#status = this.#running ? ABORTING : ABORTED
}
})
}
@@ -126,12 +123,14 @@ exports.Task = class Task {
this.#onProgress(data)
}
#maybeStart() {
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
#handleMaybeAbortion(result) {
if (this.status === ABORTING) {
this.#status = ABORTED
this.#emit('end', { status: ABORTED, result })
return true
}
return false;
}
async run(fn) {
@@ -149,19 +148,22 @@ exports.Task = class Task {
assert.equal(this.#running, false)
this.#running = true
this.#maybeStart()
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
}
try {
const result = await asyncStorage.run(this, fn)
this.#handleMaybeAbortion(result)
this.#running = false
return result
} catch (result) {
const { signal } = this.#abortController
const aborted = signal.aborted && result === signal.reason
const status = aborted ? ABORTED : FAILURE
this.#status = status
this.#emit('end', { status, result })
if (!this.#handleMaybeAbortion(result)) {
this.#status = FAILURE
this.#emit('end', { status: FAILURE, result })
}
throw result
}
}

View File

@@ -1,341 +0,0 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { Task } = require('./index.js')
const noop = Function.prototype
function assertEvent(task, expected, eventIndex = -1) {
const logs = task.$events
const actual = logs[eventIndex < 0 ? logs.length + eventIndex : eventIndex]
assert.equal(typeof actual, 'object')
assert.equal(typeof actual.id, 'string')
assert.equal(typeof actual.timestamp, 'number')
for (const keys of Object.keys(expected)) {
assert.equal(actual[keys], expected[keys])
}
}
// like new Task() but with a custom onProgress which adds event to task.$events
function createTask(opts) {
const events = []
const task = new Task({ ...opts, onProgress: events.push.bind(events) })
task.$events = events
return task
}
describe('Task', function () {
describe('contructor', function () {
it('data properties are passed to the start event', async function () {
const data = { foo: 0, bar: 1 }
const task = createTask({ data })
await task.run(noop)
assertEvent(task, { ...data, type: 'start' }, 0)
})
})
it('subtasks events are passed to root task', async function () {
const task = createTask()
const result = {}
await task.run(async () => {
await new Task().run(() => result)
})
assert.equal(task.$events.length, 4)
assertEvent(task, { type: 'start', parentId: task.id }, 1)
assertEvent(task, { type: 'end', status: 'success', result }, 2)
})
describe('.abortSignal', function () {
it('is undefined when run outside a task', function () {
assert.equal(Task.abortSignal, undefined)
})
it('is the current abort signal when run inside a task', async function () {
const task = createTask()
await task.run(() => {
const { abortSignal } = Task
assert.equal(abortSignal.aborted, false)
task.abort()
assert.equal(abortSignal.aborted, true)
})
})
})
describe('.abort()', function () {
it('aborts if the task throws fails with the abort reason', async function () {
const task = createTask()
const reason = {}
await task
.run(() => {
task.abort(reason)
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
it('does not abort if the task fails without the abort reason', async function () {
const task = createTask()
const result = new Error()
await task
.run(() => {
task.abort({})
throw result
})
.catch(noop)
assert.equal(task.status, 'failure')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'failure', result }, 1)
})
it('does not abort if the task succeed', async function () {
const task = createTask()
const result = {}
await task
.run(() => {
task.abort({})
return result
})
.catch(noop)
assert.equal(task.status, 'success')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'success', result }, 1)
})
it('aborts before task is running', function () {
const task = createTask()
const reason = {}
task.abort(reason)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
})
describe('.info()', function () {
it('does nothing when run outside a task', function () {
Task.info('foo')
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.info('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'info',
})
})
})
})
describe('.set()', function () {
it('does nothing when run outside a task', function () {
Task.set('progress', 10)
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.set('progress', 10)
assertEvent(task, {
name: 'progress',
type: 'property',
value: 10,
})
})
})
})
describe('.warning()', function () {
it('does nothing when run outside a task', function () {
Task.warning('foo')
})
it('emits an warning message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.warning('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'warning',
})
})
})
})
describe('#id', function () {
it('can be set', function () {
const task = createTask()
task.id = 'foo'
assert.equal(task.id, 'foo')
})
it('cannot be set more than once', function () {
const task = createTask()
task.id = 'foo'
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
it('is randomly generated if not set', function () {
assert.notEqual(createTask().id, createTask().id)
})
it('cannot be set after being observed', function () {
const task = createTask()
noop(task.id)
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
})
describe('#status', function () {
it('starts as pending', function () {
assert.equal(createTask().status, 'pending')
})
it('changes to success when finish without error', async function () {
const task = createTask()
await task.run(noop)
assert.equal(task.status, 'success')
})
it('changes to failure when finish with error', async function () {
const task = createTask()
await task
.run(() => {
throw Error()
})
.catch(noop)
assert.equal(task.status, 'failure')
})
it('changes to aborted after run is complete', async function () {
const task = createTask()
await task
.run(() => {
task.abort()
assert.equal(task.status, 'pending')
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
})
it('changes to aborted if aborted when not running', async function () {
const task = createTask()
task.abort()
assert.equal(task.status, 'aborted')
})
})
function makeRunTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('finishes the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'success')
assertEvent(task, {
status: 'success',
result: 'foo',
type: 'end',
})
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.run', function () {
makeRunTests((task, fn) => task.run(fn))
})
describe('.wrap', function () {
makeRunTests((task, fn) => task.wrap(fn)())
})
function makeRunInsideTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('does not finish the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'pending')
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.runInside', function () {
makeRunInsideTests((task, fn) => task.runInside(fn))
})
describe('.wrapInside', function () {
makeRunInsideTests((task, fn) => task.wrapInside(fn)())
})
})

View File

@@ -13,19 +13,11 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.2",
"version": "0.0.1",
"engines": {
"node": ">=14"
},
"devDependencies": {
"test": "^3.3.0"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
},
"exports": {
".": "./index.js",
"./combineEvents": "./combineEvents.js"
"postversion": "npm publish --access public"
}
}

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.36.1",
"@xen-orchestra/fs": "^3.3.4",
"@xen-orchestra/backups": "^0.32.0",
"@xen-orchestra/fs": "^3.3.2",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.6",
"version": "1.0.2",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -49,7 +49,6 @@ const DEFAULT_VM_SETTINGS = {
timeout: 0,
useNbd: false,
unconditionalSnapshot: false,
validateVhdStreams: false,
vmTimeout: 0,
}
@@ -270,35 +269,24 @@ exports.Backup = class Backup {
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const handleVm = vmUuid => {
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, vm => {
taskStart.data.name_label = vm.name_label
return runTask(taskStart, () =>
new VmBackup({
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}).run()
)
}),
error =>
runTask(taskStart, () => {
throw error
})
const handleVm = vmUuid =>
runTask({ name: 'backup VM', data: { type: 'VM', id: vmUuid } }, () =>
Disposable.use(this._getRecord('VM', vmUuid), vm =>
new VmBackup({
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}).run()
)
)
}
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}

View File

@@ -3,14 +3,12 @@
const { Task } = require('./Task')
exports.HealthCheckVmBackup = class HealthCheckVmBackup {
#restoredVm
#timeout
#xapi
#restoredVm
constructor({ restoredVm, timeout = 10 * 60 * 1000, xapi }) {
constructor({ restoredVm, xapi }) {
this.#restoredVm = restoredVm
this.#xapi = xapi
this.#timeout = timeout
}
async run() {
@@ -25,12 +23,7 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
// remove vifs
await Promise.all(restoredVm.$VIFs.map(vif => xapi.callAsync('VIF.destroy', vif.$ref)))
const waitForScript = restoredVm.tags.includes('xo-backup-health-check-xenstore')
if (waitForScript) {
await restoredVm.set_xenstore_data({
'vm-data/xo-backup-health-check': 'planned',
})
}
const start = new Date()
// start Vm
@@ -41,7 +34,7 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
false // Skip pre-boot checks?
)
const started = new Date()
const timeout = this.#timeout
const timeout = 10 * 60 * 1000
const startDuration = started - start
let remainingTimeout = timeout - startDuration
@@ -59,52 +52,12 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
remainingTimeout -= running - started
if (remainingTimeout < 0) {
throw new Error(`local xapi did not get Running state for VM ${restoredId} after ${timeout / 1000} second`)
throw new Error(`local xapi did not get Runnig state for VM ${restoredId} after ${timeout / 1000} second`)
}
// wait for the guest tool version to be defined
await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, {
timeout: remainingTimeout,
})
const guestToolsReady = new Date()
remainingTimeout -= guestToolsReady - running
if (remainingTimeout < 0) {
throw new Error(`local xapi did not get he guest tools check ${restoredId} after ${timeout / 1000} second`)
}
if (waitForScript) {
const startedRestoredVm = await xapi.waitObjectState(
restoredVm.$ref,
vm =>
vm?.xenstore_data !== undefined &&
(vm.xenstore_data['vm-data/xo-backup-health-check'] === 'success' ||
vm.xenstore_data['vm-data/xo-backup-health-check'] === 'failure'),
{
timeout: remainingTimeout,
}
)
const scriptOk = new Date()
remainingTimeout -= scriptOk - guestToolsReady
if (remainingTimeout < 0) {
throw new Error(
`Backup health check script did not update vm-data/xo-backup-health-check of ${restoredId} after ${
timeout / 1000
} second, got ${
startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check']
} instead of 'success' or 'failure'`
)
}
if (startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check'] !== 'success') {
const message = startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check-error']
if (message) {
throw new Error(`Backup health check script failed with message ${message} for VM ${restoredId} `)
} else {
throw new Error(`Backup health check script failed for VM ${restoredId} `)
}
}
Task.info('Backup health check script successfully executed')
}
}
)
}

View File

@@ -10,7 +10,14 @@ const groupBy = require('lodash/groupBy.js')
const pickBy = require('lodash/pickBy.js')
const { dirname, join, normalize, resolve } = require('path')
const { createLogger } = require('@xen-orchestra/log')
const { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = require('vhd-lib')
const {
createVhdDirectoryFromStream,
createVhdStreamWithLength,
openVhd,
VhdAbstract,
VhdDirectory,
VhdSynthetic,
} = require('vhd-lib')
const { deduped } = require('@vates/disposable/deduped.js')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { compose } = require('@vates/compose')
@@ -32,6 +39,7 @@ const { watchStreamSize } = require('./_watchStreamSize')
// @todo : this import is marked extraneous , sould be fixed when lib is published
const { mount } = require('@vates/fuse-vhd')
const { asyncEach } = require('@vates/async-each')
const { strictEqual } = require('assert')
const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
exports.DIR_XO_CONFIG_BACKUPS = DIR_XO_CONFIG_BACKUPS
@@ -658,7 +666,7 @@ class RemoteAdapter {
return path
}
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency, nbdClient } = {}) {
const handler = this._handler
if (this.useVhdDirectory()) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
@@ -669,21 +677,42 @@ class RemoteAdapter {
await input.task
return validator.apply(this, arguments)
},
nbdClient,
})
await VhdAbstract.createAlias(handler, path, dataPath)
return size
} else {
return this.outputStream(path, input, { checksum, validator })
const inputWithSize = await createVhdStreamWithLength(input)
return this.outputStream(path, inputWithSize, { checksum, validator, expectedSize: inputWithSize.length })
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
async outputStream(path, input, { checksum = true, validator = noop, expectedSize } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
async validator() {
await input.task
if (expectedSize !== undefined) {
// check that we read all the stream
strictEqual(
container.size,
expectedSize,
`transferred size ${container.size}, expected file size : ${expectedSize}`
)
}
let size
try {
size = await this._handler.getSize(path)
} catch (err) {
// can fail is the remote is encrypted
}
if (size !== undefined) {
// check that everything is written to disk
strictEqual(size, container.size, `written size ${size}, transfered size : ${container.size}`)
}
return validator.apply(this, arguments)
},
})
@@ -719,7 +748,7 @@ class RemoteAdapter {
async readDeltaVmBackup(metadata, ignoredVdis) {
const handler = this._handler
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
const { vbds, vhds, vifs, vm } = metadata
const dir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
@@ -734,7 +763,7 @@ class RemoteAdapter {
vdis,
version: '1.0.0',
vifs,
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
vm,
}
}
@@ -746,49 +775,7 @@ class RemoteAdapter {
// _filename is a private field used to compute the backup id
//
// it's enumerable to make it cacheable
const metadata = { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
// backups created on XenServer < 7.1 via JSON in XML-RPC transports have boolean values encoded as integers, which make them unusable with more recent XAPIs
if (typeof metadata.vm.is_a_template === 'number') {
const properties = {
vbds: ['bootable', 'unpluggable', 'storage_lock', 'empty', 'currently_attached'],
vdis: [
'sharable',
'read_only',
'storage_lock',
'managed',
'missing',
'is_a_snapshot',
'allow_caching',
'metadata_latest',
],
vifs: ['currently_attached', 'MAC_autogenerated'],
vm: ['is_a_template', 'is_control_domain', 'ha_always_run', 'is_a_snapshot', 'is_snapshot_from_vmpp'],
vmSnapshot: ['is_a_template', 'is_control_domain', 'ha_always_run', 'is_snapshot_from_vmpp'],
}
function fixBooleans(obj, properties) {
properties.forEach(property => {
if (typeof obj[property] === 'number') {
obj[property] = obj[property] === 1
}
})
}
for (const [key, propertiesInKey] of Object.entries(properties)) {
const value = metadata[key]
if (value !== undefined) {
// some properties of the metadata are collections indexed by the opaqueRef
const isCollection = Object.keys(value).some(subKey => subKey.startsWith('OpaqueRef:'))
if (isCollection) {
Object.values(value).forEach(subValue => fixBooleans(subValue, propertiesInKey))
} else {
fixBooleans(value, propertiesInKey)
}
}
}
}
return metadata
return { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
}
}

View File

@@ -6,13 +6,11 @@ const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const vhdStreamValidator = require('vhd-lib/vhdStreamValidator.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { pipeline } = require('node:stream')
const { DeltaBackupWriter } = require('./writers/DeltaBackupWriter.js')
const { DeltaReplicationWriter } = require('./writers/DeltaReplicationWriter.js')
@@ -46,8 +44,6 @@ const forkDeltaExport = deltaExport =>
},
})
const noop = Function.prototype
class VmBackup {
constructor({
config,
@@ -249,17 +245,7 @@ class VmBackup {
const deltaExport = await exportDeltaVm(exportedVm, baseVm, {
fullVdisRequired,
})
// since NBD is network based, if one disk use nbd , all the disk use them
// except the suspended VDI
if (Object.values(deltaExport.streams).some(({ _nbd }) => _nbd)) {
Task.info('Transfer data using NBD')
}
const sizeContainers = mapValues(deltaExport.streams, stream => watchStreamSize(stream))
if (this._settings.validateVhdStreams) {
deltaExport.streams = mapValues(deltaExport.streams, stream => pipeline(stream, vhdStreamValidator, noop))
}
deltaExport.streams = mapValues(deltaExport.streams, this._throttleStream)
const timestamp = Date.now()

View File

@@ -187,11 +187,11 @@ exports.importDeltaVm = defer(async function importDeltaVm(
// 0. Create suspend_VDI
let suspendVdi
if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
if (vmRecord.power_state === 'Suspended') {
const vdi = vdiRecords[vmRecord.suspend_VDI]
if (vdi === undefined) {
Task.warning('Suspend VDI not available for this suspended VM', {
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
vm: pick(vmRecord, 'uuid', 'name_label'),
})
} else {
suspendVdi = await xapi.getRecord(

View File

@@ -94,13 +94,13 @@ In case any incoherence is detected, the file is deleted so it will be fully gen
job.start(data: { mode: Mode, reportWhen: ReportWhen })
├─ task.info(message: 'vms', data: { vms: string[] })
├─ task.warning(message: string)
├─ task.start(data: { type: 'VM', id: string, name_label?: string })
├─ task.start(data: { type: 'VM', id: string })
│ ├─ task.warning(message: string)
| ├─ task.start(message: 'clean-vm')
│ │ └─ task.end
│ ├─ task.start(message: 'snapshot')
│ │ └─ task.end
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string, name_label?: string, isFull: boolean })
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string, isFull: boolean })
│ │ ├─ task.warning(message: string)
│ │ ├─ task.start(message: 'transfer')
│ │ │ ├─ task.warning(message: string)

View File

@@ -1,35 +0,0 @@
#!/bin/sh
# This script must be executed at the start of the machine.
#
# It must run as root to be able to use xenstore-read and xenstore-write
# fail in case of error or undefined variable
set -eu
# stop there if a health check is not in progress
if [ "$(xenstore-read vm-data/xo-backup-health-check 2>&1)" != planned ]
then
exit
fi
# not necessary, but informs XO that this script has started which helps diagnose issues
xenstore-write vm-data/xo-backup-health-check running
# put your test here
#
# in this example, the command `sqlite3` is used to validate the health of a database
# and its output is captured and passed to XO via the XenStore in case of error
if output=$(sqlite3 ~/my-database.sqlite3 .table 2>&1)
then
# inform XO everything is ok
xenstore-write vm-data/xo-backup-health-check success
else
# inform XO there is an issue
xenstore-write vm-data/xo-backup-health-check failure
# more info about the issue can be written to `vm-data/health-check-error`
#
# it will be shown in XO
xenstore-write vm-data/xo-backup-health-check-error "$output"
fi

View File

@@ -8,13 +8,13 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.36.1",
"version": "0.32.0",
"engines": {
"node": ">=14.6"
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "node--test *.integ.js"
"test": "node--test"
},
"dependencies": {
"@kldzj/stream-throttle": "^1.1.1",
@@ -24,10 +24,10 @@
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "^1.2.0",
"@vates/nbd-client": "^1.0.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.3.4",
"@xen-orchestra/fs": "^3.3.2",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1",
@@ -42,7 +42,7 @@
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"uuid": "^9.0.0",
"vhd-lib": "^4.4.0",
"vhd-lib": "^4.2.1",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -52,7 +52,7 @@
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^2.2.0"
"@xen-orchestra/xapi": "^2.0.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -20,8 +20,9 @@ const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { checkVhd } = require('./_checkVhd.js')
const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const NbdClient = require('@vates/nbd-client')
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
const { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
@@ -199,12 +200,41 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
await checkVhd(handler, parentPath)
}
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
let nbdClient
if (this._backup.config.useNbd && adapter.useVhdDirectory()) {
debug('useNbd is enabled', { vdi: id, path })
// get nbd if possible
try {
// this will always take the first host in the list
const [nbdInfo] = await vm.$xapi.call('VDI.get_nbd_info', vdiRef)
debug('got NBD info', { nbdInfo, vdi: id, path })
nbdClient = new NbdClient(nbdInfo)
await nbdClient.connect()
// this will inform the xapi that we don't need this anymore
// and will detach the vdi from dom0
$defer(() => nbdClient.disconnect())
info('NBD client ready', { vdi: id, path })
Task.info('NBD used')
} catch (error) {
Task.warning('NBD configured but unusable', { error })
nbdClient = undefined
warn('error connecting to NBD server', { error, vdi: id, path })
}
} else {
debug('useNbd is disabled', { vdi: id, path })
}
transferSize += await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
// no checksum for VHDs, because they will be invalidated by
// merges and chainings
checksum: false,
validator: tmpPath => checkVhd(handler, tmpPath),
writeBlockConcurrency: this._backup.config.writeBlockConcurrency,
nbdClient,
})
if (isDelta) {

View File

@@ -45,13 +45,11 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
data: {
id: this._sr.uuid,
isFull,
name_label: this._sr.name_label,
type: 'SR',
},
})
this.transfer = task.wrapFn(this.transfer)
this.cleanup = task.wrapFn(this.cleanup)
this.healthCheck = task.wrapFn(this.healthCheck, true)
this.cleanup = task.wrapFn(this.cleanup, true)
return task.run(() => this._prepare())
}

View File

@@ -21,7 +21,6 @@ exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplica
name: 'export',
data: {
id: props.sr.uuid,
name_label: this._sr.name_label,
type: 'SR',
// necessary?

View File

@@ -80,7 +80,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
assert.notStrictEqual(
this._metadataFileName,
undefined,
'Metadata file name should be defined before making a health check'
'Metadata file name should be defined before making a healthcheck'
)
return Task.run(
{

View File

@@ -18,7 +18,7 @@
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^1.3.1"
"xen-api": "^1.2.7"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "3.3.4",
"version": "3.3.2",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -29,7 +29,7 @@
"@vates/async-each": "^1.0.0",
"@vates/coalesce-calls": "^0.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.1.1",
"@vates/read-chunk": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"bind-property-descriptor": "^2.0.0",

View File

@@ -3,6 +3,7 @@ import { parse } from 'xo-remote-parser'
import RemoteHandlerLocal from './local'
import RemoteHandlerNfs from './nfs'
import RemoteHandlerNull from './null'
import RemoteHandlerS3 from './s3'
import RemoteHandlerSmb from './smb'
export { DEFAULT_ENCRYPTION_ALGORITHM, UNENCRYPTED_ALGORITHM, isLegacyEncryptionAlgorithm } from './_encryptor'
@@ -10,6 +11,7 @@ export { DEFAULT_ENCRYPTION_ALGORITHM, UNENCRYPTED_ALGORITHM, isLegacyEncryption
const HANDLERS = {
file: RemoteHandlerLocal,
nfs: RemoteHandlerNfs,
null: RemoteHandlerNull,
s3: RemoteHandlerS3,
}

View File

@@ -0,0 +1,14 @@
import LocalHandler from './local'
export default class NullHandler extends LocalHandler {
get type() {
return 'null'
}
_outputStream() {}
_writeFile(file, data, options) {
if (file.indexOf('xo-vm-backups') === -1) {
// metadata, remote tests
return super._writeFile(file, data, options)
}
}
}

View File

@@ -18,7 +18,6 @@
"@novnc/novnc": "^1.3.0",
"@types/d3-time-format": "^4.0.0",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.8",
"@vueuse/core": "^9.5.0",
"@vueuse/math": "^9.5.0",
"complex-matcher": "^0.7.0",
@@ -26,14 +25,13 @@
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"highlight.js": "^11.6.0",
"human-format": "^1.1.0",
"iterable-backoff": "^0.1.0",
"human-format": "^1.0.0",
"json-rpc-2.0": "^1.3.0",
"json5": "^2.2.1",
"limit-concurrency-decorator": "^0.5.0",
"lodash-es": "^4.17.21",
"make-error": "^1.3.6",
"marked": "^4.2.12",
"markdown-it": "^13.0.1",
"pinia": "^2.0.14",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.2.37",

View File

@@ -1,5 +1,25 @@
<template>
<UnreachableHostsModal />
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #subtitle>{{ $t("following-hosts-unreachable") }}</template>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url.hostname">
<a :href="url.href" rel="noopener" target="_blank">{{ url.href }}</a>
</li>
</ul>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin />
</div>
@@ -21,14 +41,21 @@ import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { usePoolStore } from "@/stores/pool.store";
import { useHostStore } from "@/stores/host.store";
import { useUiStore } from "@/stores/ui.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
import { logicAnd } from "@vueuse/math";
import { computed } from "vue";
import { difference } from "lodash";
import { computed, ref, watch, watchEffect } from "vue";
import { useRoute } from "vue-router";
const unreachableHostsUrls = ref<URL[]>([]);
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
let link = document.querySelector(
"link[rel~='icon']"
@@ -43,7 +70,7 @@ link.href = favicon;
document.title = "XO Lite";
const xenApiStore = useXenApiStore();
const { pool } = usePoolStore().subscribe();
const hostStore = useHostStore();
useChartTheme();
const uiStore = useUiStore();
@@ -65,14 +92,34 @@ if (import.meta.env.DEV) {
);
}
whenever(
() => pool.value?.$ref,
async (poolRef) => {
const xenApi = xenApiStore.getXapi();
await xenApi.injectWatchEvent(poolRef);
await xenApi.startWatch();
const route = useRoute();
watchEffect(() => {
if (route.meta.hasStoryNav) {
return;
}
if (xenApiStore.isConnected) {
xenApiStore.init();
}
});
watch(
() => hostStore.allRecords,
(hosts, previousHosts) => {
difference(hosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.push(url)
);
});
}
);
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
const reload = () => window.location.reload();
</script>
<style lang="postcss">

View File

@@ -38,11 +38,6 @@ code * {
color: var(--color-extra-blue-d20);
}
.link:active,
.link.router-link-active {
.link:active {
color: var(--color-extra-blue-d40);
}
.link.router-link-active {
text-decoration: underline;
}

View File

@@ -12,23 +12,13 @@ html {
font-family: Poppins, sans-serif;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
margin: 0;
padding: 0;
font-weight: normal;
}
ol,
ul {
ol, ul {
list-style: none;
}

View File

@@ -1,85 +1,75 @@
:root {
--color-blue-scale-000: #000000;
--color-blue-scale-100: #1a1b38;
--color-blue-scale-200: #595a6f;
--color-blue-scale-300: #9899a5;
--color-blue-scale-400: #e5e5e7;
--color-blue-scale-500: #ffffff;
--color-blue-scale-100: #1A1B38;
--color-blue-scale-200: #595A6F;
--color-blue-scale-300: #9899A5;
--color-blue-scale-400: #E5E5E7;
--color-blue-scale-500: #FFFFFF;
--color-extra-blue-l60: #d1cefb;
--color-extra-blue-l40: #bbb5f9;
--color-extra-blue-l20: #a39df8;
--color-extra-blue-base: #8f84ff;
--color-extra-blue-d20: #716ac6;
--color-extra-blue-d40: #554f94;
--color-extra-blue-l60: #D1CEFB;
--color-extra-blue-l40: #BBB5F9;
--color-extra-blue-l20: #A39DF8;
--color-extra-blue-base: #8F84FF;
--color-extra-blue-d20: #716AC6;
--color-extra-blue-d40: #554F94;
--color-extra-blue-d60: #383563;
--color-green-infra-l60: #b5dbca;
--color-green-infra-l40: #91c9b0;
--color-green-infra-l20: #70b795;
--color-green-infra-base: #55a57b;
--color-green-infra-l60: #B5DBCA;
--color-green-infra-l40: #91C9B0;
--color-green-infra-l20: #70B795;
--color-green-infra-base: #55A57B;
--color-green-infra-d20: #438463;
--color-green-infra-d40: #32634a;
--color-green-infra-d40: #32634A;
--color-green-infra-d60: #214231;
--color-orange-world-l60: #f2cda8;
--color-orange-world-l40: #ebb57d;
--color-orange-world-l20: #e59d56;
--color-orange-world-base: #ef7f18;
--color-orange-world-d20: #bf6612;
--color-orange-world-d40: #864f1f;
--color-orange-world-d60: #5a3514;
--color-orange-world-l60: #F2CDA8;
--color-orange-world-l40: #EBB57D;
--color-orange-world-l20: #E59D56;
--color-orange-world-base: #EF7F18;
--color-orange-world-d20: #BF6612;
--color-orange-world-d40: #864F1F;
--color-orange-world-d60: #5A3514;
--color-red-vates-l60: #dda5a7;
--color-red-vates-l40: #ce787c;
--color-red-vates-l20: #bf4f51;
--color-red-vates-base: #be1621;
--color-red-vates-d20: #8e2221;
--color-red-vates-d40: #6a1919;
--color-red-vates-l60: #DDA5A7;
--color-red-vates-l40: #CE787C;
--color-red-vates-l20: #BF4F51;
--color-red-vates-base: #BE1621;
--color-red-vates-d20: #8E2221;
--color-red-vates-d40: #6A1919;
--color-red-vates-d60: #471010;
--color-grayscale-200: #585757;
--background-color-primary: #ffffff;
--background-color-secondary: #f6f6f7;
--background-color-extra-blue: #f4f3fe;
--background-color-green-infra: #ecf5f2;
--background-color-orange-world: #fbf2e9;
--background-color-red-vates: #f5e8e9;
--background-color-primary: #FFFFFF;
--background-color-secondary: #F6F6F7;
--background-color-extra-blue: #F4F3FE;
--background-color-green-infra: #ECF5F2;
--background-color-orange-world: #FBF2E9;
--background-color-red-vates: #F5E8E9;
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.06);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1),
0 0.2rem 0.1rem rgba(20, 20, 30, 0.06),
0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1),
0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1rem rgba(20, 20, 30, 0.08);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1),
0 0.9rem 4.6rem rgba(20, 20, 30, 0.06),
0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.06), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.08);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.06), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
}
:root.dark {
--color-blue-scale-000: #ffffff;
--color-blue-scale-100: #e5e5e7;
--color-blue-scale-200: #9899a5;
--color-blue-scale-300: #595a6f;
--color-blue-scale-400: #1a1b38;
--color-blue-scale-000: #FFFFFF;
--color-blue-scale-100: #E5E5E7;
--color-blue-scale-200: #9899A5;
--color-blue-scale-300: #595A6F;
--color-blue-scale-400: #1A1B38;
--color-blue-scale-500: #000000;
--background-color-primary: #14141d;
--background-color-secondary: #17182a;
--background-color-extra-blue: #35335d;
--background-color-green-infra: #243b3d;
--background-color-primary: #14141D;
--background-color-secondary: #17182A;
--background-color-extra-blue: #35335D;
--background-color-green-infra: #243B3D;
--background-color-orange-world: #493328;
--background-color-red-vates: #3c1a28;
--background-color-red-vates: #3C1A28;
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.12);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.2),
0 0.2rem 0.1rem rgba(20, 20, 30, 0.12),
0 0.1rem 0.1rem rgba(20, 20, 30, 0.16);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.2),
0 0.1rem 1.8rem rgba(20, 20, 30, 0.12), 0 0.6rem 1rem rgba(20, 20, 30, 0.16);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.2),
0 0.9rem 4.6rem rgba(20, 20, 30, 0.12),
0 2.4rem 3.8rem rgba(20, 20, 30, 0.08);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.2), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.12), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.16);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.2), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.12), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.16);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.2), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.12), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.08);
}

View File

@@ -3,10 +3,10 @@
</template>
<script lang="ts" setup>
import markdown from "@/libs/markdown";
import { type Ref, computed, ref } from "vue";
import { useEventListener } from "@vueuse/core";
import "highlight.js/styles/github-dark.css";
import { computed, type Ref, ref } from "vue";
import { markdown } from "@/libs/markdown";
const rootElement = ref() as Ref<HTMLElement>;
@@ -14,7 +14,7 @@ const props = defineProps<{
content: string;
}>();
const html = computed(() => markdown.parse(props.content ?? ""));
const html = computed(() => markdown.render(props.content ?? ""));
useEventListener(
rootElement,
@@ -96,7 +96,6 @@ useEventListener(
position: absolute;
z-index: 1;
right: 1rem;
top: 0.4rem;
cursor: pointer;
color: white;
border: none;

View File

@@ -1,15 +1,15 @@
<template>
<div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip">
<span class="triangle" />
<span class="label">{{ options.content }}</span>
<span class="label">{{ content }}</span>
</div>
</template>
<script lang="ts" setup>
import type { TooltipOptions } from "@/stores/tooltip.store";
import { isString } from "lodash-es";
import { isEmpty, isFunction, isString } from "lodash-es";
import place from "placement.js";
import { computed, ref, watchEffect } from "vue";
import type { TooltipOptions } from "@/stores/tooltip.store";
const props = defineProps<{
target: HTMLElement;
@@ -18,13 +18,29 @@ const props = defineProps<{
const tooltipElement = ref<HTMLElement>();
const isDisabled = computed(() =>
isString(props.options.content)
? props.options.content.trim() === ""
: props.options.content === false
const content = computed(() =>
isString(props.options) ? props.options : props.options.content
);
const placement = computed(() => props.options.placement ?? "top");
const isDisabled = computed(() => {
if (isEmpty(content.value)) {
return true;
}
if (isString(props.options)) {
return false;
}
if (isFunction(props.options.disabled)) {
return props.options.disabled(props.target);
}
return props.options.disabled ?? false;
});
const placement = computed(() =>
isString(props.options) ? "top" : props.options.placement ?? "top"
);
watchEffect(() => {
if (tooltipElement.value) {

View File

@@ -14,12 +14,7 @@
</UiActionButton>
</UiFilterGroup>
<UiModal
v-if="isOpen"
:icon="faFilter"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<UiModal v-if="isOpen" :icon="faFilter" @submit.prevent="handleSubmit">
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"

View File

@@ -17,12 +17,7 @@
</UiActionButton>
</UiFilterGroup>
<UiModal
v-if="isOpen"
:icon="faSort"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<UiModal v-if="isOpen" :icon="faSort" @submit.prevent="handleSubmit">
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">

View File

@@ -18,27 +18,24 @@
/>
</div>
<UiTable vertical-border>
<thead>
<tr>
<td v-if="isSelectable">
<input v-model="areAllSelected" type="checkbox" />
</td>
<slot name="head-row" />
</tr>
</thead>
<tbody>
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
<td v-if="isSelectable">
<input
v-model="selected"
:value="item[props.idProperty]"
type="checkbox"
/>
</td>
<slot :item="item" name="body-row" />
</tr>
</tbody>
<UiTable>
<template #header>
<td v-if="isSelectable">
<input v-model="areAllSelected" type="checkbox" />
</td>
<slot name="header" />
</template>
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
<td v-if="isSelectable">
<input
v-model="selected"
:value="item[props.idProperty]"
type="checkbox"
/>
</td>
<slot :item="item" name="row" />
</tr>
</UiTable>
</template>

View File

@@ -1,31 +1,33 @@
<template>
<div v-if="!isReady" class="wrapper-spinner">
<div class="wrapper-spinner" v-if="store.isLoading">
<UiSpinner class="spinner" />
</div>
<ObjectNotFoundView v-else-if="isRecordNotFound" :id="id" />
<ObjectNotFoundView :id="id" v-else-if="isRecordNotFound" />
<slot v-else />
</template>
<script lang="ts" setup>
import UiSpinner from "@/components/ui/UiSpinner.vue";
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { useRouter } from "vue-router";
const props = defineProps<{
isReady: boolean;
uuidChecker: (uuid: string) => boolean;
id?: string;
}>();
const storeByType = {
vm: useVmStore,
host: useHostStore,
};
const props = defineProps<{ objectType: "vm" | "host"; id?: string }>();
const store = storeByType[props.objectType]();
const { currentRoute } = useRouter();
const id = computed(
() => props.id ?? (currentRoute.value.params.uuid as string)
);
const isRecordNotFound = computed(
() => props.isReady && !props.uuidChecker(id.value)
() => store.isReady && !store.hasRecordByUuid(id.value)
);
</script>

View File

@@ -3,16 +3,9 @@
</template>
<script lang="ts" setup>
import { fibonacci } from "iterable-backoff";
import { computed, onBeforeUnmount, ref, watch, watchEffect } from "vue";
import { 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 FIBONACCI_MS_ARRAY: number[] = Array.from(
fibonacci().toMs().take(N_TOTAL_TRIES)
);
const props = defineProps<{
location: string;
@@ -21,84 +14,37 @@ const props = defineProps<{
const vmConsoleContainer = ref<HTMLDivElement>();
const xenApiStore = useXenApiStore();
const url = computed(() => {
if (xenApiStore.currentSessionId == null) {
return;
}
const _url = new URL(props.location);
_url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
_url.searchParams.set("session_id", xenApiStore.currentSessionId);
return _url;
});
let vncClient: VncClient | undefined;
let nConnectionAttempts = 0;
const handleDisconnectionEvent = () => {
clearVncClient();
if (props.isConsoleAvailable) {
nConnectionAttempts++;
if (nConnectionAttempts > N_TOTAL_TRIES) {
console.error(
"The number of reconnection attempts has been exceeded for:",
props.location
);
return;
}
console.error(
`Connection lost for the remote console: ${
props.location
}. New attempt in ${FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]}ms`
);
createVncConnection();
}
};
const handleConnectionEvent = () => (nConnectionAttempts = 0);
const clearVncClient = () => {
if (vncClient === undefined) {
return;
if (vncClient !== undefined) {
if (vncClient._rfbConnectionState !== "disconnected") {
vncClient.disconnect();
}
vncClient = undefined;
}
vncClient.removeEventListener("disconnect", handleDisconnectionEvent);
vncClient.removeEventListener("connect", handleConnectionEvent);
if (vncClient._rfbConnectionState !== "disconnected") {
vncClient.disconnect();
}
vncClient = undefined;
};
const createVncConnection = async () => {
if (nConnectionAttempts !== 0) {
await promiseTimeout(FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]);
}
vncClient = new VncClient(vmConsoleContainer.value!, url.value!.toString(), {
wsProtocols: ["binary"],
});
vncClient.scaleViewport = true;
vncClient.addEventListener("disconnect", handleDisconnectionEvent);
vncClient.addEventListener("connect", handleConnectionEvent);
};
watch(url, clearVncClient);
watchEffect(() => {
if (
url.value === undefined ||
vmConsoleContainer.value === undefined ||
!vmConsoleContainer.value ||
!xenApiStore.currentSessionId ||
!props.isConsoleAvailable
) {
return;
}
nConnectionAttempts = 0;
createVncConnection();
clearVncClient();
const url = new URL(props.location);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("session_id", xenApiStore.currentSessionId);
vncClient = new VncClient(vmConsoleContainer.value, url.toString(), {
wsProtocols: ["binary"],
});
vncClient.scaleViewport = true;
});
onBeforeUnmount(() => {

View File

@@ -1,59 +0,0 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import { faServer } from "@fortawesome/free-solid-svg-icons";
import UiModal from "@/components/ui/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { computed, ref, watch } from "vue";
import { difference } from "lodash";
import { useHostStore } from "@/stores/host.store";
const { records: hosts } = useHostStore().subscribe();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
const reload = () => window.location.reload();
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
</script>
<style lang="postcss" scoped>
.description p {
margin: 1rem 0;
}
</style>

View File

@@ -19,7 +19,6 @@
class="preset-tab"
@click="open"
>
<UiIcon :icon="faSliders" />
Presets
</UiTab>
</template>
@@ -106,7 +105,6 @@ import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiTab from "@/components/ui/UiTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import {
@@ -118,7 +116,6 @@ import {
ModelParam,
type Param,
} from "@/libs/story/story-param";
import { faSliders } from "@fortawesome/free-solid-svg-icons";
import "highlight.js/styles/github-dark.css";
import { uniqueId, upperFirst } from "lodash-es";
import { computed, reactive, ref, watch, watchEffect } from "vue";
@@ -177,8 +174,8 @@ if (propParams.value.length !== 0) {
selectedTab.value = TAB.SETTINGS;
}
const propValues = ref<Record<string, any>>({});
const settingValues = ref<Record<string, any>>({});
const propValues = reactive<Record<string, any>>({});
const settingValues = reactive<Record<string, any>>({});
const eventsLog = ref<
{ id: string; name: string; args: { name: string; value: any }[] }[]
>([]);
@@ -186,13 +183,13 @@ const unreadEventsCount = ref(0);
const resetProps = () => {
propParams.value.forEach((param) => {
propValues.value[param.name] = param.getPresetValue();
propValues[param.name] = param.getPresetValue();
});
};
const resetSettings = () => {
settingParams.value.forEach((param) => {
settingValues.value[param.name] = param.getPresetValue();
settingValues[param.name] = param.getPresetValue();
});
};
@@ -237,13 +234,13 @@ const slotProperties = computed(() => {
const properties: Record<string, any> = {};
propParams.value.forEach(({ name }) => {
properties[name] = propValues.value[name];
properties[name] = propValues[name];
});
eventParams.value.forEach((eventParam) => {
properties[`on${upperFirst(eventParam.name)}`] = (...args: any[]) => {
if (eventParam.isVModel()) {
propValues.value[eventParam.rawName] = args[0];
propValues[eventParam.rawName] = args[0];
}
const logArgs = Object.keys(eventParam.getArguments()).map(
(argName, index) => ({
@@ -263,7 +260,7 @@ const slotSettings = computed(() => {
const result: Record<string, any> = {};
settingParams.value.forEach(({ name }) => {
result[name] = settingValues.value[name];
result[name] = settingValues[name];
});
return result;
@@ -287,13 +284,13 @@ const applyPreset = (preset: {
}) => {
if (preset.props !== undefined) {
Object.entries(preset.props).forEach(([name, value]) => {
propValues.value[name] = value;
propValues[name] = value;
});
}
if (preset.settings !== undefined) {
Object.entries(preset.settings).forEach(([name, value]) => {
settingValues.value[name] = value;
settingValues[name] = value;
});
}
};

View File

@@ -2,7 +2,7 @@
<StoryParamsTable>
<thead>
<tr>
<th>Setting</th>
<th>Prop</th>
<th><!--Widget--></th>
<th>Help</th>
</tr>

View File

@@ -1,5 +1,12 @@
<template>
<li v-if="host !== undefined" class="infra-host-item">
<li
v-if="host"
class="infra-host-item"
v-tooltip="{
content: host.name_label,
disabled: isTooltipDisabled,
}"
>
<InfraItemLabel
:active="isCurrentHost"
:icon="faServer"
@@ -8,10 +15,10 @@
{{ host.name_label || "(Host)" }}
<template #actions>
<InfraAction
v-if="isPoolMaster"
v-tooltip="'Master'"
:icon="faStar"
class="master-icon"
v-if="isPoolMaster"
v-tooltip="'Master'"
/>
<InfraAction
:icon="isExpanded ? faAngleDown : faAngleUp"
@@ -25,13 +32,8 @@
</template>
<script lang="ts" setup>
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { computed } from "vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import {
faAngleDown,
faAngleUp,
@@ -39,18 +41,25 @@ import {
faStar,
} from "@fortawesome/free-solid-svg-icons";
import { useToggle } from "@vueuse/core";
import { computed } from "vue";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { hasEllipsis } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
const props = defineProps<{
hostOpaqueRef: string;
}>();
const { getByOpaqueRef } = useHostStore().subscribe();
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
const hostStore = useHostStore();
const host = computed(() => hostStore.getRecord(props.hostOpaqueRef));
const { pool } = usePoolStore().subscribe();
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
const poolStore = usePoolStore();
const isPoolMaster = computed(
() => poolStore.pool?.master === props.hostOpaqueRef
);
const uiStore = useUiStore();
@@ -58,16 +67,17 @@ const isCurrentHost = computed(
() => props.hostOpaqueRef === uiStore.currentHostOpaqueRef
);
const [isExpanded, toggle] = useToggle(true);
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>
.infra-host-item:deep(.link),
.infra-host-item:deep(.link-placeholder) {
.infra-host-item:deep(.link) {
padding-left: 3rem;
}
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
.infra-vm-list:deep(.link) {
padding-left: 4.5rem;
}

View File

@@ -1,24 +1,26 @@
<template>
<ul class="infra-host-list">
<li v-if="hasError" class="text-error">
<li v-if="isLoading">{{ $t("loading-hosts") }}</li>
<li v-else-if="hasError" class="text-error">
{{ $t("error-no-data") }}
</li>
<li v-else-if="!isReady">{{ $t("loading-hosts") }}</li>
<template v-else>
<InfraHostItem
v-for="host in hosts"
:key="host.$ref"
:host-opaque-ref="host.$ref"
v-for="opaqueRef in opaqueRefs"
:key="opaqueRef"
:host-opaque-ref="opaqueRef"
/>
</template>
</ul>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
import { useHostStore } from "@/stores/host.store";
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
const hostStore = useHostStore();
const { hasError, isLoading, opaqueRefs } = storeToRefs(hostStore);
</script>
<style lang="postcss" scoped>

View File

@@ -7,9 +7,9 @@
class="infra-item-label"
v-bind="$attrs"
>
<a :href="href" class="link" @click="navigate" v-tooltip="hasTooltip">
<a :href="href" class="link" @click="navigate">
<UiIcon :icon="icon" class="icon" />
<div ref="textElement" class="text">
<div class="text">
<slot />
</div>
</a>
@@ -22,10 +22,7 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { computed, ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
defineProps<{
@@ -33,9 +30,6 @@ defineProps<{
route: RouteLocationRaw;
active?: boolean;
}>();
const textElement = ref<HTMLElement>();
const hasTooltip = computed(() => hasEllipsis(textElement.value));
</script>
<style lang="postcss" scoped>

View File

@@ -1,12 +1,12 @@
<template>
<ul class="infra-pool-list">
<li v-if="hasError" class="text-error">
{{ $t("error-no-data") }}
</li>
<InfraLoadingItem
v-else-if="!isReady || pool === undefined"
v-if="isLoading || pool === undefined"
:icon="faBuilding"
/>
<li v-else-if="hasError" class="text-error">
{{ $t("error-no-data") }}
</li>
<li v-else class="infra-pool-item">
<InfraItemLabel
:icon="faBuilding"
@@ -24,14 +24,16 @@
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import InfraHostList from "@/components/infra/InfraHostList.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { usePoolStore } from "@/stores/pool.store";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
const { isReady, hasError, pool } = usePoolStore().subscribe();
const poolStore = usePoolStore();
const { hasError, isLoading, pool } = storeToRefs(poolStore);
</script>
<style lang="postcss" scoped>
@@ -40,8 +42,7 @@ const { isReady, hasError, pool } = usePoolStore().subscribe();
font-weight: 500;
}
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
.infra-vm-list:deep(.link) {
padding-left: 3rem;
}

View File

@@ -1,5 +1,12 @@
<template>
<li v-if="vm !== undefined" ref="rootElement" class="infra-vm-item">
<li
ref="rootElement"
class="infra-vm-item"
v-tooltip="{
content: vm.name_label,
disabled: isTooltipDisabled,
}"
>
<InfraItemLabel
v-if="isVisible"
:icon="faDisplay"
@@ -8,7 +15,7 @@
{{ vm.name_label || "(VM)" }}
<template #actions>
<InfraAction>
<PowerStateIcon :state="vm.power_state" />
<PowerStateIcon :state="vm?.power_state" />
</InfraAction>
</template>
</InfraItemLabel>
@@ -16,20 +23,20 @@
</template>
<script lang="ts" setup>
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import { useVmStore } from "@/stores/vm.store";
import { computed, ref } from "vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
import { computed, ref } from "vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import { hasEllipsis } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
const props = defineProps<{
vmOpaqueRef: string;
}>();
const { getByOpaqueRef } = useVmStore().subscribe();
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
const rootElement = ref();
const isVisible = ref(false);
@@ -39,6 +46,13 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
stop();
}
});
const vmStore = useVmStore();
const vm = computed(() => vmStore.getRecord(props.vmOpaqueRef));
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>

View File

@@ -1,32 +1,38 @@
<template>
<ul class="infra-vm-list">
<li v-if="hasError" class="text-error">{{ $t("error-no-data") }}</li>
<template v-else-if="!isReady">
<InfraLoadingItem v-for="i in 3" :key="i" :icon="faDisplay" />
<template v-if="isLoading">
<InfraLoadingItem v-for="i in 3" :icon="faDisplay" :key="i" />
</template>
<InfraVmItem v-for="vm in vms" :key="vm.$ref" :vm-opaque-ref="vm.$ref" />
<p class="text-error" v-else-if="hasError">{{ $t("error-no-data") }}</p>
<InfraVmItem
v-else
v-for="vmOpaqueRef in vmOpaqueRefs"
:key="vmOpaqueRef"
:vm-opaque-ref="vmOpaqueRef"
/>
</ul>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
hostOpaqueRef?: string;
}>();
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
const vms = computed(() =>
recordsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
const vmStore = useVmStore();
const { hasError, isLoading, opaqueRefsByHostRef } = storeToRefs(vmStore);
const vmOpaqueRefs = computed(() =>
opaqueRefsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
);
</script>
<style lang="postcss" scoped>
<style scoped lang="postcss">
.text-error {
padding-left: 3rem;
font-weight: 700;

View File

@@ -10,7 +10,6 @@ import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import TitleBar from "@/components/TitleBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const { pool } = usePoolStore().subscribe();
const name = computed(() => pool.value?.name_label ?? "...");
const poolStore = usePoolStore();
const name = computed(() => poolStore.pool?.name_label ?? "...");
</script>

View File

@@ -1,39 +1,43 @@
<template>
<UiTabBar :disabled="!isReady">
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: poolUuid } }">
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: poolUuid } }">
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.stats', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.stats', params: { uuid: poolUuid } }">
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.system', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.system', params: { uuid: poolUuid } }">
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.network', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.network', params: { uuid: poolUuid } }">
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.storage', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.storage', params: { uuid: poolUuid } }">
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: poolUuid } }">
{{ $t("tasks") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: poolUuid } }">
{{ $t("hosts") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.vms', params: { uuid: pool?.uuid } }">
<RouterTab :to="{ name: 'pool.vms', params: { uuid: poolUuid } }">
{{ $t("vms") }}
</RouterTab>
</UiTabBar>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { computed } from "vue";
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const { pool, isReady } = usePoolStore().subscribe();
const poolStore = usePoolStore();
const { pool, isReady } = storeToRefs(poolStore);
const poolUuid = computed(() => pool.value?.uuid);
</script>

View File

@@ -7,9 +7,9 @@
<UiStatusIcon v-if="state !== 'success'" :state="state" />
</template>
</UiCardTitle>
<div v-if="isReady" :class="state" class="progress-item">
<UiProgressBar :max-value="maxValue" :value="value" color="custom" />
<UiProgressScale :max-value="maxValue" :steps="1" unit="%" />
<div v-if="isReady" class="progress-item" :class="state">
<UiProgressBar color="custom" :value="value" :max-value="maxValue" />
<UiProgressScale :max-value="maxValue" unit="%" :steps="1" />
<UiProgressLegend :label="$t('vcpus')" :value="`${value}%`" />
<UiCardFooter>
<template #left>
@@ -27,62 +27,52 @@
</template>
<script lang="ts" setup>
import UiStatusIcon from "@/components/ui/icon/UiStatusIcon.vue";
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
import UiProgressLegend from "@/components/ui/progress/UiProgressLegend.vue";
import UiProgressScale from "@/components/ui/progress/UiProgressScale.vue";
import { computed } from "vue";
import { storeToRefs } from "pinia";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
import UiProgressLegend from "@/components/ui/progress/UiProgressLegend.vue";
import UiProgressScale from "@/components/ui/progress/UiProgressScale.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { percent } from "@/libs/utils";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import UiStatusIcon from "@/components/ui/icon/UiStatusIcon.vue";
import { isHostRunning, percent } from "@/libs/utils";
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 { computed } from "vue";
const ACTIVE_STATES = new Set(["Running", "Paused"]);
const { isReady: isHostStoreReady, runningHosts } = useHostStore().subscribe({
hostMetricsSubscription: useHostMetricsStore().subscribe(),
});
const { records: vms, isReady: isVmStoreReady } = useVmStore().subscribe();
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
useVmMetricsStore().subscribe();
const { allRecords: hosts, isReady: hostStoreIsReady } = storeToRefs(
useHostStore()
);
const { allRecords: vms, isReady: vmStoreIsReady } = storeToRefs(useVmStore());
const vmMetricsStore = useVmMetricsStore();
const nPCpu = computed(() =>
runningHosts.value.reduce(
(total, host) => total + Number(host.cpu_info.cpu_count),
hosts.value.reduce(
(total, host) =>
isHostRunning(host) ? total + Number(host.cpu_info.cpu_count) : total,
0
)
);
const nVCpuInUse = computed(() => {
if (!isReady.value) {
return 0;
}
return vms.value.reduce(
const nVCpuInUse = computed(() =>
vms.value.reduce(
(total, vm) =>
ACTIVE_STATES.has(vm.power_state)
? total + getVmMetrics(vm.metrics)!.VCPUs_number
? total + vmMetricsStore.getRecord(vm.metrics).VCPUs_number
: total,
0
);
});
)
);
const value = computed(() =>
Math.round(percent(nVCpuInUse.value, nPCpu.value))
);
const maxValue = computed(() => Math.ceil(value.value / 100) * 100);
const state = computed(() => (value.value > 100 ? "warning" : "success"));
const isReady = logicAnd(
isVmStoreReady,
isHostStoreReady,
isVmMetricsStoreReady
const isReady = computed(
() => vmStoreIsReady.value && vmMetricsStore.isReady && hostStoreIsReady.value
);
</script>
@@ -92,17 +82,14 @@ const isReady = logicAnd(
--progress-bar-height: 1.2rem;
--progress-bar-color: var(--color-extra-blue-base);
--progress-bar-background-color: var(--color-blue-scale-400);
&.warning {
--progress-bar-color: var(--color-orange-world-base);
--footer-value-color: var(--color-orange-world-base);
}
& .footer-value {
color: var(--footer-value-color);
}
}
.spinner {
color: var(--color-extra-blue-base);
display: flex;

View File

@@ -6,16 +6,15 @@
</UiCard>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { useHostStore } from "@/stores/host.store";
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const hasError = computed(() => hasVmError.value || hasHostError.value);
const hasError = computed(
() => useVmStore().hasError || useHostStore().hasError
);
</script>

View File

@@ -6,17 +6,16 @@
</UiCard>
</template>
<script lang="ts" setup>
<script setup lang="ts">
import { computed } from "vue";
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { useHostStore } from "@/stores/host.store";
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const hasError = computed(() => hasVmError.value || hasHostError.value);
const hasError = computed(
() => useVmStore().hasError || useHostStore().hasError
);
</script>

View File

@@ -1,8 +1,8 @@
<template>
<UiCard :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("status") }}</UiCardTitle>
<NoDataError v-if="hasError" />
<UiSpinner v-else-if="!isReady" class="spinner" />
<UiSpinner v-if="isLoading" class="spinner" />
<NoDataError v-else-if="hasError" />
<template v-else>
<PoolDashboardStatusItem
:active="activeHostsCount"
@@ -20,7 +20,6 @@
</template>
<script lang="ts" setup>
import NoDataError from "@/components/NoDataError.vue";
import PoolDashboardStatusItem from "@/components/pool/dashboard/PoolDashboardStatusItem.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
@@ -29,33 +28,30 @@ import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import NoDataError from "@/components/NoDataError.vue";
const {
isReady: isVmReady,
records: vms,
hasError: hasVmError,
runningVms,
} = useVmStore().subscribe();
const vmStore = useVmStore();
const hostMetricsStore = useHostMetricsStore();
const {
isReady: isHostMetricsReady,
records: hostMetrics,
hasError: hasHostMetricsError,
} = useHostMetricsStore().subscribe();
const hasError = computed(() => vmStore.hasError || hostMetricsStore.hasError);
const hasError = computed(() => hasVmError.value || hasHostMetricsError.value);
const isReady = computed(() => isVmReady.value && isHostMetricsReady.value);
const totalHostsCount = computed(() => hostMetrics.value.length);
const activeHostsCount = computed(
() => hostMetrics.value.filter((hostMetrics) => hostMetrics.live).length
const isLoading = computed(
() => vmStore.isLoading && hostMetricsStore.isLoading
);
const totalVmsCount = computed(() => vms.value.length);
const totalHostsCount = computed(() => hostMetricsStore.opaqueRefs.length);
const activeHostsCount = computed(() => {
return hostMetricsStore.opaqueRefs.filter(
(opaqueRef) => hostMetricsStore.getRecord(opaqueRef)?.live
).length;
});
const activeVmsCount = computed(() => runningVms.value.length);
const totalVmsCount = computed(() => vmStore.opaqueRefs.length);
const activeVmsCount = computed(() => {
return vmStore.opaqueRefs.filter(
(opaqueRef) => vmStore.getRecord(opaqueRef)?.power_state === "Running"
).length;
});
</script>
<style lang="postcss" scoped>

View File

@@ -18,16 +18,18 @@
</template>
<script lang="ts" setup>
import NoDataError from "@/components/NoDataError.vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import UiCard from "@/components/ui/UiCard.vue";
import { storeToRefs } from "pinia";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { computed } from "vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import UsageBar from "@/components/UsageBar.vue";
import UiCard from "@/components/ui/UiCard.vue";
import { useSrStore } from "@/stores/storage.store";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed } from "vue";
import NoDataError from "@/components/NoDataError.vue";
const { records: srs, isReady, hasError } = useSrStore().subscribe();
const srStore = useSrStore();
const { hasError, isReady } = storeToRefs(srStore);
const data = computed<{
result: { id: string; label: string; value: number }[];
@@ -38,7 +40,7 @@ const data = computed<{
let maxSize = 0;
let usedSize = 0;
srs.value.forEach(
srStore.allRecords.forEach(
({ name_label, physical_size, physical_utilisation, uuid }) => {
if (physical_size < 0 || physical_utilisation < 0) {
return;

View File

@@ -1,8 +1,8 @@
<template>
<UiCardTitle
subtitle
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
subtitle
/>
<NoDataError v-if="hasError" />
<UsageBar v-else :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
@@ -16,10 +16,11 @@ import { getAvgCpuUsage } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { useHostStore } from "@/stores/host.store";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { storeToRefs } from "pinia";
import { computed, type ComputedRef, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
const { hasError } = useHostStore().subscribe();
const { hasError } = storeToRefs(useHostStore());
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",

View File

@@ -12,13 +12,14 @@
<script lang="ts" setup>
import LinearChart from "@/components/charts/LinearChart.vue";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import type { HostStats } from "@/libs/xapi-stats";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData } from "@/types/chart";
import { sumBy } from "lodash-es";
import { storeToRefs } from "pinia";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
@@ -27,7 +28,7 @@ const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const { records: hosts } = useHostStore().subscribe();
const { allRecords: hosts } = storeToRefs(useHostStore());
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)

View File

@@ -18,8 +18,9 @@ import type { VmStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useVmStore } from "@/stores/vm.store";
import { storeToRefs } from "pinia";
const { hasError } = useVmStore().subscribe();
const { hasError } = storeToRefs(useVmStore());
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",

View File

@@ -18,8 +18,9 @@ import type { HostStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useHostStore } from "@/stores/host.store";
import { storeToRefs } from "pinia";
const { hasError } = useHostStore().subscribe();
const { hasError } = storeToRefs(useHostStore());
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",

View File

@@ -18,39 +18,33 @@
import LinearChart from "@/components/charts/LinearChart.vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import { formatSize, getHostMemory } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData } from "@/types/chart";
import { sumBy } from "lodash-es";
import { storeToRefs } from "pinia";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
import { formatSize, getHostMemory, isHostRunning } from "@/libs/utils";
import type { XenApiHost } from "@/libs/xen-api";
const hostMetricsSubscription = useHostMetricsStore().subscribe();
const hostStore = useHostStore();
const { runningHosts } = hostStore.subscribe({ hostMetricsSubscription });
const { allRecords: hosts } = storeToRefs(useHostStore());
const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const runningHosts = computed(() => hosts.value.filter(isHostRunning));
const customMaxValue = computed(() =>
sumBy(
runningHosts.value,
(host) => getHostMemory(host, hostMetricsSubscription)?.size ?? 0
)
sumBy(runningHosts.value, (host) => getHostMemory(host)?.size ?? 0)
);
const currentData = computed(() => {
let size = 0,
usage = 0;
runningHosts.value.forEach((host) => {
const hostMemory = getHostMemory(host, hostMetricsSubscription);
const hostMemory = getHostMemory(host);
size += hostMemory?.size ?? 0;
usage += hostMemory?.usage ?? 0;
});

View File

@@ -18,8 +18,9 @@ import type { VmStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useVmStore } from "@/stores/vm.store";
import { storeToRefs } from "pinia";
const { hasError } = useVmStore().subscribe();
const { hasError } = storeToRefs(useVmStore());
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",

View File

@@ -1,9 +1,8 @@
<template>
<tr :class="{ finished: !isPending }" class="finished-task-row">
<tr class="finished-task-row" :class="{ finished: !isPending }">
<td>{{ task.name_label }}</td>
<td>
<RouterLink
v-if="host !== undefined"
:to="{
name: 'host.dashboard',
params: { uuid: host.uuid },
@@ -44,7 +43,7 @@ const props = defineProps<{
task: XenApiTask;
}>();
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
const { getRecord: getHost } = useHostStore();
const createdAt = computed(() => parseDateTime(props.task.created));

View File

@@ -1,23 +1,20 @@
<template>
<UiTable class="tasks-table">
<thead>
<tr>
<th>{{ $t("name") }}</th>
<th>{{ $t("object") }}</th>
<th>{{ $t("task.progress") }}</th>
<th>{{ $t("task.started") }}</th>
<th>{{ $t("task.estimated-end") }}</th>
</tr>
</thead>
<tbody>
<TaskRow
v-for="task in pendingTasks"
:key="task.uuid"
:task="task"
is-pending
/>
<TaskRow v-for="task in finishedTasks" :key="task.uuid" :task="task" />
</tbody>
<template #header>
<th>{{ $t("name") }}</th>
<th>{{ $t("object") }}</th>
<th>{{ $t("task.progress") }}</th>
<th>{{ $t("task.started") }}</th>
<th>{{ $t("task.estimated-end") }}</th>
</template>
<TaskRow
v-for="task in pendingTasks"
:key="task.uuid"
:task="task"
is-pending
/>
<TaskRow v-for="task in finishedTasks" :key="task.uuid" :task="task" />
</UiTable>
</template>

View File

@@ -16,7 +16,6 @@ defineProps<{
<style lang="postcss" scoped>
.ui-badge {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.4rem;

View File

@@ -1,55 +1,45 @@
<template>
<table :class="{ 'vertical-border': verticalBorder }" class="ui-table">
<slot />
<table class="ui-table">
<thead>
<tr class="header-row">
<slot name="header" />
</tr>
</thead>
<tbody class="body">
<slot />
</tbody>
</table>
</template>
<script lang="ts" setup>
defineProps<{
verticalBorder?: boolean;
}>();
</script>
<script lang="ts" setup></script>
<style lang="postcss" scoped>
.ui-table {
width: 100%;
border-spacing: 0;
background-color: var(--background-color-primary);
}
:deep(th),
:deep(td) {
padding: 1rem;
border-top: 1px solid lightgrey;
border-right: 1px solid lightgrey;
text-align: left;
&:last-child {
border-right: none;
}
}
:deep(.header-row th) {
color: var(--color-extra-blue-base);
font-size: 1.4rem;
font-weight: 400;
text-transform: uppercase;
}
:deep(.body td) {
font-weight: 400;
font-size: 1.6rem;
line-height: 2.4rem;
color: var(--color-blue-scale-200);
:deep(th),
:deep(td) {
padding: 1rem;
border-top: 1px solid var(--color-blue-scale-400);
text-align: left;
}
:deep(th) {
font-weight: 700;
}
:deep(thead) {
th,
td {
color: var(--color-extra-blue-base);
font-size: 1.4rem;
font-weight: 400;
text-transform: uppercase;
}
}
&.vertical-border {
:deep(th),
:deep(td) {
border-right: 1px solid var(--color-blue-scale-400);
&:last-child {
border-right: none;
}
}
}
}
</style>

View File

@@ -1,13 +1,7 @@
<template>
<div class="legend">
<template v-if="$slots.label || label">
<span class="circle" />
<div class="label-container">
<div ref="labelElement" v-tooltip="isTooltipEnabled" class="label">
<slot name="label">{{ label }}</slot>
</div>
</div>
</template>
<span class="circle" />
<slot name="label">{{ label }}</slot>
<UiBadge class="badge">
<slot name="value">{{ value }}</slot>
</UiBadge>
@@ -16,23 +10,14 @@
<script lang="ts" setup>
import UiBadge from "@/components/ui/UiBadge.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { computed, ref } from "vue";
defineProps<{
label?: string;
value?: string;
}>();
const labelElement = ref<HTMLElement>();
const isTooltipEnabled = computed(() =>
hasEllipsis(labelElement.value, { vertical: true })
);
</script>
<style lang="postcss" scoped>
<style scoped lang="postcss">
.badge {
font-size: 0.9em;
font-weight: 700;
@@ -40,8 +25,8 @@ const isTooltipEnabled = computed(() =>
.circle {
display: inline-block;
min-width: 1rem;
min-height: 1rem;
width: 1rem;
height: 1rem;
border-radius: 0.5rem;
background-color: var(--progress-bar-color);
}
@@ -53,14 +38,4 @@ const isTooltipEnabled = computed(() =>
gap: 0.5rem;
margin: 1.6em 0;
}
.label-container {
overflow: hidden;
}
.label {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -2,7 +2,7 @@
<TitleBar :icon="faDisplay">
{{ name }}
<template #actions>
<AppMenu v-if="vm !== undefined" placement="bottom-end" shadow>
<AppMenu shadow placement="bottom-end">
<template #trigger="{ open, isOpen }">
<UiButton :active="isOpen" :icon="faPowerOff" @click="open">
{{ $t("change-state") }}
@@ -10,10 +10,10 @@
</UiButton>
</template>
<MenuItem
@click="xenApi.vm.start({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'start')"
:disabled="!isHalted"
:icon="faPlay"
@click="xenApi.vm.start(vm!.$ref)"
>
{{ $t("start") }}
</MenuItem>
@@ -25,24 +25,22 @@
{{ $t("start-on-host") }}
<template #submenu>
<MenuItem
v-for="host in hosts as XenApiHost[]"
v-for="host in hostStore.allRecords"
@click="xenApi.vm.startOn({ vmRef: vm.$ref, hostRef: host.$ref })"
v-bind:key="host.$ref"
:icon="faServer"
@click="xenApi.vm.startOn(vm!.$ref, host.$ref)"
>
<div class="wrapper">
{{ host.name_label }}
<div>
<UiIcon
:icon="host.$ref === pool?.master ? faStar : undefined"
:icon="
host.$ref === poolStore.pool?.master ? faStar : undefined
"
class="star"
/>
<PowerStateIcon
:state="
isHostRunning(host, hostMetricsSubscription)
? 'Running'
: 'Halted'
"
:state="isHostRunning(host) ? 'Running' : 'Halted'"
/>
</div>
</div>
@@ -50,59 +48,63 @@
</template>
</MenuItem>
<MenuItem
@click="xenApi.vm.pause({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'pause')"
:disabled="!isRunning"
:icon="faPause"
@click="xenApi.vm.pause(vm!.$ref)"
>
{{ $t("pause") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.suspend({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'suspend')"
:disabled="!isRunning"
:icon="faMoon"
@click="xenApi.vm.suspend(vm!.$ref)"
>
{{ $t("suspend") }}
</MenuItem>
<!-- TODO: update the icon once Clémence has integrated the action into figma -->
<MenuItem
@click="
xenApi.vm.resume({
vmRef: vm.$ref,
})
"
:busy="isOperationsPending(vm, ['unpause', 'resume'])"
:disabled="!isSuspended && !isPaused"
:icon="faCirclePlay"
@click="xenApi.vm.resume({ [vm!.$ref]: vm!.power_state })"
>
{{ $t("resume") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'clean_reboot')"
:disabled="!isRunning"
:icon="faRotateLeft"
@click="xenApi.vm.reboot(vm!.$ref)"
>
{{ $t("reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending(vm, 'hard_reboot')"
:disabled="!isRunning && !isPaused"
:icon="faRepeat"
@click="xenApi.vm.reboot(vm!.$ref, true)"
>
{{ $t("force-reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'clean_shutdown')"
:disabled="!isRunning"
:icon="faPowerOff"
@click="xenApi.vm.shutdown(vm!.$ref)"
>
{{ $t("shutdown") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending(vm, 'hard_shutdown')"
:disabled="!isRunning && !isSuspended && !isPaused"
:icon="faPlug"
@click="xenApi.vm.shutdown(vm!.$ref, true)"
>
{{ $t("force-shutdown") }}
</MenuItem>
@@ -110,17 +112,14 @@
</template>
</TitleBar>
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import TitleBar from "@/components/TitleBar.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { isHostRunning, isOperationsPending } from "@/libs/utils";
import type { XenApiHost } 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";
@@ -141,23 +140,22 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { computedAsync } from "@vueuse/core";
const { getByUuid: getVmByUuid } = useVmStore().subscribe();
const { records: hosts } = useHostStore().subscribe();
const { pool } = usePoolStore().subscribe();
const hostMetricsSubscription = useHostMetricsStore().subscribe();
const xenApi = useXenApiStore().getXapi();
const vmStore = useVmStore();
const hostStore = useHostStore();
const poolStore = usePoolStore();
const { currentRoute } = useRouter();
const vm = computed(() =>
getVmByUuid(currentRoute.value.params.uuid as string)
const vm = computed(
() => vmStore.getRecordByUuid(currentRoute.value.params.uuid as string)!
);
const name = computed(() => vm.value?.name_label);
const isRunning = computed(() => vm.value?.power_state === "Running");
const isHalted = computed(() => vm.value?.power_state === "Halted");
const isSuspended = computed(() => vm.value?.power_state === "Suspended");
const isPaused = computed(() => vm.value?.power_state === "Paused");
const xenApi = computedAsync(() => useXenApiStore().getXapi());
const name = computed(() => vm.value.name_label);
const isRunning = computed(() => vm.value.power_state === "Running");
const isHalted = computed(() => vm.value.power_state === "Halted");
const isSuspended = computed(() => vm.value.power_state === "Suspended");
const isPaused = computed(() => vm.value.power_state === "Paused");
</script>
<style lang="postcss" scoped>

View File

@@ -1,37 +0,0 @@
<template>
<UiTabBar>
<RouterTab :to="{ name: 'vm.dashboard', params: { uuid } }">
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.console', params: { uuid } }">
{{ $t("console") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.alarms', params: { uuid } }">
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.stats', params: { uuid } }">
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.system', params: { uuid } }">
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.network', params: { uuid } }">
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.storage', params: { uuid } }">
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.tasks', params: { uuid } }">
{{ $t("tasks") }}
</RouterTab>
</UiTabBar>
</template>
<script lang="ts" setup>
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
defineProps<{
uuid: string;
}>();
</script>

View File

@@ -6,10 +6,10 @@ This composable allows you to keep a history of each removed item of an array.
```typescript
const myArray = ref([]);
const history = useArrayRemovedItemsHistory(myArray);
const history = useArrayRemovedItemsHistory(myArray)
myArray.push("A"); // myArray = ['A']; history = []
myArray.push("B"); // myArray = ['A', 'B']; history = []
myArray.push('A'); // myArray = ['A']; history = []
myArray.push('B'); // myArray = ['A', 'B']; history = []
myArray.shift(); // myArray = ['B']; history = ['A']
```
@@ -25,8 +25,8 @@ Be careful when using an array of objects which is likely to be replaced (instea
```typescript
const myArray = ref([]);
const history = useArrayRemovedItemsHistory(myArray);
myArray.value = [{ id: "foo" }, { id: "bar" }];
myArray.value = [{ id: "bar" }, { id: "baz" }]; // history = [{ id: 'foo' }, { id: 'bar' }]
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }, { id: 'bar' }]
```
In this case, `{ id: 'bar' }` is detected as removed since in JavaScript `{ id: 'bar' } !== { id: 'bar' }`.
@@ -35,11 +35,7 @@ You must therefore use an identity function as third parameter to return the val
```typescript
const myArray = ref<{ id: string }[]>([]);
const history = useArrayRemovedItemsHistory(
myArray,
undefined,
(item) => item.id
);
myArray.value = [{ id: "foo" }, { id: "bar" }];
myArray.value = [{ id: "bar" }, { id: "baz" }]; // history = [{ id: 'foo' }]
const history = useArrayRemovedItemsHistory(myArray, undefined, (item) => item.id);
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }]
```

View File

@@ -1,3 +1,5 @@
import { computed, onUnmounted, ref, type ComputedRef } from "vue";
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
import {
type GRANULARITY,
type HostStats,
@@ -6,36 +8,36 @@ import {
type XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
import { computed, type ComputedRef, onUnmounted, ref } from "vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
const STORES_BY_OBJECT_TYPE = {
host: useHostStore,
vm: useVmStore,
};
export type Stat<T> = {
id: string;
name: string;
stats: T | undefined;
stats?: T | null;
pausable: Pausable;
};
type GetStats<T extends HostStats | VmStats> = (
uuid: string,
granularity: GRANULARITY
) => Promise<XapiStatsResponse<T>> | undefined;
export type FetchedStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
> = {
register: (object: T) => void;
unregister: (object: T) => void;
stats: ComputedRef<Stat<S>[]>;
timestampStart: ComputedRef<number>;
timestampEnd: ComputedRef<number>;
stats?: ComputedRef<Stat<S>[] | null>;
timestampStart?: ComputedRef<number>;
timestampEnd?: ComputedRef<number>;
};
export default function useFetchStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
>(getStats: GetStats<S>, granularity: GRANULARITY): FetchedStats<T, S> {
>(type: "host" | "vm", granularity: GRANULARITY) {
const stats = ref<Map<string, Stat<S>>>(new Map());
const timestamp = ref<number[]>([0, 0]);
@@ -52,12 +54,18 @@ export default function useFetchStats<
return;
}
const newStats = await getStats(object.uuid, granularity);
const objectStore = STORES_BY_OBJECT_TYPE[type]();
if (newStats === undefined) {
if (objectStore.hasError) {
stats.value.get(mapKey)!.stats = null;
return;
}
const newStats = (await objectStore.getStats(
object.uuid,
granularity
)) as XapiStatsResponse<S>;
timestamp.value = [
newStats.endTimestamp -
RRD_STEP_FROM_STRING[granularity] *

View File

@@ -1,71 +1,36 @@
# Tooltip Directive
By default, the tooltip will appear centered above the target element.
## Directive argument
The directive argument can be either:
- The tooltip content
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
## Tooltip content
The tooltip content can be either:
- `false` or an empty-string to disable the tooltip
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
- Non-empty string to enable the tooltip and use the string as content.
## Tooltip placement
Tooltip can be placed on the following positions:
- `top`
- `top-start`
- `top-end`
- `bottom`
- `bottom-start`
- `bottom-end`
- `left`
- `left-start`
- `left-end`
- `right`
- `right-start`
- `right-end`
By default, tooltip will appear centered above the target element.
## Usage
```vue
<template>
<!-- Boolean / Undefined -->
<span v-tooltip="true"
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<span v-tooltip
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<!-- String -->
<!-- Static -->
<span v-tooltip="'Tooltip content'">Item</span>
<!-- Object -->
<!-- Dynamic -->
<span v-tooltip="myTooltipContent">Item</span>
<!-- Placement -->
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
<!-- Dynamic -->
<span v-tooltip="myTooltip">Item</span>
<!-- Disabling (variable) -->
<span v-tooltip="{ content: 'Foobar', disabled: isDisabled }">Item</span>
<!-- Conditional -->
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span>
<!-- Disabling (function) -->
<span v-tooltip="{ content: 'Foobar', disabled: isDisabledFn }">Item</span>
</template>
<script setup>
import { ref } from "vue";
import { vTooltip } from "@/directives/tooltip.directive";
const myTooltip = ref("Content"); // or ref({ content: "Content", placement: "left-end" })
const isTooltipEnabled = ref(true);
const myTooltipContent = ref("Content");
const isDisabled = ref(true);
const isDisabledFn = (target: Element) => {
// return boolean;
};
</script>
```

View File

@@ -1,36 +1,8 @@
import type { Directive } from "vue";
import type { TooltipEvents, TooltipOptions } from "@/stores/tooltip.store";
import { useTooltipStore } from "@/stores/tooltip.store";
import { isObject } from "lodash-es";
import type { Options } from "placement.js";
import type { Directive } from "vue";
type TooltipDirectiveContent = undefined | boolean | string;
type TooltipDirectiveOptions =
| TooltipDirectiveContent
| {
content?: TooltipDirectiveContent;
placement?: Options["placement"];
};
const parseOptions = (
options: TooltipDirectiveOptions,
target: HTMLElement
): TooltipOptions => {
const { placement, content } = isObject(options)
? options
: { placement: undefined, content: options };
return {
placement,
content:
content === true || content === undefined
? target.innerText.trim()
: content,
};
};
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
mounted(target, binding) {
const store = useTooltipStore();
@@ -38,11 +10,11 @@ export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
? { on: "focusin", off: "focusout" }
: { on: "mouseenter", off: "mouseleave" };
store.register(target, parseOptions(binding.value, target), events);
store.register(target, binding.value, events);
},
updated(target, binding) {
const store = useTooltipStore();
store.updateOptions(target, parseOptions(binding.value, target));
store.updateOptions(target, binding.value);
},
beforeUnmount(target) {
const store = useTooltipStore();

View File

@@ -1,12 +1,12 @@
import HLJS from "highlight.js";
import { marked } from "marked";
import MarkdownIt from "markdown-it";
marked.use({
renderer: {
code(str: string, lang: string) {
const code = highlight(str, HLJS.getLanguage(lang) ? lang : "plaintext");
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
},
export const markdown = new MarkdownIt();
markdown.set({
highlight: (str: string, lang: string) => {
const code = highlight(str, lang);
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
},
});
@@ -25,7 +25,11 @@ function highlight(str: string, lang: string) {
case "vue-style":
return wrap(str.trim(), "style");
default: {
return copyable(HLJS.highlight(str, { language: lang }).value);
if (HLJS.getLanguage(lang) !== undefined) {
return copyable(HLJS.highlight(str, { language: lang }).value);
}
return copyable(markdown.utils.escapeHtml(str));
}
}
}
@@ -58,5 +62,3 @@ function wrap(str: string, tag: "template" | "script" | "style") {
function copyable(code: string) {
return `<div class="copyable">${code}</div>`;
}
export default marked;

View File

@@ -1,18 +1,18 @@
import type {
RawObjectType,
RawXenApiRecord,
XenApiHost,
XenApiHostMetrics,
XenApiRecord,
XenApiVm,
} from "@/libs/xen-api";
import type { CollectionSubscription } from "@/stores/xapi-collection.store";
import { utcParse } from "d3-time-format";
import humanFormat from "human-format";
import { castArray, round } from "lodash-es";
import { find, forEach, isEqual, size, sum } from "lodash-es";
import { type ComputedGetter, type Ref, computed, ref, watchEffect } from "vue";
import type { Filter } from "@/types/filter";
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
import { utcParse } from "d3-time-format";
import humanFormat from "human-format";
import { castArray, find, forEach, round, size, sum } from "lodash-es";
import type {
RawXenApiRecord,
XenApiHost,
XenApiRecord,
XenApiVm,
} from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
export function sortRecordsByNameLabel(
record1: { name_label: string },
@@ -71,25 +71,12 @@ export function parseDateTime(dateTime: string) {
return date.getTime();
}
export const hasEllipsis = (
target: Element | undefined | null,
{ vertical = false }: { vertical?: boolean } = {}
) => {
if (target == null) {
return false;
}
if (vertical) {
return target.clientHeight < target.scrollHeight;
}
return target.clientWidth < target.scrollWidth;
};
export const hasEllipsis = (target: Element | undefined | null) =>
target != undefined && target.clientWidth < target.scrollWidth;
export function percent(currentValue: number, maxValue: number, precision = 2) {
return round((currentValue / maxValue) * 100, precision);
}
export function getAvgCpuUsage(cpus?: object | any[], { nSequence = 4 } = {}) {
const statsLength = getStatsLength(cpus);
if (statsLength === undefined) {
@@ -114,37 +101,48 @@ export function getStatsLength(stats?: object | any[]) {
return size(find(stats, (stat) => stat != null));
}
export function isHostRunning(
host: XenApiHost,
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
) {
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
export function deepComputed<T>(getter: ComputedGetter<T>) {
const value = computed(getter);
const cache = ref<T>(value.value) as Ref<T>;
watchEffect(() => {
if (!isEqual(cache.value, value.value)) {
cache.value = value.value;
}
});
return cache;
}
export function getHostMemory(
host: XenApiHost,
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
) {
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
if (hostMetrics !== undefined) {
const total = +hostMetrics.memory_total;
return {
usage: total - +hostMetrics.memory_free,
size: total,
};
export function isHostRunning(host: XenApiHost) {
const store = useHostMetricsStore();
try {
return store.getRecord(host.metrics).live;
} catch (_) {
return undefined;
}
}
export const buildXoObject = <T extends XenApiRecord>(
record: RawXenApiRecord<T>,
export function getHostMemory(host: XenApiHost) {
try {
const metrics = useHostMetricsStore().getRecord(host.metrics);
const total = +metrics.memory_total;
return {
usage: total - +metrics.memory_free,
size: total,
};
} catch (error) {
console.error("getHostMemory function:", error);
return undefined;
}
}
export const buildXoObject = (
record: RawXenApiRecord<XenApiRecord>,
params: { opaqueRef: string }
) => {
return {
...record,
$ref: params.opaqueRef,
} as T;
};
) => ({
...record,
$ref: params.opaqueRef,
});
export function parseRamUsage(
{
@@ -183,15 +181,6 @@ export function parseRamUsage(
export const getFirst = <T>(value: T | T[]): T | undefined =>
Array.isArray(value) ? value[0] : value;
export function requireSubscription<T>(
subscription: T | undefined,
type: RawObjectType
): asserts subscription is T {
if (subscription === undefined) {
throw new Error(`You need to provide a ${type} subscription`);
}
}
export const isOperationsPending = (
obj: XenApiVm,
operations: string[] | string

View File

@@ -1,72 +1,67 @@
import { buildXoObject, parseDateTime } from "@/libs/utils";
import { JSONRPCClient } from "json-rpc-2.0";
import { castArray } from "lodash-es";
import { buildXoObject, parseDateTime } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
const OBJECT_TYPES = {
bond: "Bond",
certificate: "Certificate",
cluster: "Cluster",
cluster_host: "Cluster_host",
dr_task: "DR_task",
feature: "Feature",
gpu_group: "GPU_group",
pbd: "PBD",
pci: "PCI",
pgpu: "PGPU",
pif: "PIF",
pif_metrics: "PIF_metrics",
pusb: "PUSB",
pvs_cache_storage: "PVS_cache_storage",
pvs_proxy: "PVS_proxy",
pvs_server: "PVS_server",
pvs_site: "PVS_site",
sdn_controller: "SDN_controller",
sm: "SM",
sr: "SR",
usb_group: "USB_group",
vbd: "VBD",
vbd_metrics: "VBD_metrics",
vdi: "VDI",
vgpu: "VGPU",
vgpu_type: "VGPU_type",
vif: "VIF",
vif_metrics: "VIF_metrics",
vlan: "VLAN",
vm: "VM",
vmpp: "VMPP",
vmss: "VMSS",
vm_guest_metrics: "VM_guest_metrics",
vm_metrics: "VM_metrics",
vusb: "VUSB",
blob: "blob",
console: "console",
crashdump: "crashdump",
host: "host",
host_cpu: "host_cpu",
host_crashdump: "host_crashdump",
host_metrics: "host_metrics",
host_patch: "host_patch",
network: "network",
network_sriov: "network_sriov",
pool: "pool",
pool_patch: "pool_patch",
pool_update: "pool_update",
role: "role",
secret: "secret",
subject: "subject",
task: "task",
tunnel: "tunnel",
} as const;
export type ObjectType = keyof typeof OBJECT_TYPES;
export type RawObjectType = (typeof OBJECT_TYPES)[ObjectType];
export const getRawObjectType = (type: ObjectType): RawObjectType => {
return OBJECT_TYPES[type];
};
export type RawObjectType =
| "Bond"
| "Certificate"
| "Cluster"
| "Cluster_host"
| "DR_task"
| "Feature"
| "GPU_group"
| "PBD"
| "PCI"
| "PGPU"
| "PIF"
| "PIF_metrics"
| "PUSB"
| "PVS_cache_storage"
| "PVS_proxy"
| "PVS_server"
| "PVS_site"
| "SDN_controller"
| "SM"
| "SR"
| "USB_group"
| "VBD"
| "VBD_metrics"
| "VDI"
| "VGPU"
| "VGPU_type"
| "VIF"
| "VIF_metrics"
| "VLAN"
| "VM"
| "VMPP"
| "VMSS"
| "VM_appliance"
| "VM_guest_metrics"
| "VM_metrics"
| "VUSB"
| "blob"
| "console"
| "crashdump"
| "host"
| "host_cpu"
| "host_crashdump"
| "host_metrics"
| "host_patch"
| "network"
| "network_sriov"
| "pool"
| "pool_patch"
| "pool_update"
| "role"
| "secret"
| "subject"
| "task"
| "tunnel";
export type PowerState = "Running" | "Paused" | "Halted" | "Suspended";
export type ObjectType = Lowercase<RawObjectType>;
export interface XenApiRecord {
$ref: string;
uuid: string;
@@ -88,7 +83,6 @@ export interface XenApiHost extends XenApiRecord {
metrics: string;
resident_VMs: string[];
cpu_info: { cpu_count: string };
software_version: { product_version: string };
}
export interface XenApiSr extends XenApiRecord {
@@ -143,7 +137,7 @@ type WatchCallbackResult = {
class: ObjectType;
operation: "add" | "mod" | "del";
ref: string;
snapshot: RawXenApiRecord<XenApiRecord>;
snapshot: object;
};
type WatchCallback = (results: WatchCallbackResult[]) => void;
@@ -205,8 +199,8 @@ export default class XenApi {
}
}
async disconnect() {
await this.#call("session.logout", [this.#sessionId]);
disconnect() {
this.#call("session.logout", [this.#sessionId]);
this.stopWatch();
this.#sessionId = undefined;
}
@@ -253,15 +247,20 @@ export default class XenApi {
return fetch(url);
}
async loadRecords<T extends XenApiRecord>(type: RawObjectType): Promise<T[]> {
async loadRecords<T extends XenApiRecord>(
type: RawObjectType
): Promise<Map<string, T>> {
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
`${type}.get_all_records`,
[this.sessionId]
);
return Object.entries(result).map(([opaqueRef, record]) =>
buildXoObject(record, { opaqueRef })
);
const entries = Object.entries(result).map<[string, T]>(([key, entry]) => [
key,
buildXoObject(entry, { opaqueRef: key }) as T,
]);
return new Map(entries);
}
async #watch() {
@@ -287,7 +286,7 @@ export default class XenApi {
startWatch() {
this.#watching = true;
return this.#watch();
this.#watch();
}
stopWatch() {
@@ -308,57 +307,77 @@ export default class XenApi {
}
get vm() {
type VmRefs = XenApiVm["$ref"] | XenApiVm["$ref"][];
type VmRefsWithPowerState = Record<
XenApiVm["$ref"],
XenApiVm["power_state"]
>;
type VmsRef =
| {
vmRef: XenApiVm["$ref"];
vmsRef?: undefined;
}
| {
vmRef?: undefined;
vmsRef: XenApiVm["$ref"][];
};
return {
start: (vmRefs: VmRefs) =>
Promise.all(
castArray(vmRefs).map((vmRef) =>
this._call("VM.start", [vmRef, false, false])
)
),
startOn: (vmRefs: VmRefs, hostRef: XenApiHost["$ref"]) =>
Promise.all(
castArray(vmRefs).map((vmRef) =>
this._call("VM.start_on", [vmRef, hostRef, false, false])
)
),
pause: (vmRefs: VmRefs) =>
Promise.all(
castArray(vmRefs).map((vmRef) => this._call("VM.pause", [vmRef]))
),
suspend: (vmRefs: VmRefs) => {
start: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
castArray(vmRefs).map((vmRef) => this._call("VM.suspend", [vmRef]))
_vmsRef.map((vmRef) => this._call("VM.start", [vmRef, false, false]))
);
},
resume: (vmRefsWithPowerState: VmRefsWithPowerState) => {
const vmRefs = Object.keys(vmRefsWithPowerState);
startOn: ({ vmRef, vmsRef, hostRef }: VmsRef & { hostRef: string }) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
vmRefs.map((vmRef) => {
if (vmRefsWithPowerState[vmRef] === "Suspended") {
return this._call("VM.resume", [vmRef, false, false]);
}
return this._call("VM.unpause", [vmRef]);
_vmsRef.map((vmRef) =>
this._call("VM.start_on", [vmRef, hostRef, false, false])
)
);
},
pause: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.pause", [vmRef]))
);
},
suspend: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.suspend", [vmRef]))
);
},
resume: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
const vmStore = useVmStore();
return Promise.all(
_vmsRef.map((ref) => {
const isSuspended =
vmStore.getRecord(ref).power_state === "Suspended";
return this._call(
`VM.${isSuspended ? "resume" : "unpause"}`,
isSuspended ? [ref, false, false] : [ref]
);
})
);
},
reboot: (vmRefs: VmRefs, force = false) => {
reboot: ({
vmRef,
vmsRef,
force = false,
}: VmsRef & { force?: boolean }) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
castArray(vmRefs).map((vmRef) =>
_vmsRef.map((vmRef) =>
this._call(`VM.${force ? "hard" : "clean"}_reboot`, [vmRef])
)
);
},
shutdown: (vmRefs: VmRefs, force = false) => {
shutdown: ({
vmRef,
vmsRef,
force = false,
}: VmsRef & { force?: boolean }) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
castArray(vmRefs).map((vmRef) =>
_vmsRef.map((vmRef) =>
this._call(`VM.${force ? "hard" : "clean"}_shutdown`, [vmRef])
)
);

View File

@@ -1,5 +1,4 @@
{
"about": "About",
"add": "Add",
"add-filter": "Add filter",
"add-or": "+OR",
@@ -17,7 +16,6 @@
"coming-soon": "Coming soon!",
"community": "Community",
"community-name": "{name} community",
"console": "Console",
"copy": "Copy",
"cpu-provisioning": "CPU provisioning",
"cpu-usage": "CPU usage",
@@ -26,12 +24,10 @@
"descending": "descending",
"description": "Description",
"display": "Display",
"documentation": "Documentation",
"documentation-name": "{name} documentation",
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"edit-config": "Edit config",
"error-no-data": "Error, can't collect data.",
"error-occured": "An error has occurred",
"error-occured": "An error has occurred",
"export": "Export",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
@@ -112,8 +108,6 @@
"status": "Status",
"storage": "Storage",
"storage-usage": "Storage usage",
"support": "Support",
"support-name": "{name} pro support",
"suspend": "Suspend",
"switch-theme": "Switch theme",
"system": "System",

View File

@@ -1,5 +1,4 @@
{
"about": "À propos",
"add": "Ajouter",
"add-filter": "Ajouter un filtre",
"add-or": "+OU",
@@ -17,7 +16,6 @@
"coming-soon": "Bientôt disponible !",
"community": "Communauté",
"community-name": "Communauté {name}",
"console": "Console",
"copy": "Copier",
"cpu-provisioning": "Provisionnement CPU",
"cpu-usage": "Utilisation CPU",
@@ -26,8 +24,6 @@
"descending": "descendant",
"description": "Description",
"display": "Affichage",
"documentation": "Documentation",
"documentation-name": "Documentation {name}",
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
"edit-config": "Modifier config",
"error-no-data": "Erreur, impossible de collecter les données.",
@@ -112,8 +108,6 @@
"status": "Statut",
"storage": "Stockage",
"storage-usage": "Utilisation du stockage",
"support": "Support",
"support-name": "Support pro {name}",
"suspend": "Suspendre",
"switch-theme": "Changer de thème",
"system": "Système",

View File

@@ -1,11 +1,12 @@
import pool from "@/router/pool";
import vm from "@/router/vm";
import HomeView from "@/views/HomeView.vue";
import HostDashboardView from "@/views/host/HostDashboardView.vue";
import HostRootView from "@/views/host/HostRootView.vue";
import PageNotFoundView from "@/views/PageNotFoundView.vue";
import SettingsView from "@/views/settings/SettingsView.vue";
import StoryView from "@/views/StoryView.vue";
import VmConsoleView from "@/views/vm/VmConsoleView.vue";
import VmRootView from "@/views/vm/VmRootView.vue";
import storiesRoutes from "virtual:stories";
import { createRouter, createWebHashHistory } from "vue-router";
@@ -30,7 +31,6 @@ const router = createRouter({
component: SettingsView,
},
pool,
vm,
{
path: "/host/:uuid",
component: HostRootView,
@@ -42,6 +42,17 @@ const router = createRouter({
},
],
},
{
path: "/vm/:uuid",
component: VmRootView,
children: [
{
path: "console",
name: "vm.console",
component: VmConsoleView,
},
],
},
{
path: "/:pathMatch(.*)*",
name: "notFound",

View File

@@ -1,47 +0,0 @@
export default {
path: "/vm/:uuid",
component: () => import("@/views/vm/VmRootView.vue"),
redirect: { name: "vm.console" },
children: [
{
path: "dashboard",
name: "vm.dashboard",
component: () => import("@/views/vm/VmDashboardView.vue"),
},
{
path: "console",
name: "vm.console",
component: () => import("@/views/vm/VmConsoleView.vue"),
},
{
path: "alarms",
name: "vm.alarms",
component: () => import("@/views/vm/VmAlarmsView.vue"),
},
{
path: "stats",
name: "vm.stats",
component: () => import("@/views/vm/VmStatsView.vue"),
},
{
path: "system",
name: "vm.system",
component: () => import("@/views/vm/VmSystemView.vue"),
},
{
path: "network",
name: "vm.network",
component: () => import("@/views/vm/VmNetworkView.vue"),
},
{
path: "storage",
name: "vm.storage",
component: () => import("@/views/vm/VmStorageView.vue"),
},
{
path: "tasks",
name: "vm.tasks",
component: () => import("@/views/vm/VmTasksView.vue"),
},
],
};

View File

@@ -1,6 +1,7 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
import type { XenApiConsole } from "@/libs/xen-api";
import { createRecordContext } from "@/stores/index";
export const useConsoleStore = defineStore("console", () =>
useXapiCollectionStore().get("console")
createRecordContext<XenApiConsole>("console")
);

Some files were not shown because too many files have changed in this diff Show More