Compare commits
136 Commits
lite/human
...
feat_test_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f4e15cc6 | ||
|
|
769e27e2cb | ||
|
|
8ec5461338 | ||
|
|
4a2843cb67 | ||
|
|
a0e69a79ab | ||
|
|
3da94f18df | ||
|
|
17cb59b898 | ||
|
|
315e5c9289 | ||
|
|
01ba10fedb | ||
|
|
13e7594560 | ||
|
|
f9ac2ac84d | ||
|
|
09cfac1111 | ||
|
|
008f7a30fd | ||
|
|
ff65dbcba7 | ||
|
|
264a0d1678 | ||
|
|
7dcaf454ed | ||
|
|
17b2756291 | ||
|
|
57e48b5d34 | ||
|
|
57ed984e5a | ||
|
|
100122f388 | ||
|
|
12d4b3396e | ||
|
|
ab35c710cb | ||
|
|
4bd5b38aeb | ||
|
|
836db1b807 | ||
|
|
73d88cc5f1 | ||
|
|
3def66d968 | ||
|
|
3f73138fc3 | ||
|
|
bfe621a21d | ||
|
|
32fa792eeb | ||
|
|
a833050fc2 | ||
|
|
e7e6294bc3 | ||
|
|
7c71884e27 | ||
|
|
3e822044f2 | ||
|
|
d457f5fca4 | ||
|
|
1837e01719 | ||
|
|
f17f5abf0f | ||
|
|
82c229c755 | ||
|
|
c7e3ba3184 | ||
|
|
470c9bb6c8 | ||
|
|
bb3ab20b2a | ||
|
|
90ce1c4d1e | ||
|
|
5c436f3870 | ||
|
|
159339625d | ||
|
|
87e6f7fded | ||
|
|
fd2c7c2fc3 | ||
|
|
7fc76c1df4 | ||
|
|
f2758d036d | ||
|
|
ac670da793 | ||
|
|
c0465eb4d9 | ||
|
|
cea55b03e5 | ||
|
|
d78d802066 | ||
|
|
a562c74492 | ||
|
|
d1f2e0a84b | ||
|
|
49e2d128ad | ||
|
|
f587798fb0 | ||
|
|
3430ee743b | ||
|
|
83299587b0 | ||
|
|
7c0ecf9b06 | ||
|
|
abfd84d32c | ||
|
|
0583a978be | ||
|
|
75989cf92d | ||
|
|
f1cc284b6f | ||
|
|
0444cf0b3b | ||
|
|
226f9ad964 | ||
|
|
a956cb2ac9 | ||
|
|
76a91cc5e9 | ||
|
|
f012d126b9 | ||
|
|
bae0b52893 | ||
|
|
a24512cea9 | ||
|
|
84b75e8a58 | ||
|
|
6e25b7a83a | ||
|
|
136718df7e | ||
|
|
d48ef1f810 | ||
|
|
9e60c53750 | ||
|
|
f3c5e817a3 | ||
|
|
60f6e54da1 | ||
|
|
f5a59caca2 | ||
|
|
6ea671a434 | ||
|
|
036f3f6bd0 | ||
|
|
12552a1391 | ||
|
|
e9b658b60d | ||
|
|
15f69a19f5 | ||
|
|
54d885fa9c | ||
|
|
11cc299940 | ||
|
|
091b0a3ef3 | ||
|
|
87874a4b81 | ||
|
|
86aaa50946 | ||
|
|
68b2c287eb | ||
|
|
61f1316c42 | ||
|
|
afadc8f95a | ||
|
|
955ef6806c | ||
|
|
4d55c5ae48 | ||
|
|
5c6ae1912b | ||
|
|
083483645e | ||
|
|
c077e9a699 | ||
|
|
280b60808f | ||
|
|
eb9608b893 | ||
|
|
a29f3d67ea | ||
|
|
6b150dc8a8 | ||
|
|
8f55884602 | ||
|
|
2fdba2eb0f | ||
|
|
7e4bd30f04 | ||
|
|
eb8f098aaf | ||
|
|
5237fdd387 | ||
|
|
8a07b7a3db | ||
|
|
a41037833c | ||
|
|
6a780d94a3 | ||
|
|
506ef0b44f | ||
|
|
a4d1d41b6a | ||
|
|
4e9477f34a | ||
|
|
43b6285437 | ||
|
|
c26a7a3e51 | ||
|
|
93eb42785d | ||
|
|
02bb622e92 | ||
|
|
b873c147a6 | ||
|
|
5e7fb7a881 | ||
|
|
97790313eb | ||
|
|
954b29cb61 | ||
|
|
dc6a13962f | ||
|
|
23da202790 | ||
|
|
f237101b4a | ||
|
|
8a99326a76 | ||
|
|
8c95974e65 | ||
|
|
3f7454efad | ||
|
|
e5c890e29b | ||
|
|
53e0f17c55 | ||
|
|
34f6be868e | ||
|
|
c84b899276 | ||
|
|
266a26fa31 | ||
|
|
bbf92be652 | ||
|
|
e19c7b949d | ||
|
|
5ce6f1fe4d | ||
|
|
9c36520c79 | ||
|
|
a85a8ea208 | ||
|
|
c2e0c97d94 | ||
|
|
a5447fda3c |
@@ -28,7 +28,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.{spec,test}.{,c,m}js'],
|
||||
files: ['*.{integ,spec,test}.{,c,m}js'],
|
||||
rules: {
|
||||
'n/no-unpublished-require': 'off',
|
||||
'n/no-unpublished-import': 'off',
|
||||
|
||||
32
@vates/diff/.USAGE.md
Normal file
32
@vates/diff/.USAGE.md
Normal file
@@ -0,0 +1,32 @@
|
||||
```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
@vates/diff/.npmignore
Symbolic link
1
@vates/diff/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
65
@vates/diff/README.md
Normal file
65
@vates/diff/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
<!-- 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)
|
||||
37
@vates/diff/index.js
Normal file
37
@vates/diff/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
'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
|
||||
}
|
||||
51
@vates/diff/index.test.js
Normal file
51
@vates/diff/index.test.js
Normal file
@@ -0,0 +1,51 @@
|
||||
'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'],
|
||||
])
|
||||
})
|
||||
36
@vates/diff/package.json
Normal file
36
@vates/diff/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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.2.1"
|
||||
"vhd-lib": "^4.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -18,8 +18,11 @@ const {
|
||||
OPTS_MAGIC,
|
||||
NBD_CMD_DISC,
|
||||
} = require('./constants.js')
|
||||
const { fromCallback } = require('promise-toolbox')
|
||||
const { fromCallback, pRetry, pDelay, pTimeout } = 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
|
||||
|
||||
@@ -32,18 +35,34 @@ 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
|
||||
|
||||
constructor({ address, port = NBD_DEFAULT_PORT, exportname, cert }) {
|
||||
#reconnectingPromise
|
||||
constructor(
|
||||
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
|
||||
{ connectTimeout = 6e4, waitBeforeReconnect = 1e3, readAhead = 10, readBlockRetries = 5, reconnectRetry = 5 } = {}
|
||||
) {
|
||||
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() {
|
||||
@@ -78,24 +97,55 @@ module.exports = class NbdClient {
|
||||
})
|
||||
}
|
||||
|
||||
async connect() {
|
||||
// first we connect to the serve without tls, and then we upgrade the connection
|
||||
async #connect() {
|
||||
// first we connect to the server 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
|
||||
@@ -173,7 +223,6 @@ module.exports = class NbdClient {
|
||||
this.#commandQueryBacklog.forEach(({ reject }) => {
|
||||
reject(error)
|
||||
})
|
||||
await this.disconnect()
|
||||
}
|
||||
|
||||
async #readBlockResponse() {
|
||||
@@ -181,7 +230,6 @@ module.exports = class NbdClient {
|
||||
if (this.#waitingForResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const magic = await this.#readInt32()
|
||||
@@ -206,7 +254,8 @@ module.exports = class NbdClient {
|
||||
query.resolve(data)
|
||||
this.#waitingForResponse = false
|
||||
if (this.#commandQueryBacklog.size > 0) {
|
||||
await this.#readBlockResponse()
|
||||
// it doesn't throw directly but will throw all relevant promise on failure
|
||||
this.#readBlockResponse()
|
||||
}
|
||||
} catch (error) {
|
||||
// reject all the promises
|
||||
@@ -217,6 +266,11 @@ 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++
|
||||
|
||||
@@ -231,19 +285,67 @@ 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,
|
||||
reject: decoratedReject,
|
||||
})
|
||||
// really send the command to the server
|
||||
this.#write(buffer).catch(reject)
|
||||
this.#write(buffer).catch(decoratedReject)
|
||||
|
||||
// #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,16 +13,17 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.0",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^1.2.7"
|
||||
"xen-api": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.3.0",
|
||||
@@ -30,6 +31,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test-integration": "tap *.spec.js"
|
||||
"test-integration": "tap --lines 70 --functions 36 --branches 54 --statements 69 *.integ.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,25 @@ 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,6 +43,28 @@ 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,18 +1,36 @@
|
||||
'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.
|
||||
* @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.
|
||||
* @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.
|
||||
*/
|
||||
const readChunk = (stream, size) =>
|
||||
stream.closed || stream.readableEnded
|
||||
stream.errored != null
|
||||
? Promise.reject(stream.errored)
|
||||
: 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()
|
||||
@@ -43,9 +61,17 @@ 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.
|
||||
* @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.
|
||||
* @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.
|
||||
*/
|
||||
exports.readChunkStrict = async function readChunkStrict(stream, size) {
|
||||
const chunk = await readChunk(stream, size)
|
||||
@@ -54,7 +80,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')
|
||||
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
|
||||
Object.defineProperties(error, {
|
||||
chunk: {
|
||||
value: chunk,
|
||||
@@ -65,3 +91,69 @@ 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,12 +5,58 @@ const assert = require('node:assert').strict
|
||||
|
||||
const { Readable } = require('stream')
|
||||
|
||||
const { readChunk, readChunkStrict } = require('./')
|
||||
const { readChunk, readChunkStrict, skip, skipStrict } = 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)
|
||||
})
|
||||
@@ -38,10 +84,6 @@ 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', () => {
|
||||
@@ -52,14 +94,6 @@ 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([])))
|
||||
@@ -71,7 +105,43 @@ 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')
|
||||
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 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.0.1",
|
||||
"version": "1.1.1",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
|
||||
42
@vates/stream-reader/.USAGE.md
Normal file
42
@vates/stream-reader/.USAGE.md
Normal file
@@ -0,0 +1,42 @@
|
||||
```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
@vates/stream-reader/.npmignore
Symbolic link
1
@vates/stream-reader/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
75
@vates/stream-reader/README.md
Normal file
75
@vates/stream-reader/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
<!-- 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)
|
||||
123
@vates/stream-reader/index.js
Normal file
123
@vates/stream-reader/index.js
Normal file
@@ -0,0 +1,123 @@
|
||||
'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
|
||||
141
@vates/stream-reader/index.test.js
Normal file
141
@vates/stream-reader/index.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
'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)
|
||||
})
|
||||
})
|
||||
39
@vates/stream-reader/package.json
Normal file
39
@vates/stream-reader/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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,7 +2,12 @@
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
const task = new Task({
|
||||
name: 'my 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',
|
||||
},
|
||||
|
||||
// if defined, a new detached task is created
|
||||
//
|
||||
@@ -25,8 +30,19 @@ 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
|
||||
await task.abort()
|
||||
|
||||
// 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)
|
||||
|
||||
// if fn rejects, the task will be marked as failed
|
||||
const result = await task.runInside(fn)
|
||||
@@ -34,7 +50,11 @@ 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
|
||||
|
||||
@@ -52,3 +72,43 @@ 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,7 +18,12 @@ npm install --save @vates/task
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
const task = new Task({
|
||||
name: 'my 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',
|
||||
},
|
||||
|
||||
// if defined, a new detached task is created
|
||||
//
|
||||
@@ -41,8 +46,19 @@ 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
|
||||
await task.abort()
|
||||
|
||||
// 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)
|
||||
|
||||
// if fn rejects, the task will be marked as failed
|
||||
const result = await task.runInside(fn)
|
||||
@@ -50,7 +66,11 @@ 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
|
||||
|
||||
@@ -69,6 +89,46 @@ 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
|
||||
|
||||
77
@vates/task/combineEvents.js
Normal file
77
@vates/task/combineEvents.js
Normal file
@@ -0,0 +1,77 @@
|
||||
'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)
|
||||
}
|
||||
}
|
||||
67
@vates/task/combineEvents.test.js
Normal file
67
@vates/task/combineEvents.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
'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,13 +11,15 @@ 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, ABORTING, FAILURE, PENDING, 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())
|
||||
|
||||
const asyncStorage = new AsyncLocalStorage()
|
||||
const getTask = () => asyncStorage.getStore()
|
||||
|
||||
exports.Task = class Task {
|
||||
@@ -66,7 +68,6 @@ exports.Task = class Task {
|
||||
|
||||
#abortController = new AbortController()
|
||||
#onProgress
|
||||
#parent
|
||||
|
||||
get id() {
|
||||
return (this.id = Math.random().toString(36).slice(2))
|
||||
@@ -82,16 +83,14 @@ exports.Task = class Task {
|
||||
return this.#status
|
||||
}
|
||||
|
||||
constructor({ name, onProgress }) {
|
||||
this.#startData = { name }
|
||||
constructor({ data = {}, onProgress } = {}) {
|
||||
this.#startData = data
|
||||
|
||||
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)
|
||||
@@ -106,8 +105,12 @@ exports.Task = class Task {
|
||||
|
||||
const { signal } = this.#abortController
|
||||
signal.addEventListener('abort', () => {
|
||||
if (this.status === PENDING) {
|
||||
this.#status = this.#running ? ABORTING : ABORTED
|
||||
if (this.status === PENDING && !this.#running) {
|
||||
this.#maybeStart()
|
||||
|
||||
const status = ABORTED
|
||||
this.#status = status
|
||||
this.#emit('end', { result: signal.reason, status })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -123,14 +126,12 @@ exports.Task = class Task {
|
||||
this.#onProgress(data)
|
||||
}
|
||||
|
||||
#handleMaybeAbortion(result) {
|
||||
if (this.status === ABORTING) {
|
||||
this.#status = ABORTED
|
||||
this.#emit('end', { status: ABORTED, result })
|
||||
return true
|
||||
#maybeStart() {
|
||||
const startData = this.#startData
|
||||
if (startData !== undefined) {
|
||||
this.#startData = undefined
|
||||
this.#emit('start', startData)
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async run(fn) {
|
||||
@@ -148,22 +149,19 @@ exports.Task = class Task {
|
||||
assert.equal(this.#running, false)
|
||||
this.#running = true
|
||||
|
||||
const startData = this.#startData
|
||||
if (startData !== undefined) {
|
||||
this.#startData = undefined
|
||||
this.#emit('start', startData)
|
||||
}
|
||||
this.#maybeStart()
|
||||
|
||||
try {
|
||||
const result = await asyncStorage.run(this, fn)
|
||||
this.#handleMaybeAbortion(result)
|
||||
this.#running = false
|
||||
return result
|
||||
} catch (result) {
|
||||
if (!this.#handleMaybeAbortion(result)) {
|
||||
this.#status = FAILURE
|
||||
this.#emit('end', { status: FAILURE, 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 })
|
||||
throw result
|
||||
}
|
||||
}
|
||||
|
||||
341
@vates/task/index.test.js
Normal file
341
@vates/task/index.test.js
Normal file
@@ -0,0 +1,341 @@
|
||||
'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,11 +13,19 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.2",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./combineEvents": "./combineEvents.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.32.0",
|
||||
"@xen-orchestra/fs": "^3.3.2",
|
||||
"@xen-orchestra/backups": "^0.36.1",
|
||||
"@xen-orchestra/fs": "^3.3.4",
|
||||
"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.2",
|
||||
"version": "1.0.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -49,6 +49,7 @@ const DEFAULT_VM_SETTINGS = {
|
||||
timeout: 0,
|
||||
useNbd: false,
|
||||
unconditionalSnapshot: false,
|
||||
validateVhdStreams: false,
|
||||
vmTimeout: 0,
|
||||
}
|
||||
|
||||
@@ -269,24 +270,35 @@ exports.Backup = class Backup {
|
||||
const allSettings = this._job.settings
|
||||
const baseSettings = this._baseSettings
|
||||
|
||||
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 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 { concurrency } = settings
|
||||
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
const { Task } = require('./Task')
|
||||
|
||||
exports.HealthCheckVmBackup = class HealthCheckVmBackup {
|
||||
#xapi
|
||||
#restoredVm
|
||||
#timeout
|
||||
#xapi
|
||||
|
||||
constructor({ restoredVm, xapi }) {
|
||||
constructor({ restoredVm, timeout = 10 * 60 * 1000, xapi }) {
|
||||
this.#restoredVm = restoredVm
|
||||
this.#xapi = xapi
|
||||
this.#timeout = timeout
|
||||
}
|
||||
|
||||
async run() {
|
||||
@@ -23,7 +25,12 @@ 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
|
||||
|
||||
@@ -34,7 +41,7 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
|
||||
false // Skip pre-boot checks?
|
||||
)
|
||||
const started = new Date()
|
||||
const timeout = 10 * 60 * 1000
|
||||
const timeout = this.#timeout
|
||||
const startDuration = started - start
|
||||
|
||||
let remainingTimeout = timeout - startDuration
|
||||
@@ -52,12 +59,52 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
|
||||
remainingTimeout -= running - started
|
||||
|
||||
if (remainingTimeout < 0) {
|
||||
throw new Error(`local xapi did not get Runnig state for VM ${restoredId} after ${timeout / 1000} second`)
|
||||
throw new Error(`local xapi did not get Running 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,14 +10,7 @@ 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,
|
||||
createVhdStreamWithLength,
|
||||
openVhd,
|
||||
VhdAbstract,
|
||||
VhdDirectory,
|
||||
VhdSynthetic,
|
||||
} = require('vhd-lib')
|
||||
const { createVhdDirectoryFromStream, 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')
|
||||
@@ -39,7 +32,6 @@ 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
|
||||
@@ -666,7 +658,7 @@ class RemoteAdapter {
|
||||
return path
|
||||
}
|
||||
|
||||
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency, nbdClient } = {}) {
|
||||
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
|
||||
const handler = this._handler
|
||||
if (this.useVhdDirectory()) {
|
||||
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
|
||||
@@ -677,42 +669,21 @@ class RemoteAdapter {
|
||||
await input.task
|
||||
return validator.apply(this, arguments)
|
||||
},
|
||||
nbdClient,
|
||||
})
|
||||
await VhdAbstract.createAlias(handler, path, dataPath)
|
||||
return size
|
||||
} else {
|
||||
const inputWithSize = await createVhdStreamWithLength(input)
|
||||
return this.outputStream(path, inputWithSize, { checksum, validator, expectedSize: inputWithSize.length })
|
||||
return this.outputStream(path, input, { checksum, validator })
|
||||
}
|
||||
}
|
||||
|
||||
async outputStream(path, input, { checksum = true, validator = noop, expectedSize } = {}) {
|
||||
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
|
||||
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)
|
||||
},
|
||||
})
|
||||
@@ -748,7 +719,7 @@ class RemoteAdapter {
|
||||
|
||||
async readDeltaVmBackup(metadata, ignoredVdis) {
|
||||
const handler = this._handler
|
||||
const { vbds, vhds, vifs, vm } = metadata
|
||||
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
|
||||
const dir = dirname(metadata._filename)
|
||||
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
|
||||
|
||||
@@ -763,7 +734,7 @@ class RemoteAdapter {
|
||||
vdis,
|
||||
version: '1.0.0',
|
||||
vifs,
|
||||
vm,
|
||||
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,7 +746,49 @@ class RemoteAdapter {
|
||||
// _filename is a private field used to compute the backup id
|
||||
//
|
||||
// it's enumerable to make it cacheable
|
||||
return { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ 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')
|
||||
@@ -44,6 +46,8 @@ const forkDeltaExport = deltaExport =>
|
||||
},
|
||||
})
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
class VmBackup {
|
||||
constructor({
|
||||
config,
|
||||
@@ -245,7 +249,17 @@ 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()
|
||||
@@ -253,7 +267,7 @@ class VmBackup {
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.transfer({
|
||||
deltaExport: forkDeltaExport(deltaExport),
|
||||
deltaExport: this._writers.size > 1 ? forkDeltaExport(deltaExport) : deltaExport,
|
||||
sizeContainers,
|
||||
timestamp,
|
||||
}),
|
||||
|
||||
@@ -187,11 +187,11 @@ exports.importDeltaVm = defer(async function importDeltaVm(
|
||||
|
||||
// 0. Create suspend_VDI
|
||||
let suspendVdi
|
||||
if (vmRecord.power_state === 'Suspended') {
|
||||
if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
|
||||
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'),
|
||||
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
|
||||
})
|
||||
} 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 })
|
||||
├─ task.start(data: { type: 'VM', id: string, name_label?: 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, isFull: boolean })
|
||||
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string, name_label?: string, isFull: boolean })
|
||||
│ │ ├─ task.warning(message: string)
|
||||
│ │ ├─ task.start(message: 'transfer')
|
||||
│ │ │ ├─ task.warning(message: string)
|
||||
|
||||
35
@xen-orchestra/backups/docs/healthcheck/example.sh
Normal file
35
@xen-orchestra/backups/docs/healthcheck/example.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/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.32.0",
|
||||
"version": "0.36.1",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
"test-integration": "node--test *.integ.js"
|
||||
},
|
||||
"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.0.1",
|
||||
"@vates/nbd-client": "^1.2.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^3.3.2",
|
||||
"@xen-orchestra/fs": "^3.3.4",
|
||||
"@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.2.1",
|
||||
"vhd-lib": "^4.4.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,7 +52,7 @@
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^2.0.0"
|
||||
"@xen-orchestra/xapi": "^2.2.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -20,9 +20,8 @@ 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 { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
|
||||
class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
@@ -200,41 +199,12 @@ 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,11 +45,13 @@ 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, true)
|
||||
this.cleanup = task.wrapFn(this.cleanup)
|
||||
this.healthCheck = task.wrapFn(this.healthCheck, true)
|
||||
|
||||
return task.run(() => this._prepare())
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 healthcheck'
|
||||
'Metadata file name should be defined before making a health check'
|
||||
)
|
||||
return Task.run(
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^1.2.7"
|
||||
"xen-api": "^1.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "3.3.2",
|
||||
"version": "3.3.4",
|
||||
"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.0.1",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"bind-property-descriptor": "^2.0.0",
|
||||
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
UploadPartCommand,
|
||||
UploadPartCopyCommand,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { Upload } from '@aws-sdk/lib-storage'
|
||||
import { NodeHttpHandler } from '@aws-sdk/node-http-handler'
|
||||
import { getApplyMd5BodyChecksumPlugin } from '@aws-sdk/middleware-apply-body-checksum'
|
||||
import assert from 'assert'
|
||||
import { Agent as HttpAgent } from 'http'
|
||||
import { Agent as HttpsAgent } from 'https'
|
||||
import pRetry from 'promise-toolbox/retry'
|
||||
@@ -24,7 +22,6 @@ import { decorateWith } from '@vates/decorate-with'
|
||||
import { PassThrough, pipeline } from 'stream'
|
||||
import { parse } from 'xo-remote-parser'
|
||||
import copyStreamToBuffer from './_copyStreamToBuffer.js'
|
||||
import createBufferFromStream from './_createBufferFromStream.js'
|
||||
import guessAwsRegion from './_guessAwsRegion.js'
|
||||
import RemoteHandlerAbstract from './abstract'
|
||||
import { basename, join, split } from './path'
|
||||
@@ -33,12 +30,7 @@ import { asyncEach } from '@vates/async-each'
|
||||
// endpoints https://docs.aws.amazon.com/general/latest/gr/s3.html
|
||||
|
||||
// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
|
||||
const MIN_PART_SIZE = 1024 * 1024 * 5 // 5MB
|
||||
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
|
||||
const MAX_PARTS_COUNT = 10000
|
||||
const MAX_OBJECT_SIZE = 1024 * 1024 * 1024 * 1024 * 5 // 5TB
|
||||
const IDEAL_FRAGMENT_SIZE = Math.ceil(MAX_OBJECT_SIZE / MAX_PARTS_COUNT) // the smallest fragment size that still allows a 5TB upload in 10000 fragments, about 524MB
|
||||
|
||||
const { warn } = createLogger('xo:fs:s3')
|
||||
|
||||
export default class S3Handler extends RemoteHandlerAbstract {
|
||||
@@ -198,8 +190,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
|
||||
const upload = new Upload({
|
||||
client: this._s3,
|
||||
queueSize: 1,
|
||||
partSize: IDEAL_FRAGMENT_SIZE,
|
||||
params: {
|
||||
...this._createParams(path),
|
||||
Body,
|
||||
@@ -396,138 +386,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
} while (NextContinuationToken !== undefined)
|
||||
}
|
||||
|
||||
async _write(file, buffer, position) {
|
||||
if (typeof file !== 'string') {
|
||||
file = file.fd
|
||||
}
|
||||
const uploadParams = this._createParams(file)
|
||||
let fileSize
|
||||
try {
|
||||
fileSize = +(await this._s3.send(new HeadObjectCommand(uploadParams))).ContentLength
|
||||
} catch (e) {
|
||||
if (e.name === 'NotFound') {
|
||||
fileSize = 0
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
if (fileSize < MIN_PART_SIZE) {
|
||||
const resultBuffer = Buffer.alloc(Math.max(fileSize, position + buffer.length))
|
||||
if (fileSize !== 0) {
|
||||
const result = await this._s3.send(new GetObjectCommand(uploadParams))
|
||||
await copyStreamToBuffer(result.Body, resultBuffer)
|
||||
} else {
|
||||
Buffer.alloc(0).copy(resultBuffer)
|
||||
}
|
||||
buffer.copy(resultBuffer, position)
|
||||
await this._s3.send(
|
||||
new PutObjectCommand({
|
||||
...uploadParams,
|
||||
Body: resultBuffer,
|
||||
})
|
||||
)
|
||||
return { buffer, bytesWritten: buffer.length }
|
||||
} else {
|
||||
// using this trick: https://stackoverflow.com/a/38089437/72637
|
||||
// multipart fragments have a minimum size of 5Mo and a max of 5Go unless they are last
|
||||
// splitting the file in 3 parts: [prefix, edit, suffix]
|
||||
// if `prefix` is bigger than 5Mo, it will be sourced from uploadPartCopy()
|
||||
// otherwise otherwise it will be downloaded, concatenated to `edit`
|
||||
// `edit` will always be an upload part
|
||||
// `suffix` will always be sourced from uploadPartCopy()
|
||||
// Then everything will be sliced in 5Gb parts before getting uploaded
|
||||
const multipartParams = await this._s3.send(new CreateMultipartUploadCommand(uploadParams))
|
||||
const copyMultipartParams = {
|
||||
...multipartParams,
|
||||
CopySource: this._makeCopySource(file),
|
||||
}
|
||||
try {
|
||||
const parts = []
|
||||
const prefixSize = position
|
||||
let suffixOffset = prefixSize + buffer.length
|
||||
let suffixSize = Math.max(0, fileSize - suffixOffset)
|
||||
let hasSuffix = suffixSize > 0
|
||||
let editBuffer = buffer
|
||||
let editBufferOffset = position
|
||||
let partNumber = 1
|
||||
let prefixPosition = 0
|
||||
// use floor() so that last fragment is handled in the if bellow
|
||||
let fragmentsCount = Math.floor(prefixSize / MAX_PART_SIZE)
|
||||
const prefixFragmentSize = MAX_PART_SIZE
|
||||
let prefixLastFragmentSize = prefixSize - prefixFragmentSize * fragmentsCount
|
||||
if (prefixLastFragmentSize >= MIN_PART_SIZE) {
|
||||
// the last fragment of the prefix is smaller than MAX_PART_SIZE, but bigger than the minimum
|
||||
// so we can copy it too
|
||||
fragmentsCount++
|
||||
prefixLastFragmentSize = 0
|
||||
}
|
||||
for (let i = 0; i < fragmentsCount; i++) {
|
||||
const fragmentEnd = Math.min(prefixPosition + prefixFragmentSize, prefixSize)
|
||||
assert.strictEqual(fragmentEnd - prefixPosition <= MAX_PART_SIZE, true)
|
||||
const range = `bytes=${prefixPosition}-${fragmentEnd - 1}`
|
||||
const copyPrefixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: range }
|
||||
const part = await this._s3.send(new UploadPartCopyCommand(copyPrefixParams))
|
||||
parts.push({ ETag: part.CopyPartResult.ETag, PartNumber: copyPrefixParams.PartNumber })
|
||||
prefixPosition += prefixFragmentSize
|
||||
}
|
||||
if (prefixLastFragmentSize) {
|
||||
// grab everything from the prefix that was too small to be copied, download and merge to the edit buffer.
|
||||
const downloadParams = { ...uploadParams, Range: `bytes=${prefixPosition}-${prefixSize - 1}` }
|
||||
let prefixBuffer
|
||||
if (prefixSize > 0) {
|
||||
const result = await this._s3.send(new GetObjectCommand(downloadParams))
|
||||
prefixBuffer = await createBufferFromStream(result.Body)
|
||||
} else {
|
||||
prefixBuffer = Buffer.alloc(0)
|
||||
}
|
||||
editBuffer = Buffer.concat([prefixBuffer, buffer])
|
||||
editBufferOffset -= prefixLastFragmentSize
|
||||
}
|
||||
if (hasSuffix && editBuffer.length < MIN_PART_SIZE) {
|
||||
// the edit fragment is too short and is not the last fragment
|
||||
// let's steal from the suffix fragment to reach the minimum size
|
||||
// the suffix might be too short and itself entirely absorbed in the edit fragment, making it the last one.
|
||||
const complementSize = Math.min(MIN_PART_SIZE - editBuffer.length, suffixSize)
|
||||
const complementOffset = editBufferOffset + editBuffer.length
|
||||
suffixOffset += complementSize
|
||||
suffixSize -= complementSize
|
||||
hasSuffix = suffixSize > 0
|
||||
const prefixRange = `bytes=${complementOffset}-${complementOffset + complementSize - 1}`
|
||||
const downloadParams = { ...uploadParams, Range: prefixRange }
|
||||
const result = await this._s3.send(new GetObjectCommand(downloadParams))
|
||||
const complementBuffer = await createBufferFromStream(result.Body)
|
||||
editBuffer = Buffer.concat([editBuffer, complementBuffer])
|
||||
}
|
||||
const editParams = { ...multipartParams, Body: editBuffer, PartNumber: partNumber++ }
|
||||
const editPart = await this._s3.send(new UploadPartCommand(editParams))
|
||||
parts.push({ ETag: editPart.ETag, PartNumber: editParams.PartNumber })
|
||||
if (hasSuffix) {
|
||||
// use ceil because the last fragment can be arbitrarily small.
|
||||
const suffixFragments = Math.ceil(suffixSize / MAX_PART_SIZE)
|
||||
let suffixFragmentOffset = suffixOffset
|
||||
for (let i = 0; i < suffixFragments; i++) {
|
||||
const fragmentEnd = suffixFragmentOffset + MAX_PART_SIZE
|
||||
assert.strictEqual(Math.min(fileSize, fragmentEnd) - suffixFragmentOffset <= MAX_PART_SIZE, true)
|
||||
const suffixRange = `bytes=${suffixFragmentOffset}-${Math.min(fileSize, fragmentEnd) - 1}`
|
||||
const copySuffixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: suffixRange }
|
||||
const suffixPart = (await this._s3.send(new UploadPartCopyCommand(copySuffixParams))).CopyPartResult
|
||||
parts.push({ ETag: suffixPart.ETag, PartNumber: copySuffixParams.PartNumber })
|
||||
suffixFragmentOffset = fragmentEnd
|
||||
}
|
||||
}
|
||||
await this._s3.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
...multipartParams,
|
||||
MultipartUpload: { Parts: parts },
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
await this._s3.send(new AbortMultipartUploadCommand(multipartParams))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _openFile(path, flags) {
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@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",
|
||||
@@ -25,13 +26,14 @@
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"echarts": "^5.3.3",
|
||||
"highlight.js": "^11.6.0",
|
||||
"human-format": "^1.0.0",
|
||||
"human-format": "^1.1.0",
|
||||
"iterable-backoff": "^0.1.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",
|
||||
"markdown-it": "^13.0.1",
|
||||
"marked": "^4.2.12",
|
||||
"pinia": "^2.0.14",
|
||||
"placement.js": "^1.0.0-beta.5",
|
||||
"vue": "^3.2.37",
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
<template>
|
||||
<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>
|
||||
<UnreachableHostsModal />
|
||||
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
|
||||
<AppLogin />
|
||||
</div>
|
||||
@@ -41,21 +21,14 @@ 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 UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
|
||||
import { useChartTheme } from "@/composables/chart-theme.composable";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { usePoolStore } from "@/stores/pool.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 { difference } from "lodash";
|
||||
import { computed, ref, watch, watchEffect } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const unreachableHostsUrls = ref<URL[]>([]);
|
||||
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
|
||||
import { computed } from "vue";
|
||||
|
||||
let link = document.querySelector(
|
||||
"link[rel~='icon']"
|
||||
@@ -70,7 +43,7 @@ link.href = favicon;
|
||||
document.title = "XO Lite";
|
||||
|
||||
const xenApiStore = useXenApiStore();
|
||||
const hostStore = useHostStore();
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
useChartTheme();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
@@ -92,34 +65,14 @@ if (import.meta.env.DEV) {
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
});
|
||||
whenever(
|
||||
() => pool.value?.$ref,
|
||||
async (poolRef) => {
|
||||
const xenApi = xenApiStore.getXapi();
|
||||
await xenApi.injectWatchEvent(poolRef);
|
||||
await xenApi.startWatch();
|
||||
}
|
||||
);
|
||||
|
||||
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
|
||||
const reload = () => window.location.reload();
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
@@ -38,6 +38,11 @@ code * {
|
||||
color: var(--color-extra-blue-d20);
|
||||
}
|
||||
|
||||
.link:active {
|
||||
.link:active,
|
||||
.link.router-link-active {
|
||||
color: var(--color-extra-blue-d40);
|
||||
}
|
||||
|
||||
.link.router-link-active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -12,13 +12,23 @@ 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,75 +1,85 @@
|
||||
: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 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);
|
||||
--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);
|
||||
}
|
||||
|
||||
: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 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);
|
||||
--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);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type Ref, computed, ref } from "vue";
|
||||
import markdown from "@/libs/markdown";
|
||||
import { useEventListener } from "@vueuse/core";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { markdown } from "@/libs/markdown";
|
||||
import { computed, type Ref, ref } from "vue";
|
||||
|
||||
const rootElement = ref() as Ref<HTMLElement>;
|
||||
|
||||
@@ -14,7 +14,7 @@ const props = defineProps<{
|
||||
content: string;
|
||||
}>();
|
||||
|
||||
const html = computed(() => markdown.render(props.content ?? ""));
|
||||
const html = computed(() => markdown.parse(props.content ?? ""));
|
||||
|
||||
useEventListener(
|
||||
rootElement,
|
||||
@@ -96,6 +96,7 @@ 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">{{ content }}</span>
|
||||
<span class="label">{{ options.content }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { isEmpty, isFunction, isString } from "lodash-es";
|
||||
import type { TooltipOptions } from "@/stores/tooltip.store";
|
||||
import { 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,29 +18,13 @@ const props = defineProps<{
|
||||
|
||||
const tooltipElement = ref<HTMLElement>();
|
||||
|
||||
const content = computed(() =>
|
||||
isString(props.options) ? props.options : props.options.content
|
||||
const isDisabled = computed(() =>
|
||||
isString(props.options.content)
|
||||
? props.options.content.trim() === ""
|
||||
: props.options.content === false
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
const placement = computed(() => props.options.placement ?? "top");
|
||||
|
||||
watchEffect(() => {
|
||||
if (tooltipElement.value) {
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen" :icon="faFilter" @submit.prevent="handleSubmit">
|
||||
<UiModal
|
||||
v-if="isOpen"
|
||||
:icon="faFilter"
|
||||
@submit.prevent="handleSubmit"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<div class="rows">
|
||||
<CollectionFilterRow
|
||||
v-for="(newFilter, index) in newFilters"
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen" :icon="faSort" @submit.prevent="handleSubmit">
|
||||
<UiModal
|
||||
v-if="isOpen"
|
||||
:icon="faSort"
|
||||
@submit.prevent="handleSubmit"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<div class="form-widgets">
|
||||
<FormWidget :label="$t('sort-by')">
|
||||
<select v-model="newSortProperty">
|
||||
|
||||
@@ -18,24 +18,27 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
<template>
|
||||
<div class="wrapper-spinner" v-if="store.isLoading">
|
||||
<div v-if="!isReady" class="wrapper-spinner">
|
||||
<UiSpinner class="spinner" />
|
||||
</div>
|
||||
<ObjectNotFoundView :id="id" v-else-if="isRecordNotFound" />
|
||||
<ObjectNotFoundView v-else-if="isRecordNotFound" :id="id" />
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const storeByType = {
|
||||
vm: useVmStore,
|
||||
host: useHostStore,
|
||||
};
|
||||
const props = defineProps<{
|
||||
isReady: boolean;
|
||||
uuidChecker: (uuid: string) => boolean;
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{ objectType: "vm" | "host"; id?: string }>();
|
||||
|
||||
const store = storeByType[props.objectType]();
|
||||
const { currentRoute } = useRouter();
|
||||
|
||||
const id = computed(
|
||||
() => props.id ?? (currentRoute.value.params.uuid as string)
|
||||
);
|
||||
|
||||
const isRecordNotFound = computed(
|
||||
() => store.isReady && !store.hasRecordByUuid(id.value)
|
||||
() => props.isReady && !props.uuidChecker(id.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import { fibonacci } from "iterable-backoff";
|
||||
import { computed, onBeforeUnmount, ref, watch, 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;
|
||||
@@ -14,37 +21,84 @@ const props = defineProps<{
|
||||
|
||||
const vmConsoleContainer = ref<HTMLDivElement>();
|
||||
const xenApiStore = useXenApiStore();
|
||||
let vncClient: VncClient | undefined;
|
||||
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;
|
||||
});
|
||||
|
||||
const clearVncClient = () => {
|
||||
if (vncClient !== undefined) {
|
||||
if (vncClient._rfbConnectionState !== "disconnected") {
|
||||
vncClient.disconnect();
|
||||
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;
|
||||
}
|
||||
vncClient = undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (
|
||||
!vmConsoleContainer.value ||
|
||||
!xenApiStore.currentSessionId ||
|
||||
url.value === undefined ||
|
||||
vmConsoleContainer.value === undefined ||
|
||||
!props.isConsoleAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
nConnectionAttempts = 0;
|
||||
createVncConnection();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
59
@xen-orchestra/lite/src/components/UnreachableHostsModal.vue
Normal file
59
@xen-orchestra/lite/src/components/UnreachableHostsModal.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<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,6 +19,7 @@
|
||||
class="preset-tab"
|
||||
@click="open"
|
||||
>
|
||||
<UiIcon :icon="faSliders" />
|
||||
Presets
|
||||
</UiTab>
|
||||
</template>
|
||||
@@ -105,6 +106,7 @@ 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 {
|
||||
@@ -116,6 +118,7 @@ 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";
|
||||
@@ -174,8 +177,8 @@ if (propParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.SETTINGS;
|
||||
}
|
||||
|
||||
const propValues = reactive<Record<string, any>>({});
|
||||
const settingValues = reactive<Record<string, any>>({});
|
||||
const propValues = ref<Record<string, any>>({});
|
||||
const settingValues = ref<Record<string, any>>({});
|
||||
const eventsLog = ref<
|
||||
{ id: string; name: string; args: { name: string; value: any }[] }[]
|
||||
>([]);
|
||||
@@ -183,13 +186,13 @@ const unreadEventsCount = ref(0);
|
||||
|
||||
const resetProps = () => {
|
||||
propParams.value.forEach((param) => {
|
||||
propValues[param.name] = param.getPresetValue();
|
||||
propValues.value[param.name] = param.getPresetValue();
|
||||
});
|
||||
};
|
||||
|
||||
const resetSettings = () => {
|
||||
settingParams.value.forEach((param) => {
|
||||
settingValues[param.name] = param.getPresetValue();
|
||||
settingValues.value[param.name] = param.getPresetValue();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -234,13 +237,13 @@ const slotProperties = computed(() => {
|
||||
const properties: Record<string, any> = {};
|
||||
|
||||
propParams.value.forEach(({ name }) => {
|
||||
properties[name] = propValues[name];
|
||||
properties[name] = propValues.value[name];
|
||||
});
|
||||
|
||||
eventParams.value.forEach((eventParam) => {
|
||||
properties[`on${upperFirst(eventParam.name)}`] = (...args: any[]) => {
|
||||
if (eventParam.isVModel()) {
|
||||
propValues[eventParam.rawName] = args[0];
|
||||
propValues.value[eventParam.rawName] = args[0];
|
||||
}
|
||||
const logArgs = Object.keys(eventParam.getArguments()).map(
|
||||
(argName, index) => ({
|
||||
@@ -260,7 +263,7 @@ const slotSettings = computed(() => {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
settingParams.value.forEach(({ name }) => {
|
||||
result[name] = settingValues[name];
|
||||
result[name] = settingValues.value[name];
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -284,13 +287,13 @@ const applyPreset = (preset: {
|
||||
}) => {
|
||||
if (preset.props !== undefined) {
|
||||
Object.entries(preset.props).forEach(([name, value]) => {
|
||||
propValues[name] = value;
|
||||
propValues.value[name] = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (preset.settings !== undefined) {
|
||||
Object.entries(preset.settings).forEach(([name, value]) => {
|
||||
settingValues[name] = value;
|
||||
settingValues.value[name] = value;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<StoryParamsTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prop</th>
|
||||
<th>Setting</th>
|
||||
<th><!--Widget--></th>
|
||||
<th>Help</th>
|
||||
</tr>
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<template>
|
||||
<li
|
||||
v-if="host"
|
||||
class="infra-host-item"
|
||||
v-tooltip="{
|
||||
content: host.name_label,
|
||||
disabled: isTooltipDisabled,
|
||||
}"
|
||||
>
|
||||
<li v-if="host !== undefined" class="infra-host-item">
|
||||
<InfraItemLabel
|
||||
:active="isCurrentHost"
|
||||
:icon="faServer"
|
||||
@@ -15,10 +8,10 @@
|
||||
{{ host.name_label || "(Host)" }}
|
||||
<template #actions>
|
||||
<InfraAction
|
||||
:icon="faStar"
|
||||
class="master-icon"
|
||||
v-if="isPoolMaster"
|
||||
v-tooltip="'Master'"
|
||||
:icon="faStar"
|
||||
class="master-icon"
|
||||
/>
|
||||
<InfraAction
|
||||
:icon="isExpanded ? faAngleDown : faAngleUp"
|
||||
@@ -32,8 +25,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 { 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,
|
||||
@@ -41,25 +39,18 @@ import {
|
||||
faStar,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useToggle } from "@vueuse/core";
|
||||
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";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
hostOpaqueRef: string;
|
||||
}>();
|
||||
|
||||
const hostStore = useHostStore();
|
||||
const host = computed(() => hostStore.getRecord(props.hostOpaqueRef));
|
||||
const { getByOpaqueRef } = useHostStore().subscribe();
|
||||
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
|
||||
|
||||
const poolStore = usePoolStore();
|
||||
const isPoolMaster = computed(
|
||||
() => poolStore.pool?.master === props.hostOpaqueRef
|
||||
);
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
|
||||
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
|
||||
|
||||
const uiStore = useUiStore();
|
||||
|
||||
@@ -67,17 +58,16 @@ 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),
|
||||
.infra-host-item:deep(.link-placeholder) {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.infra-vm-list:deep(.link) {
|
||||
.infra-vm-list:deep(.link),
|
||||
.infra-vm-list:deep(.link-placeholder) {
|
||||
padding-left: 4.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
<template>
|
||||
<ul class="infra-host-list">
|
||||
<li v-if="isLoading">{{ $t("loading-hosts") }}</li>
|
||||
<li v-else-if="hasError" class="text-error">
|
||||
<li v-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="opaqueRef in opaqueRefs"
|
||||
:key="opaqueRef"
|
||||
:host-opaque-ref="opaqueRef"
|
||||
v-for="host in hosts"
|
||||
:key="host.$ref"
|
||||
:host-opaque-ref="host.$ref"
|
||||
/>
|
||||
</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 hostStore = useHostStore();
|
||||
const { hasError, isLoading, opaqueRefs } = storeToRefs(hostStore);
|
||||
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
class="infra-item-label"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<a :href="href" class="link" @click="navigate">
|
||||
<a :href="href" class="link" @click="navigate" v-tooltip="hasTooltip">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<div class="text">
|
||||
<div ref="textElement" class="text">
|
||||
<slot />
|
||||
</div>
|
||||
</a>
|
||||
@@ -22,7 +22,10 @@
|
||||
|
||||
<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<{
|
||||
@@ -30,6 +33,9 @@ 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">
|
||||
<InfraLoadingItem
|
||||
v-if="isLoading || pool === undefined"
|
||||
:icon="faBuilding"
|
||||
/>
|
||||
<li v-else-if="hasError" class="text-error">
|
||||
<li v-if="hasError" class="text-error">
|
||||
{{ $t("error-no-data") }}
|
||||
</li>
|
||||
<InfraLoadingItem
|
||||
v-else-if="!isReady || pool === undefined"
|
||||
:icon="faBuilding"
|
||||
/>
|
||||
<li v-else class="infra-pool-item">
|
||||
<InfraItemLabel
|
||||
:icon="faBuilding"
|
||||
@@ -24,16 +24,14 @@
|
||||
</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 poolStore = usePoolStore();
|
||||
const { hasError, isLoading, pool } = storeToRefs(poolStore);
|
||||
const { isReady, hasError, pool } = usePoolStore().subscribe();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@@ -42,7 +40,8 @@ const { hasError, isLoading, pool } = storeToRefs(poolStore);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.infra-vm-list:deep(.link) {
|
||||
.infra-vm-list:deep(.link),
|
||||
.infra-vm-list:deep(.link-placeholder) {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<template>
|
||||
<li
|
||||
ref="rootElement"
|
||||
class="infra-vm-item"
|
||||
v-tooltip="{
|
||||
content: vm.name_label,
|
||||
disabled: isTooltipDisabled,
|
||||
}"
|
||||
>
|
||||
<li v-if="vm !== undefined" ref="rootElement" class="infra-vm-item">
|
||||
<InfraItemLabel
|
||||
v-if="isVisible"
|
||||
:icon="faDisplay"
|
||||
@@ -15,7 +8,7 @@
|
||||
{{ vm.name_label || "(VM)" }}
|
||||
<template #actions>
|
||||
<InfraAction>
|
||||
<PowerStateIcon :state="vm?.power_state" />
|
||||
<PowerStateIcon :state="vm.power_state" />
|
||||
</InfraAction>
|
||||
</template>
|
||||
</InfraItemLabel>
|
||||
@@ -23,20 +16,20 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 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 PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
vmOpaqueRef: string;
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef } = useVmStore().subscribe();
|
||||
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
|
||||
const rootElement = ref();
|
||||
const isVisible = ref(false);
|
||||
|
||||
@@ -46,13 +39,6 @@ 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,38 +1,32 @@
|
||||
<template>
|
||||
<ul class="infra-vm-list">
|
||||
<template v-if="isLoading">
|
||||
<InfraLoadingItem v-for="i in 3" :icon="faDisplay" :key="i" />
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
<InfraVmItem v-for="vm in vms" :key="vm.$ref" :vm-opaque-ref="vm.$ref" />
|
||||
</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 vmStore = useVmStore();
|
||||
const { hasError, isLoading, opaqueRefsByHostRef } = storeToRefs(vmStore);
|
||||
const vmOpaqueRefs = computed(() =>
|
||||
opaqueRefsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
|
||||
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
|
||||
|
||||
const vms = computed(() =>
|
||||
recordsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
<style lang="postcss" scoped>
|
||||
.text-error {
|
||||
padding-left: 3rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||
import TitleBar from "@/components/TitleBar.vue";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
|
||||
const poolStore = usePoolStore();
|
||||
const name = computed(() => poolStore.pool?.name_label ?? "...");
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
|
||||
const name = computed(() => pool.value?.name_label ?? "...");
|
||||
</script>
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
<template>
|
||||
<UiTabBar :disabled="!isReady">
|
||||
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("dashboard") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("alarms") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.stats', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.stats', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("stats") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.system', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.system', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("system") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.network', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.network', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("network") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.storage', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.storage', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("storage") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("tasks") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: pool?.uuid } }">
|
||||
{{ $t("hosts") }}
|
||||
</RouterTab>
|
||||
<RouterTab :to="{ name: 'pool.vms', params: { uuid: poolUuid } }">
|
||||
<RouterTab :to="{ name: 'pool.vms', params: { uuid: pool?.uuid } }">
|
||||
{{ $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 poolStore = usePoolStore();
|
||||
const { pool, isReady } = storeToRefs(poolStore);
|
||||
const poolUuid = computed(() => pool.value?.uuid);
|
||||
const { pool, isReady } = usePoolStore().subscribe();
|
||||
</script>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<UiStatusIcon v-if="state !== 'success'" :state="state" />
|
||||
</template>
|
||||
</UiCardTitle>
|
||||
<div v-if="isReady" class="progress-item" :class="state">
|
||||
<UiProgressBar color="custom" :value="value" :max-value="maxValue" />
|
||||
<UiProgressScale :max-value="maxValue" unit="%" :steps="1" />
|
||||
<div v-if="isReady" :class="state" class="progress-item">
|
||||
<UiProgressBar :max-value="maxValue" :value="value" color="custom" />
|
||||
<UiProgressScale :max-value="maxValue" :steps="1" unit="%" />
|
||||
<UiProgressLegend :label="$t('vcpus')" :value="`${value}%`" />
|
||||
<UiCardFooter>
|
||||
<template #left>
|
||||
@@ -27,52 +27,62 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 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 UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import UiStatusIcon from "@/components/ui/icon/UiStatusIcon.vue";
|
||||
import { isHostRunning, percent } from "@/libs/utils";
|
||||
import { percent } from "@/libs/utils";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmMetricsStore } from "@/stores/vm-metrics.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { logicAnd } from "@vueuse/math";
|
||||
import { computed } from "vue";
|
||||
|
||||
const ACTIVE_STATES = new Set(["Running", "Paused"]);
|
||||
|
||||
const { allRecords: hosts, isReady: hostStoreIsReady } = storeToRefs(
|
||||
useHostStore()
|
||||
);
|
||||
const { allRecords: vms, isReady: vmStoreIsReady } = storeToRefs(useVmStore());
|
||||
const vmMetricsStore = useVmMetricsStore();
|
||||
const { isReady: isHostStoreReady, runningHosts } = useHostStore().subscribe({
|
||||
hostMetricsSubscription: useHostMetricsStore().subscribe(),
|
||||
});
|
||||
|
||||
const { records: vms, isReady: isVmStoreReady } = useVmStore().subscribe();
|
||||
|
||||
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
|
||||
useVmMetricsStore().subscribe();
|
||||
|
||||
const nPCpu = computed(() =>
|
||||
hosts.value.reduce(
|
||||
(total, host) =>
|
||||
isHostRunning(host) ? total + Number(host.cpu_info.cpu_count) : total,
|
||||
runningHosts.value.reduce(
|
||||
(total, host) => total + Number(host.cpu_info.cpu_count),
|
||||
0
|
||||
)
|
||||
);
|
||||
const nVCpuInUse = computed(() =>
|
||||
vms.value.reduce(
|
||||
|
||||
const nVCpuInUse = computed(() => {
|
||||
if (!isReady.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return vms.value.reduce(
|
||||
(total, vm) =>
|
||||
ACTIVE_STATES.has(vm.power_state)
|
||||
? total + vmMetricsStore.getRecord(vm.metrics).VCPUs_number
|
||||
? total + getVmMetrics(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 = computed(
|
||||
() => vmStoreIsReady.value && vmMetricsStore.isReady && hostStoreIsReady.value
|
||||
const isReady = logicAnd(
|
||||
isVmStoreReady,
|
||||
isHostStoreReady,
|
||||
isVmMetricsStoreReady
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -82,14 +92,17 @@ const isReady = computed(
|
||||
--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,15 +6,16 @@
|
||||
</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 { useVmStore } from "@/stores/vm.store";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { computed } from "vue";
|
||||
|
||||
const hasError = computed(
|
||||
() => useVmStore().hasError || useHostStore().hasError
|
||||
);
|
||||
const { hasError: hasVmError } = useVmStore().subscribe();
|
||||
const { hasError: hasHostError } = useHostStore().subscribe();
|
||||
|
||||
const hasError = computed(() => hasVmError.value || hasHostError.value);
|
||||
</script>
|
||||
|
||||
@@ -6,16 +6,17 @@
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
<script lang="ts" setup>
|
||||
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 { useVmStore } from "@/stores/vm.store";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { computed } from "vue";
|
||||
|
||||
const hasError = computed(
|
||||
() => useVmStore().hasError || useHostStore().hasError
|
||||
);
|
||||
const { hasError: hasVmError } = useVmStore().subscribe();
|
||||
const { hasError: hasHostError } = useHostStore().subscribe();
|
||||
|
||||
const hasError = computed(() => hasVmError.value || hasHostError.value);
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<UiCard :color="hasError ? 'error' : undefined">
|
||||
<UiCardTitle>{{ $t("status") }}</UiCardTitle>
|
||||
<UiSpinner v-if="isLoading" class="spinner" />
|
||||
<NoDataError v-else-if="hasError" />
|
||||
<NoDataError v-if="hasError" />
|
||||
<UiSpinner v-else-if="!isReady" class="spinner" />
|
||||
<template v-else>
|
||||
<PoolDashboardStatusItem
|
||||
:active="activeHostsCount"
|
||||
@@ -20,6 +20,7 @@
|
||||
</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";
|
||||
@@ -28,30 +29,33 @@ 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 vmStore = useVmStore();
|
||||
const hostMetricsStore = useHostMetricsStore();
|
||||
const {
|
||||
isReady: isVmReady,
|
||||
records: vms,
|
||||
hasError: hasVmError,
|
||||
runningVms,
|
||||
} = useVmStore().subscribe();
|
||||
|
||||
const hasError = computed(() => vmStore.hasError || hostMetricsStore.hasError);
|
||||
const {
|
||||
isReady: isHostMetricsReady,
|
||||
records: hostMetrics,
|
||||
hasError: hasHostMetricsError,
|
||||
} = useHostMetricsStore().subscribe();
|
||||
|
||||
const isLoading = computed(
|
||||
() => vmStore.isLoading && hostMetricsStore.isLoading
|
||||
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 totalHostsCount = computed(() => hostMetricsStore.opaqueRefs.length);
|
||||
const activeHostsCount = computed(() => {
|
||||
return hostMetricsStore.opaqueRefs.filter(
|
||||
(opaqueRef) => hostMetricsStore.getRecord(opaqueRef)?.live
|
||||
).length;
|
||||
});
|
||||
const totalVmsCount = computed(() => vms.value.length);
|
||||
|
||||
const totalVmsCount = computed(() => vmStore.opaqueRefs.length);
|
||||
const activeVmsCount = computed(() => {
|
||||
return vmStore.opaqueRefs.filter(
|
||||
(opaqueRef) => vmStore.getRecord(opaqueRef)?.power_state === "Running"
|
||||
).length;
|
||||
});
|
||||
const activeVmsCount = computed(() => runningVms.value.length);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -18,18 +18,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { computed } from "vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useSrStore } from "@/stores/storage.store";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
const srStore = useSrStore();
|
||||
const { hasError, isReady } = storeToRefs(srStore);
|
||||
const { records: srs, isReady, hasError } = useSrStore().subscribe();
|
||||
|
||||
const data = computed<{
|
||||
result: { id: string; label: string; value: number }[];
|
||||
@@ -40,7 +38,7 @@ const data = computed<{
|
||||
let maxSize = 0;
|
||||
let usedSize = 0;
|
||||
|
||||
srStore.allRecords.forEach(
|
||||
srs.value.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,11 +16,10 @@ 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 } = storeToRefs(useHostStore());
|
||||
const { hasError } = useHostStore().subscribe();
|
||||
|
||||
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
|
||||
"hostStats",
|
||||
|
||||
@@ -12,14 +12,13 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LinearChart from "@/components/charts/LinearChart.vue";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import type { FetchedStats } from "@/composables/fetch-stats.composable";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
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";
|
||||
|
||||
@@ -28,7 +27,7 @@ const { t } = useI18n();
|
||||
const hostLastWeekStats =
|
||||
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
|
||||
|
||||
const { allRecords: hosts } = storeToRefs(useHostStore());
|
||||
const { records: hosts } = useHostStore().subscribe();
|
||||
|
||||
const customMaxValue = computed(
|
||||
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
|
||||
|
||||
@@ -18,9 +18,8 @@ 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 } = storeToRefs(useVmStore());
|
||||
const { hasError } = useVmStore().subscribe();
|
||||
|
||||
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
|
||||
"vmStats",
|
||||
|
||||
@@ -18,9 +18,8 @@ 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 } = storeToRefs(useHostStore());
|
||||
const { hasError } = useHostStore().subscribe();
|
||||
|
||||
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
|
||||
"hostStats",
|
||||
|
||||
@@ -18,33 +18,39 @@
|
||||
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 { allRecords: hosts } = storeToRefs(useHostStore());
|
||||
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||
|
||||
const hostStore = useHostStore();
|
||||
const { runningHosts } = hostStore.subscribe({ hostMetricsSubscription });
|
||||
|
||||
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)?.size ?? 0)
|
||||
sumBy(
|
||||
runningHosts.value,
|
||||
(host) => getHostMemory(host, hostMetricsSubscription)?.size ?? 0
|
||||
)
|
||||
);
|
||||
|
||||
const currentData = computed(() => {
|
||||
let size = 0,
|
||||
usage = 0;
|
||||
runningHosts.value.forEach((host) => {
|
||||
const hostMemory = getHostMemory(host);
|
||||
const hostMemory = getHostMemory(host, hostMetricsSubscription);
|
||||
size += hostMemory?.size ?? 0;
|
||||
usage += hostMemory?.usage ?? 0;
|
||||
});
|
||||
|
||||
@@ -18,9 +18,8 @@ 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 } = storeToRefs(useVmStore());
|
||||
const { hasError } = useVmStore().subscribe();
|
||||
|
||||
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
|
||||
"vmStats",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<tr class="finished-task-row" :class="{ finished: !isPending }">
|
||||
<tr :class="{ finished: !isPending }" class="finished-task-row">
|
||||
<td>{{ task.name_label }}</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
v-if="host !== undefined"
|
||||
:to="{
|
||||
name: 'host.dashboard',
|
||||
params: { uuid: host.uuid },
|
||||
@@ -43,7 +44,7 @@ const props = defineProps<{
|
||||
task: XenApiTask;
|
||||
}>();
|
||||
|
||||
const { getRecord: getHost } = useHostStore();
|
||||
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
|
||||
|
||||
const createdAt = computed(() => parseDateTime(props.task.created));
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<template>
|
||||
<UiTable class="tasks-table">
|
||||
<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" />
|
||||
<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>
|
||||
</UiTable>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ defineProps<{
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-badge {
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -146,7 +146,7 @@ const className = computed(() => {
|
||||
|
||||
.content {
|
||||
overflow: auto;
|
||||
min-height: 23rem;
|
||||
font-size: 1.6rem;
|
||||
max-height: calc(100vh - 40rem);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,55 @@
|
||||
<template>
|
||||
<table class="ui-table">
|
||||
<thead>
|
||||
<tr class="header-row">
|
||||
<slot name="header" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="body">
|
||||
<slot />
|
||||
</tbody>
|
||||
<table :class="{ 'vertical-border': verticalBorder }" class="ui-table">
|
||||
<slot />
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
verticalBorder?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
: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) {
|
||||
background-color: var(--background-color-primary);
|
||||
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,7 +1,13 @@
|
||||
<template>
|
||||
<div class="legend">
|
||||
<span class="circle" />
|
||||
<slot name="label">{{ label }}</slot>
|
||||
<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>
|
||||
<UiBadge class="badge">
|
||||
<slot name="value">{{ value }}</slot>
|
||||
</UiBadge>
|
||||
@@ -10,14 +16,23 @@
|
||||
|
||||
<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 scoped lang="postcss">
|
||||
<style lang="postcss" scoped>
|
||||
.badge {
|
||||
font-size: 0.9em;
|
||||
font-weight: 700;
|
||||
@@ -25,8 +40,8 @@ defineProps<{
|
||||
|
||||
.circle {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--progress-bar-color);
|
||||
}
|
||||
@@ -38,4 +53,14 @@ defineProps<{
|
||||
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 shadow placement="bottom-end">
|
||||
<AppMenu v-if="vm !== undefined" placement="bottom-end" shadow>
|
||||
<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,22 +25,24 @@
|
||||
{{ $t("start-on-host") }}
|
||||
<template #submenu>
|
||||
<MenuItem
|
||||
v-for="host in hostStore.allRecords"
|
||||
@click="xenApi.vm.startOn({ vmRef: vm.$ref, hostRef: host.$ref })"
|
||||
v-for="host in hosts as XenApiHost[]"
|
||||
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 === poolStore.pool?.master ? faStar : undefined
|
||||
"
|
||||
:icon="host.$ref === pool?.master ? faStar : undefined"
|
||||
class="star"
|
||||
/>
|
||||
<PowerStateIcon
|
||||
:state="isHostRunning(host) ? 'Running' : 'Halted'"
|
||||
:state="
|
||||
isHostRunning(host, hostMetricsSubscription)
|
||||
? 'Running'
|
||||
: 'Halted'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,63 +50,59 @@
|
||||
</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>
|
||||
@@ -112,14 +110,17 @@
|
||||
</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 UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiButton from "@/components/ui/UiButton.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";
|
||||
@@ -140,22 +141,23 @@ import {
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { computedAsync } from "@vueuse/core";
|
||||
|
||||
const vmStore = useVmStore();
|
||||
const hostStore = useHostStore();
|
||||
const poolStore = usePoolStore();
|
||||
const { getByUuid: getVmByUuid } = useVmStore().subscribe();
|
||||
const { records: hosts } = useHostStore().subscribe();
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||
const xenApi = useXenApiStore().getXapi();
|
||||
const { currentRoute } = useRouter();
|
||||
|
||||
const vm = computed(
|
||||
() => vmStore.getRecordByUuid(currentRoute.value.params.uuid as string)!
|
||||
const vm = computed(() =>
|
||||
getVmByUuid(currentRoute.value.params.uuid as string)
|
||||
);
|
||||
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");
|
||||
|
||||
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>
|
||||
|
||||
37
@xen-orchestra/lite/src/components/vm/VmTabBar.vue
Normal file
37
@xen-orchestra/lite/src/components/vm/VmTabBar.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<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,7 +35,11 @@ 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,5 +1,3 @@
|
||||
import { computed, onUnmounted, ref, type ComputedRef } from "vue";
|
||||
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
|
||||
import {
|
||||
type GRANULARITY,
|
||||
type HostStats,
|
||||
@@ -8,36 +6,36 @@ import {
|
||||
type XapiStatsResponse,
|
||||
} from "@/libs/xapi-stats";
|
||||
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
||||
const STORES_BY_OBJECT_TYPE = {
|
||||
host: useHostStore,
|
||||
vm: useVmStore,
|
||||
};
|
||||
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
|
||||
import { computed, type ComputedRef, onUnmounted, ref } from "vue";
|
||||
|
||||
export type Stat<T> = {
|
||||
id: string;
|
||||
name: string;
|
||||
stats?: T | null;
|
||||
stats: T | undefined;
|
||||
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>[] | null>;
|
||||
timestampStart?: ComputedRef<number>;
|
||||
timestampEnd?: ComputedRef<number>;
|
||||
stats: ComputedRef<Stat<S>[]>;
|
||||
timestampStart: ComputedRef<number>;
|
||||
timestampEnd: ComputedRef<number>;
|
||||
};
|
||||
|
||||
export default function useFetchStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
S extends HostStats | VmStats
|
||||
>(type: "host" | "vm", granularity: GRANULARITY) {
|
||||
>(getStats: GetStats<S>, granularity: GRANULARITY): FetchedStats<T, S> {
|
||||
const stats = ref<Map<string, Stat<S>>>(new Map());
|
||||
const timestamp = ref<number[]>([0, 0]);
|
||||
|
||||
@@ -54,18 +52,12 @@ export default function useFetchStats<
|
||||
return;
|
||||
}
|
||||
|
||||
const objectStore = STORES_BY_OBJECT_TYPE[type]();
|
||||
const newStats = await getStats(object.uuid, granularity);
|
||||
|
||||
if (objectStore.hasError) {
|
||||
stats.value.get(mapKey)!.stats = null;
|
||||
if (newStats === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStats = (await objectStore.getStats(
|
||||
object.uuid,
|
||||
granularity
|
||||
)) as XapiStatsResponse<S>;
|
||||
|
||||
timestamp.value = [
|
||||
newStats.endTimestamp -
|
||||
RRD_STEP_FROM_STRING[granularity] *
|
||||
|
||||
@@ -1,36 +1,71 @@
|
||||
# Tooltip Directive
|
||||
|
||||
By default, tooltip will appear centered above the target element.
|
||||
By default, the tooltip will appear centered above the target element.
|
||||
|
||||
## Directive argument
|
||||
|
||||
The directive argument can be either:
|
||||
|
||||
- The tooltip content
|
||||
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
|
||||
|
||||
## Tooltip content
|
||||
|
||||
The tooltip content can be either:
|
||||
|
||||
- `false` or an empty-string to disable the tooltip
|
||||
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
|
||||
- Non-empty string to enable the tooltip and use the string as content.
|
||||
|
||||
## Tooltip placement
|
||||
|
||||
Tooltip can be placed on the following positions:
|
||||
|
||||
- `top`
|
||||
- `top-start`
|
||||
- `top-end`
|
||||
- `bottom`
|
||||
- `bottom-start`
|
||||
- `bottom-end`
|
||||
- `left`
|
||||
- `left-start`
|
||||
- `left-end`
|
||||
- `right`
|
||||
- `right-start`
|
||||
- `right-end`
|
||||
|
||||
## Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Static -->
|
||||
<!-- Boolean / Undefined -->
|
||||
<span v-tooltip="true"
|
||||
>This content will be ellipsized by CSS but displayed entirely in the
|
||||
tooltip</span
|
||||
>
|
||||
<span v-tooltip
|
||||
>This content will be ellipsized by CSS but displayed entirely in the
|
||||
tooltip</span
|
||||
>
|
||||
|
||||
<!-- String -->
|
||||
<span v-tooltip="'Tooltip content'">Item</span>
|
||||
|
||||
<!-- Dynamic -->
|
||||
<span v-tooltip="myTooltipContent">Item</span>
|
||||
|
||||
<!-- Placement -->
|
||||
<!-- Object -->
|
||||
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
|
||||
|
||||
<!-- Disabling (variable) -->
|
||||
<span v-tooltip="{ content: 'Foobar', disabled: isDisabled }">Item</span>
|
||||
<!-- Dynamic -->
|
||||
<span v-tooltip="myTooltip">Item</span>
|
||||
|
||||
<!-- Disabling (function) -->
|
||||
<span v-tooltip="{ content: 'Foobar', disabled: isDisabledFn }">Item</span>
|
||||
<!-- Conditional -->
|
||||
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
|
||||
const myTooltipContent = ref("Content");
|
||||
const isDisabled = ref(true);
|
||||
|
||||
const isDisabledFn = (target: Element) => {
|
||||
// return boolean;
|
||||
};
|
||||
const myTooltip = ref("Content"); // or ref({ content: "Content", placement: "left-end" })
|
||||
const isTooltipEnabled = ref(true);
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
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";
|
||||
|
||||
export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
|
||||
type TooltipDirectiveContent = undefined | boolean | string;
|
||||
|
||||
type TooltipDirectiveOptions =
|
||||
| TooltipDirectiveContent
|
||||
| {
|
||||
content?: TooltipDirectiveContent;
|
||||
placement?: Options["placement"];
|
||||
};
|
||||
|
||||
const parseOptions = (
|
||||
options: TooltipDirectiveOptions,
|
||||
target: HTMLElement
|
||||
): TooltipOptions => {
|
||||
const { placement, content } = isObject(options)
|
||||
? options
|
||||
: { placement: undefined, content: options };
|
||||
|
||||
return {
|
||||
placement,
|
||||
content:
|
||||
content === true || content === undefined
|
||||
? target.innerText.trim()
|
||||
: content,
|
||||
};
|
||||
};
|
||||
|
||||
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
|
||||
mounted(target, binding) {
|
||||
const store = useTooltipStore();
|
||||
|
||||
@@ -10,11 +38,11 @@ export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
|
||||
? { on: "focusin", off: "focusout" }
|
||||
: { on: "mouseenter", off: "mouseleave" };
|
||||
|
||||
store.register(target, binding.value, events);
|
||||
store.register(target, parseOptions(binding.value, target), events);
|
||||
},
|
||||
updated(target, binding) {
|
||||
const store = useTooltipStore();
|
||||
store.updateOptions(target, binding.value);
|
||||
store.updateOptions(target, parseOptions(binding.value, target));
|
||||
},
|
||||
beforeUnmount(target) {
|
||||
const store = useTooltipStore();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import HLJS from "highlight.js";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { marked } from "marked";
|
||||
|
||||
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>`;
|
||||
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>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,11 +25,7 @@ function highlight(str: string, lang: string) {
|
||||
case "vue-style":
|
||||
return wrap(str.trim(), "style");
|
||||
default: {
|
||||
if (HLJS.getLanguage(lang) !== undefined) {
|
||||
return copyable(HLJS.highlight(str, { language: lang }).value);
|
||||
}
|
||||
|
||||
return copyable(markdown.utils.escapeHtml(str));
|
||||
return copyable(HLJS.highlight(str, { language: lang }).value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,3 +58,5 @@ 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 { 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 type {
|
||||
RawObjectType,
|
||||
RawXenApiRecord,
|
||||
XenApiHost,
|
||||
XenApiHostMetrics,
|
||||
XenApiRecord,
|
||||
XenApiVm,
|
||||
} from "@/libs/xen-api";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import type { CollectionSubscription } from "@/stores/xapi-collection.store";
|
||||
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";
|
||||
|
||||
export function sortRecordsByNameLabel(
|
||||
record1: { name_label: string },
|
||||
@@ -71,12 +71,25 @@ export function parseDateTime(dateTime: string) {
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
export const hasEllipsis = (target: Element | undefined | null) =>
|
||||
target != undefined && target.clientWidth < target.scrollWidth;
|
||||
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 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) {
|
||||
@@ -101,48 +114,37 @@ export function getStatsLength(stats?: object | any[]) {
|
||||
return size(find(stats, (stat) => stat != null));
|
||||
}
|
||||
|
||||
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 isHostRunning(
|
||||
host: XenApiHost,
|
||||
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
|
||||
) {
|
||||
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
|
||||
}
|
||||
|
||||
export function isHostRunning(host: XenApiHost) {
|
||||
const store = useHostMetricsStore();
|
||||
try {
|
||||
return store.getRecord(host.metrics).live;
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
export function getHostMemory(
|
||||
host: XenApiHost,
|
||||
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
|
||||
) {
|
||||
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
|
||||
|
||||
export function getHostMemory(host: XenApiHost) {
|
||||
try {
|
||||
const metrics = useHostMetricsStore().getRecord(host.metrics);
|
||||
const total = +metrics.memory_total;
|
||||
if (hostMetrics !== undefined) {
|
||||
const total = +hostMetrics.memory_total;
|
||||
return {
|
||||
usage: total - +metrics.memory_free,
|
||||
usage: total - +hostMetrics.memory_free,
|
||||
size: total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("getHostMemory function:", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const buildXoObject = (
|
||||
record: RawXenApiRecord<XenApiRecord>,
|
||||
export const buildXoObject = <T extends XenApiRecord>(
|
||||
record: RawXenApiRecord<T>,
|
||||
params: { opaqueRef: string }
|
||||
) => ({
|
||||
...record,
|
||||
$ref: params.opaqueRef,
|
||||
});
|
||||
) => {
|
||||
return {
|
||||
...record,
|
||||
$ref: params.opaqueRef,
|
||||
} as T;
|
||||
};
|
||||
|
||||
export function parseRamUsage(
|
||||
{
|
||||
@@ -181,6 +183,15 @@ 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,67 +1,72 @@
|
||||
import { JSONRPCClient } from "json-rpc-2.0";
|
||||
import { buildXoObject, parseDateTime } from "@/libs/utils";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { JSONRPCClient } from "json-rpc-2.0";
|
||||
import { castArray } from "lodash-es";
|
||||
|
||||
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";
|
||||
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 PowerState = "Running" | "Paused" | "Halted" | "Suspended";
|
||||
|
||||
export type ObjectType = Lowercase<RawObjectType>;
|
||||
|
||||
export interface XenApiRecord {
|
||||
$ref: string;
|
||||
uuid: string;
|
||||
@@ -83,6 +88,7 @@ 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 {
|
||||
@@ -137,7 +143,7 @@ type WatchCallbackResult = {
|
||||
class: ObjectType;
|
||||
operation: "add" | "mod" | "del";
|
||||
ref: string;
|
||||
snapshot: object;
|
||||
snapshot: RawXenApiRecord<XenApiRecord>;
|
||||
};
|
||||
|
||||
type WatchCallback = (results: WatchCallbackResult[]) => void;
|
||||
@@ -199,8 +205,8 @@ export default class XenApi {
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#call("session.logout", [this.#sessionId]);
|
||||
async disconnect() {
|
||||
await this.#call("session.logout", [this.#sessionId]);
|
||||
this.stopWatch();
|
||||
this.#sessionId = undefined;
|
||||
}
|
||||
@@ -247,20 +253,15 @@ export default class XenApi {
|
||||
return fetch(url);
|
||||
}
|
||||
|
||||
async loadRecords<T extends XenApiRecord>(
|
||||
type: RawObjectType
|
||||
): Promise<Map<string, T>> {
|
||||
async loadRecords<T extends XenApiRecord>(type: RawObjectType): Promise<T[]> {
|
||||
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
|
||||
`${type}.get_all_records`,
|
||||
[this.sessionId]
|
||||
);
|
||||
|
||||
const entries = Object.entries(result).map<[string, T]>(([key, entry]) => [
|
||||
key,
|
||||
buildXoObject(entry, { opaqueRef: key }) as T,
|
||||
]);
|
||||
|
||||
return new Map(entries);
|
||||
return Object.entries(result).map(([opaqueRef, record]) =>
|
||||
buildXoObject(record, { opaqueRef })
|
||||
);
|
||||
}
|
||||
|
||||
async #watch() {
|
||||
@@ -286,7 +287,7 @@ export default class XenApi {
|
||||
|
||||
startWatch() {
|
||||
this.#watching = true;
|
||||
this.#watch();
|
||||
return this.#watch();
|
||||
}
|
||||
|
||||
stopWatch() {
|
||||
@@ -307,77 +308,57 @@ export default class XenApi {
|
||||
}
|
||||
|
||||
get vm() {
|
||||
type VmsRef =
|
||||
| {
|
||||
vmRef: XenApiVm["$ref"];
|
||||
vmsRef?: undefined;
|
||||
}
|
||||
| {
|
||||
vmRef?: undefined;
|
||||
vmsRef: XenApiVm["$ref"][];
|
||||
};
|
||||
type VmRefs = XenApiVm["$ref"] | XenApiVm["$ref"][];
|
||||
type VmRefsWithPowerState = Record<
|
||||
XenApiVm["$ref"],
|
||||
XenApiVm["power_state"]
|
||||
>;
|
||||
|
||||
return {
|
||||
start: ({ vmRef, vmsRef }: VmsRef) => {
|
||||
const _vmsRef = vmsRef ?? [vmRef];
|
||||
return Promise.all(
|
||||
_vmsRef.map((vmRef) => this._call("VM.start", [vmRef, false, false]))
|
||||
);
|
||||
},
|
||||
startOn: ({ vmRef, vmsRef, hostRef }: VmsRef & { hostRef: string }) => {
|
||||
const _vmsRef = vmsRef ?? [vmRef];
|
||||
return Promise.all(
|
||||
_vmsRef.map((vmRef) =>
|
||||
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) => {
|
||||
return Promise.all(
|
||||
castArray(vmRefs).map((vmRef) => this._call("VM.suspend", [vmRef]))
|
||||
);
|
||||
},
|
||||
pause: ({ vmRef, vmsRef }: VmsRef) => {
|
||||
const _vmsRef = vmsRef ?? [vmRef];
|
||||
resume: (vmRefsWithPowerState: VmRefsWithPowerState) => {
|
||||
const vmRefs = Object.keys(vmRefsWithPowerState);
|
||||
|
||||
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]
|
||||
);
|
||||
vmRefs.map((vmRef) => {
|
||||
if (vmRefsWithPowerState[vmRef] === "Suspended") {
|
||||
return this._call("VM.resume", [vmRef, false, false]);
|
||||
}
|
||||
|
||||
return this._call("VM.unpause", [vmRef]);
|
||||
})
|
||||
);
|
||||
},
|
||||
reboot: ({
|
||||
vmRef,
|
||||
vmsRef,
|
||||
force = false,
|
||||
}: VmsRef & { force?: boolean }) => {
|
||||
const _vmsRef = vmsRef ?? [vmRef];
|
||||
reboot: (vmRefs: VmRefs, force = false) => {
|
||||
return Promise.all(
|
||||
_vmsRef.map((vmRef) =>
|
||||
castArray(vmRefs).map((vmRef) =>
|
||||
this._call(`VM.${force ? "hard" : "clean"}_reboot`, [vmRef])
|
||||
)
|
||||
);
|
||||
},
|
||||
shutdown: ({
|
||||
vmRef,
|
||||
vmsRef,
|
||||
force = false,
|
||||
}: VmsRef & { force?: boolean }) => {
|
||||
const _vmsRef = vmsRef ?? [vmRef];
|
||||
shutdown: (vmRefs: VmRefs, force = false) => {
|
||||
return Promise.all(
|
||||
_vmsRef.map((vmRef) =>
|
||||
castArray(vmRefs).map((vmRef) =>
|
||||
this._call(`VM.${force ? "hard" : "clean"}_shutdown`, [vmRef])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"about": "About",
|
||||
"add": "Add",
|
||||
"add-filter": "Add filter",
|
||||
"add-or": "+OR",
|
||||
@@ -16,6 +17,7 @@
|
||||
"coming-soon": "Coming soon!",
|
||||
"community": "Community",
|
||||
"community-name": "{name} community",
|
||||
"console": "Console",
|
||||
"copy": "Copy",
|
||||
"cpu-provisioning": "CPU provisioning",
|
||||
"cpu-usage": "CPU usage",
|
||||
@@ -24,10 +26,12 @@
|
||||
"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",
|
||||
@@ -108,6 +112,8 @@
|
||||
"status": "Status",
|
||||
"storage": "Storage",
|
||||
"storage-usage": "Storage usage",
|
||||
"support": "Support",
|
||||
"support-name": "{name} pro support",
|
||||
"suspend": "Suspend",
|
||||
"switch-theme": "Switch theme",
|
||||
"system": "System",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"about": "À propos",
|
||||
"add": "Ajouter",
|
||||
"add-filter": "Ajouter un filtre",
|
||||
"add-or": "+OU",
|
||||
@@ -16,6 +17,7 @@
|
||||
"coming-soon": "Bientôt disponible !",
|
||||
"community": "Communauté",
|
||||
"community-name": "Communauté {name}",
|
||||
"console": "Console",
|
||||
"copy": "Copier",
|
||||
"cpu-provisioning": "Provisionnement CPU",
|
||||
"cpu-usage": "Utilisation CPU",
|
||||
@@ -24,6 +26,8 @@
|
||||
"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.",
|
||||
@@ -108,6 +112,8 @@
|
||||
"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,12 +1,11 @@
|
||||
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";
|
||||
|
||||
@@ -31,6 +30,7 @@ const router = createRouter({
|
||||
component: SettingsView,
|
||||
},
|
||||
pool,
|
||||
vm,
|
||||
{
|
||||
path: "/host/:uuid",
|
||||
component: HostRootView,
|
||||
@@ -42,17 +42,6 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/vm/:uuid",
|
||||
component: VmRootView,
|
||||
children: [
|
||||
{
|
||||
path: "console",
|
||||
name: "vm.console",
|
||||
component: VmConsoleView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "notFound",
|
||||
|
||||
47
@xen-orchestra/lite/src/router/vm.ts
Normal file
47
@xen-orchestra/lite/src/router/vm.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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,7 +1,6 @@
|
||||
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", () =>
|
||||
createRecordContext<XenApiConsole>("console")
|
||||
useXapiCollectionStore().get("console")
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user