Compare commits
1 Commits
persian-lo
...
feat_null_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7baa5ac804 |
@@ -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',
|
||||
|
||||
@@ -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, [] ]
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
@@ -1,65 +0,0 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/diff
|
||||
|
||||
[](https://npmjs.org/package/@vates/diff)  [](https://bundlephobia.com/result?p=@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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
@@ -1,75 +0,0 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/stream-reader
|
||||
|
||||
[](https://npmjs.org/package/@vates/stream-reader)  [](https://bundlephobia.com/result?p=@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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)())
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
14
@xen-orchestra/fs/src/null.js
Normal file
14
@xen-orchestra/fs/src/null.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<StoryParamsTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Setting</th>
|
||||
<th>Prop</th>
|
||||
<th><!--Widget--></th>
|
||||
<th>Help</th>
|
||||
</tr>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ defineProps<{
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-badge {
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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' }]
|
||||
```
|
||||
|
||||
@@ -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] *
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user