Compare commits
114 Commits
lite/xapi-
...
lite/neste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
677a9c958c | ||
|
|
2978ad1486 | ||
|
|
c0d6dc48de | ||
|
|
f327422254 | ||
|
|
938d15d31b | ||
|
|
5ab1ddb9cb | ||
|
|
01302d7a60 | ||
|
|
c68630e2d6 | ||
|
|
db082bfbe9 | ||
|
|
650d88db46 | ||
|
|
7d1ecca669 | ||
|
|
5f71e629ae | ||
|
|
68205d4676 | ||
|
|
cdb466225d | ||
|
|
0e7fbd598f | ||
|
|
99147c893d | ||
|
|
c63fb6173d | ||
|
|
5932ada717 | ||
|
|
0d579748d6 | ||
|
|
8c5ee4eafe | ||
|
|
b03935ad2f | ||
|
|
38439cbc43 | ||
|
|
161c20b534 | ||
|
|
603696dad1 | ||
|
|
6b2ad5a7cc | ||
|
|
88063d4d87 | ||
|
|
8956a99745 | ||
|
|
0f0c0ec0d0 | ||
|
|
e5932e2c33 | ||
|
|
84ec8f5f3c | ||
|
|
661c5a269f | ||
|
|
5c6d7cae66 | ||
|
|
fcc73859b7 | ||
|
|
36645b0319 | ||
|
|
a62575e3cf | ||
|
|
d7af3d3c03 | ||
|
|
130ebb7d5f | ||
|
|
2af845ebd3 | ||
|
|
8e4d1701e6 | ||
|
|
4d16b6708f | ||
|
|
34ee08be25 | ||
|
|
d66a76a09e | ||
|
|
0d801c9766 | ||
|
|
b82b676fdb | ||
|
|
3494c0f64f | ||
|
|
311098adc2 | ||
|
|
58182e2083 | ||
|
|
a62ae43274 | ||
|
|
f256610e08 | ||
|
|
983d048219 | ||
|
|
3c6033f904 | ||
|
|
ef2bd2b59d | ||
|
|
04d70e9aa8 | ||
|
|
a2587ffc0a | ||
|
|
6776e7bb3d | ||
|
|
4c05064294 | ||
|
|
c135f1394f | ||
|
|
d68f4215f1 | ||
|
|
af562f3c3a | ||
|
|
7b949716bc | ||
|
|
d3e256289b | ||
|
|
3688e762b1 | ||
|
|
249f1a7af4 | ||
|
|
2de26030ff | ||
|
|
fcc76fb8d0 | ||
|
|
88d5b7095e | ||
|
|
b0e55d88de | ||
|
|
370ad3e928 | ||
|
|
07bf77d2dd | ||
|
|
a5ec65f3c0 | ||
|
|
522b318fd9 | ||
|
|
9eb2a4033f | ||
|
|
e87b0c393a | ||
|
|
1fb7e665fa | ||
|
|
7ea476d787 | ||
|
|
8260d07d61 | ||
|
|
ac0b4e6514 | ||
|
|
27b2f8cf27 | ||
|
|
27b5737f65 | ||
|
|
55b2e0292f | ||
|
|
464d83e70f | ||
|
|
614255a73a | ||
|
|
90d15e1346 | ||
|
|
b0e2ea64e9 | ||
|
|
1da05e239d | ||
|
|
fe7f0db81f | ||
|
|
983153e620 | ||
|
|
6fe791dcf2 | ||
|
|
1ad406c7dd | ||
|
|
4e032e11b1 | ||
|
|
ea34516d73 | ||
|
|
e1145f35ee | ||
|
|
6864775b8a | ||
|
|
f28721b847 | ||
|
|
2dc174fd9d | ||
|
|
07142d0410 | ||
|
|
41bb16ca30 | ||
|
|
d8f1034858 | ||
|
|
52b3c49cdb | ||
|
|
c5cb1a5e96 | ||
|
|
92d9d3232c | ||
|
|
9c4e0464f0 | ||
|
|
72d25754fd | ||
|
|
1465a0ba59 | ||
|
|
ac8ce28286 | ||
|
|
c4b06e1915 | ||
|
|
f77675a8a3 | ||
|
|
b907c1fd03 | ||
|
|
fba86bf653 | ||
|
|
b18ebcc38d | ||
|
|
4f7f18458e | ||
|
|
d412196052 | ||
|
|
1d140d8fd2 | ||
|
|
6948a25b09 |
@@ -313,8 +313,8 @@ module.exports = class NbdClient {
|
||||
const exportSize = this.#exportSize
|
||||
const chunkSize = 2 * 1024 * 1024
|
||||
indexGenerator = function* () {
|
||||
const nbBlocks = Math.ceil(exportSize / chunkSize)
|
||||
for (let index = 0; index < nbBlocks; index++) {
|
||||
const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
|
||||
for (let index = 0; BigInt(index) < nbBlocks; index++) {
|
||||
yield { index, size: chunkSize }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
'use strict'
|
||||
const NbdClient = require('./index.js')
|
||||
const { spawn } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const { test } = require('tap')
|
||||
const tmp = require('tmp')
|
||||
const { pFromCallback } = require('promise-toolbox')
|
||||
const { asyncEach } = require('@vates/async-each')
|
||||
|
||||
const FILE_SIZE = 2 * 1024 * 1024
|
||||
|
||||
async function createTempFile(size) {
|
||||
const tmpPath = await pFromCallback(cb => tmp.file(cb))
|
||||
const data = Buffer.alloc(size, 0)
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
data.writeUInt32BE(i, i)
|
||||
}
|
||||
await fs.writeFile(tmpPath, data)
|
||||
|
||||
return tmpPath
|
||||
}
|
||||
|
||||
test('it works with unsecured network', async tap => {
|
||||
const path = await createTempFile(FILE_SIZE)
|
||||
|
||||
const nbdServer = spawn(
|
||||
'nbdkit',
|
||||
[
|
||||
'file',
|
||||
path,
|
||||
'--newstyle', //
|
||||
'--exit-with-parent',
|
||||
'--read-only',
|
||||
'--export-name=MY_SECRET_EXPORT',
|
||||
],
|
||||
{
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
}
|
||||
)
|
||||
|
||||
const client = new NbdClient({
|
||||
address: 'localhost',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
secure: false,
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
tap.equal(client.exportSize, BigInt(FILE_SIZE))
|
||||
const CHUNK_SIZE = 128 * 1024 // non default size
|
||||
const indexes = []
|
||||
for (let i = 0; i < FILE_SIZE / CHUNK_SIZE; i++) {
|
||||
indexes.push(i)
|
||||
}
|
||||
// read mutiple blocks in parallel
|
||||
await asyncEach(
|
||||
indexes,
|
||||
async i => {
|
||||
const block = await client.readBlock(i, CHUNK_SIZE)
|
||||
let blockOk = true
|
||||
let firstFail
|
||||
for (let j = 0; j < CHUNK_SIZE; j += 4) {
|
||||
const wanted = i * CHUNK_SIZE + j
|
||||
const found = block.readUInt32BE(j)
|
||||
blockOk = blockOk && found === wanted
|
||||
if (!blockOk && firstFail === undefined) {
|
||||
firstFail = j
|
||||
}
|
||||
}
|
||||
tap.ok(blockOk, `check block ${i} content`)
|
||||
},
|
||||
{ concurrency: 8 }
|
||||
)
|
||||
await client.disconnect()
|
||||
nbdServer.kill()
|
||||
await fs.unlink(path)
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^1.3.1"
|
||||
"xen-api": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.3.0",
|
||||
@@ -31,6 +31,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test-integration": "tap --lines 70 --functions 36 --branches 54 --statements 69 *.integ.js"
|
||||
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.js"
|
||||
}
|
||||
}
|
||||
|
||||
182
@vates/nbd-client/tests/ca-cert.pem
Normal file
182
@vates/nbd-client/tests/ca-cert.pem
Normal file
@@ -0,0 +1,182 @@
|
||||
Public Key Info:
|
||||
Public Key Algorithm: RSA
|
||||
Key Security Level: High (3072 bits)
|
||||
|
||||
modulus:
|
||||
00:be:92:be:df:de:0a:ab:38:fc:1a:c0:1a:58:4d:86
|
||||
b8:1f:25:10:7d:19:05:17:bf:02:3d:e9:ef:f8:c0:04
|
||||
5d:6f:98:de:5c:dd:c3:0f:e2:61:61:e4:b5:9c:42:ac
|
||||
3e:af:fd:30:10:e1:54:32:66:75:f6:80:90:85:05:a0
|
||||
6a:14:a2:6f:a7:2e:f0:f3:52:94:2a:f2:34:fc:0d:b4
|
||||
fb:28:5d:1c:11:5c:59:6e:63:34:ba:b3:fd:73:b1:48
|
||||
35:00:84:53:da:6a:9b:84:ab:64:b1:a1:2b:3a:d1:5a
|
||||
d7:13:7c:12:2a:4e:72:e9:96:d6:30:74:c5:71:05:14
|
||||
4b:2d:01:94:23:67:4e:37:3c:1e:c1:a0:bc:34:04:25
|
||||
21:11:fb:4b:6b:53:74:8f:90:93:57:af:7f:3b:78:d6
|
||||
a4:87:fe:7d:ed:20:11:8b:70:54:67:b8:c9:f5:c0:6b
|
||||
de:4e:e7:a5:79:ff:f7:ad:cf:10:57:f5:51:70:7b:54
|
||||
68:28:9e:b9:c2:10:7b:ab:aa:11:47:9f:ec:e6:2f:09
|
||||
44:4a:88:5b:dd:8c:10:b4:c4:03:25:06:d9:e0:9f:a0
|
||||
0d:cf:94:4b:3b:fa:a5:17:2c:e4:67:c4:17:6a:ab:d8
|
||||
c8:7a:16:41:b9:91:b7:9c:ae:8c:94:be:26:61:51:71
|
||||
c1:a6:39:39:97:75:28:a9:0e:21:ea:f0:bd:71:4a:8c
|
||||
e1:f8:1d:a9:22:2f:10:a8:1b:e5:a4:9a:fd:0f:fa:c6
|
||||
20:bc:96:99:79:c6:ba:a4:1f:3e:d4:91:c5:af:bb:71
|
||||
0a:5a:ef:69:9c:64:69:ce:5a:fe:3f:c2:24:f4:26:d4
|
||||
3d:ab:ab:9a:f0:f6:f1:b1:64:a9:f4:e2:34:6a:ab:2e
|
||||
95:47:b9:07:5a:39:c6:95:9c:a9:e8:ed:71:dd:c1:21
|
||||
16:c8:2d:4c:2c:af:06:9d:c6:fa:fe:c5:2a:6c:b4:c3
|
||||
d5:96:fc:5e:fd:ec:1c:30:b4:9d:cb:29:ef:a8:50:1c
|
||||
21:
|
||||
|
||||
public exponent:
|
||||
01:00:01:
|
||||
|
||||
private exponent:
|
||||
25:37:c5:7d:35:01:02:65:73:9e:c9:cb:9b:59:30:a9
|
||||
3e:b3:df:5f:7f:06:66:97:d0:19:45:59:af:4b:d8:ce
|
||||
62:a0:09:35:3b:bd:ff:99:27:89:95:bf:fe:0f:6b:52
|
||||
26:ce:9c:97:7f:5a:11:29:bf:79:ef:ab:c9:be:ca:90
|
||||
4d:0d:58:1e:df:65:01:30:2c:6d:a2:b5:c4:4f:ec:fb
|
||||
6b:eb:9b:32:ac:c5:6e:70:83:78:be:f4:0d:a7:1e:c1
|
||||
f3:22:e4:b9:70:3e:85:0f:6f:ef:dc:d8:f3:78:b5:73
|
||||
f1:83:36:8c:fa:9b:28:91:63:ad:3c:f0:de:5c:ae:94
|
||||
eb:ea:36:03:20:06:bf:74:c7:50:eb:52:36:1a:65:21
|
||||
eb:40:17:7f:93:61:dd:33:d0:02:bc:ec:6d:31:f1:41
|
||||
5a:a9:d1:f0:00:66:4c:c4:18:47:d5:67:e3:cd:bb:83
|
||||
44:07:ab:62:83:21:dc:d8:e6:89:37:08:bb:9d:ea:62
|
||||
c2:5d:ce:85:c2:dc:48:27:0c:a4:23:61:b7:30:e7:26
|
||||
44:dc:1e:5c:2e:16:35:2b:2e:a6:e6:a4:ce:1f:9b:e9
|
||||
fe:96:fa:49:1d:fb:2a:df:bc:bf:46:da:52:f8:37:8a
|
||||
84:ab:e4:73:e6:46:56:b5:b4:3d:e1:63:eb:02:8e:d7
|
||||
67:96:c4:dc:28:6d:6b:b6:0c:a3:0b:db:87:29:ad:f9
|
||||
ec:73:b6:55:a3:40:32:13:84:c7:2f:33:74:04:dc:42
|
||||
00:11:9c:fb:fc:62:35:b3:82:c3:3c:28:80:e8:09:a8
|
||||
97:c7:c1:2e:3d:27:fa:4f:9b:fc:c2:34:58:41:5c:a1
|
||||
e2:70:2e:2f:82:ad:bd:bd:8e:dd:23:12:25:de:89:70
|
||||
60:75:48:90:80:ac:55:74:51:6f:49:9e:7f:63:41:8b
|
||||
3c:b1:f5:c3:6b:4b:5a:50:a6:4d:38:e8:82:c2:04:c8
|
||||
30:fd:06:9b:c1:04:27:b6:63:3a:5e:f5:4d:00:c3:d1
|
||||
|
||||
|
||||
prime1:
|
||||
00:f6:00:2e:7d:89:61:24:16:5e:87:ca:18:6c:03:b8
|
||||
b4:33:df:4a:a7:7f:db:ed:39:15:41:12:61:4f:4e:b4
|
||||
de:ab:29:d9:0c:6c:01:7e:53:2e:ee:e7:5f:a2:e4:6d
|
||||
c6:4b:07:4e:d8:a3:ae:45:06:97:bd:18:a3:e9:dd:29
|
||||
54:64:6d:f0:af:08:95:ae:ae:3e:71:63:76:2a:a1:18
|
||||
c4:b1:fc:bc:3d:42:15:74:b3:c5:38:1f:5d:92:f1:b2
|
||||
c6:3f:10:fe:35:1a:c6:b1:ce:70:38:ff:08:5c:de:61
|
||||
79:c7:50:91:22:4d:e9:c8:18:49:e2:5c:91:84:86:e2
|
||||
4d:0f:6e:9b:0d:81:df:aa:f3:59:75:56:e9:33:18:dd
|
||||
ab:39:da:e2:25:01:05:a1:6e:23:59:15:2c:89:35:c7
|
||||
ae:9c:c7:ea:88:9a:1a:f3:48:07:11:82:59:79:8c:62
|
||||
53:06:37:30:14:b3:82:b1:50:fc:ae:b8:f7:1c:57:44
|
||||
7d:
|
||||
|
||||
prime2:
|
||||
00:c6:51:cc:dc:88:2e:cf:98:90:10:19:e0:d3:a4:d1
|
||||
3f:dc:b0:29:d3:bb:26:ee:eb:00:17:17:d1:d1:bb:9b
|
||||
34:b1:4e:af:b5:6c:1c:54:53:b4:bb:55:da:f7:78:cd
|
||||
38:b4:2e:3a:8c:63:80:3b:64:9c:b4:2b:cd:dd:50:0b
|
||||
05:d2:00:7a:df:8e:c3:e6:29:e0:9c:d8:40:b7:11:09
|
||||
f4:38:df:f6:ed:93:1e:18:d4:93:fa:8d:ee:82:9c:0f
|
||||
c1:88:26:84:9d:4f:ae:8a:17:d5:55:54:4c:c6:0a:ac
|
||||
4d:ec:33:51:68:0f:4b:92:2e:04:57:fe:15:f5:00:46
|
||||
5c:8e:ad:09:2c:e7:df:d5:36:7a:4e:bd:da:21:22:d7
|
||||
58:b4:72:93:94:af:34:cc:e2:b8:d0:4f:0b:5d:97:08
|
||||
12:19:17:34:c5:15:49:00:48:56:13:b8:45:4e:3b:f8
|
||||
bc:d5:ab:d9:6d:c2:4a:cc:01:1a:53:4d:46:50:49:3b
|
||||
75:
|
||||
|
||||
coefficient:
|
||||
63:67:50:29:10:6a:85:a3:dc:51:90:20:76:86:8c:83
|
||||
8e:d5:ff:aa:75:fd:b5:f8:31:b0:96:6c:18:1d:5b:ed
|
||||
a4:2e:47:8d:9c:c2:1e:2c:a8:6d:4b:10:a5:c2:53:46
|
||||
8a:9a:84:91:d7:fc:f5:cc:03:ce:b9:3d:5c:01:d2:27
|
||||
99:7b:79:89:4f:a1:12:e3:05:5d:ee:10:f6:8c:e6:ce
|
||||
5e:da:32:56:6d:6f:eb:32:b4:75:7b:94:49:d8:2d:9e
|
||||
4d:19:59:2e:e4:0b:bc:95:df:df:65:67:a1:dd:c6:2b
|
||||
99:f4:76:e8:9f:fa:57:1d:ca:f9:58:a9:ce:9b:30:5c
|
||||
42:8a:ba:05:e7:e2:15:45:25:bc:e9:68:c1:8b:1a:37
|
||||
cc:e1:aa:45:2e:94:f5:81:47:1e:64:7f:c0:c1:b7:a8
|
||||
21:58:18:a9:a0:ed:e0:27:75:bf:65:81:6b:e4:1d:5a
|
||||
b7:7e:df:d8:28:c6:36:21:19:c8:6e:da:ca:9e:da:84
|
||||
|
||||
|
||||
exp1:
|
||||
00:ba:d7:fe:77:a9:0d:98:2c:49:56:57:c0:5e:e2:20
|
||||
ba:f6:1f:26:03:bc:d0:5d:08:9b:45:16:61:c4:ab:e2
|
||||
22:b1:dc:92:17:a6:3d:28:26:a4:22:1e:a8:7b:ff:86
|
||||
05:33:5d:74:9c:85:0d:cb:2d:ab:b8:9b:6b:7c:28:57
|
||||
c8:da:92:ca:59:17:6b:21:07:05:34:78:37:fb:3e:ea
|
||||
a2:13:12:04:23:7e:fa:ee:ed:cf:e0:c5:a9:fb:ff:0a
|
||||
2b:1b:21:9c:02:d7:b8:8c:ba:60:70:59:fc:8f:14:f4
|
||||
f2:5a:d9:ad:b2:61:7d:2c:56:8e:5f:98:b1:89:f8:2d
|
||||
10:1c:a5:84:ad:28:b4:aa:92:34:a3:34:04:e1:a3:84
|
||||
52:16:1a:52:e3:8a:38:2d:99:8a:cd:91:90:87:12:ca
|
||||
fc:ab:e6:08:14:03:00:6f:41:88:e4:da:9d:7c:fd:8c
|
||||
7c:c4:de:cb:ed:1d:3f:29:d0:7a:6b:76:df:71:ae:32
|
||||
bd:
|
||||
|
||||
exp2:
|
||||
4a:e9:d3:6c:ea:b4:64:0e:c9:3c:8b:c9:f5:a8:a8:b2
|
||||
6a:f6:d0:95:fe:78:32:7f:ea:c4:ce:66:9f:c7:32:55
|
||||
b1:34:7c:03:18:17:8b:73:23:2e:30:bc:4a:07:03:de
|
||||
8b:91:7a:e4:55:21:b7:4d:c6:33:f8:e8:06:d5:99:94
|
||||
55:43:81:26:b9:93:1e:7a:6b:32:54:2d:fd:f9:1d:bd
|
||||
77:4e:82:c4:33:72:87:06:a5:ef:5b:75:e1:38:7a:6b
|
||||
2c:b7:00:19:3c:64:3e:1d:ca:a4:34:f7:db:47:64:d6
|
||||
fa:86:58:15:ea:d1:2d:22:dc:d9:30:4d:b3:02:ab:91
|
||||
83:03:b2:17:98:6f:60:e6:f7:44:8f:4a:ba:81:a2:bf
|
||||
0b:4a:cc:9c:b9:a2:44:52:d0:65:3f:b6:97:5f:d9:d8
|
||||
9c:49:bb:d1:46:bd:10:b2:42:71:a8:85:e5:8b:99:e6
|
||||
1b:00:93:5d:76:ab:32:6c:a8:39:17:53:9c:38:4d:91
|
||||
|
||||
|
||||
|
||||
Public Key PIN:
|
||||
pin-sha256:ISh/UeFjUG5Gwrpx6hMUGQPvg9wOKjOkHmRbs4YjZqs=
|
||||
Public Key ID:
|
||||
sha256:21287f51e163506e46c2ba71ea13141903ef83dc0e2a33a41e645bb3862366ab
|
||||
sha1:1a48455111ac45fb5807c5cdb7b20b896c52f0b6
|
||||
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIG4wIBAAKCAYEAvpK+394Kqzj8GsAaWE2GuB8lEH0ZBRe/Aj3p7/jABF1vmN5c
|
||||
3cMP4mFh5LWcQqw+r/0wEOFUMmZ19oCQhQWgahSib6cu8PNSlCryNPwNtPsoXRwR
|
||||
XFluYzS6s/1zsUg1AIRT2mqbhKtksaErOtFa1xN8EipOcumW1jB0xXEFFEstAZQj
|
||||
Z043PB7BoLw0BCUhEftLa1N0j5CTV69/O3jWpIf+fe0gEYtwVGe4yfXAa95O56V5
|
||||
//etzxBX9VFwe1RoKJ65whB7q6oRR5/s5i8JREqIW92MELTEAyUG2eCfoA3PlEs7
|
||||
+qUXLORnxBdqq9jIehZBuZG3nK6MlL4mYVFxwaY5OZd1KKkOIerwvXFKjOH4Haki
|
||||
LxCoG+Wkmv0P+sYgvJaZeca6pB8+1JHFr7txClrvaZxkac5a/j/CJPQm1D2rq5rw
|
||||
9vGxZKn04jRqqy6VR7kHWjnGlZyp6O1x3cEhFsgtTCyvBp3G+v7FKmy0w9WW/F79
|
||||
7BwwtJ3LKe+oUBwhAgMBAAECggGAJTfFfTUBAmVznsnLm1kwqT6z319/BmaX0BlF
|
||||
Wa9L2M5ioAk1O73/mSeJlb/+D2tSJs6cl39aESm/ee+ryb7KkE0NWB7fZQEwLG2i
|
||||
tcRP7Ptr65syrMVucIN4vvQNpx7B8yLkuXA+hQ9v79zY83i1c/GDNoz6myiRY608
|
||||
8N5crpTr6jYDIAa/dMdQ61I2GmUh60AXf5Nh3TPQArzsbTHxQVqp0fAAZkzEGEfV
|
||||
Z+PNu4NEB6tigyHc2OaJNwi7nepiwl3OhcLcSCcMpCNhtzDnJkTcHlwuFjUrLqbm
|
||||
pM4fm+n+lvpJHfsq37y/RtpS+DeKhKvkc+ZGVrW0PeFj6wKO12eWxNwobWu2DKML
|
||||
24cprfnsc7ZVo0AyE4THLzN0BNxCABGc+/xiNbOCwzwogOgJqJfHwS49J/pPm/zC
|
||||
NFhBXKHicC4vgq29vY7dIxIl3olwYHVIkICsVXRRb0mef2NBizyx9cNrS1pQpk04
|
||||
6ILCBMgw/QabwQQntmM6XvVNAMPRAoHBAPYALn2JYSQWXofKGGwDuLQz30qnf9vt
|
||||
ORVBEmFPTrTeqynZDGwBflMu7udfouRtxksHTtijrkUGl70Yo+ndKVRkbfCvCJWu
|
||||
rj5xY3YqoRjEsfy8PUIVdLPFOB9dkvGyxj8Q/jUaxrHOcDj/CFzeYXnHUJEiTenI
|
||||
GEniXJGEhuJND26bDYHfqvNZdVbpMxjdqzna4iUBBaFuI1kVLIk1x66cx+qImhrz
|
||||
SAcRgll5jGJTBjcwFLOCsVD8rrj3HFdEfQKBwQDGUczciC7PmJAQGeDTpNE/3LAp
|
||||
07sm7usAFxfR0bubNLFOr7VsHFRTtLtV2vd4zTi0LjqMY4A7ZJy0K83dUAsF0gB6
|
||||
347D5ingnNhAtxEJ9Djf9u2THhjUk/qN7oKcD8GIJoSdT66KF9VVVEzGCqxN7DNR
|
||||
aA9Lki4EV/4V9QBGXI6tCSzn39U2ek692iEi11i0cpOUrzTM4rjQTwtdlwgSGRc0
|
||||
xRVJAEhWE7hFTjv4vNWr2W3CSswBGlNNRlBJO3UCgcEAutf+d6kNmCxJVlfAXuIg
|
||||
uvYfJgO80F0Im0UWYcSr4iKx3JIXpj0oJqQiHqh7/4YFM110nIUNyy2ruJtrfChX
|
||||
yNqSylkXayEHBTR4N/s+6qITEgQjfvru7c/gxan7/worGyGcAte4jLpgcFn8jxT0
|
||||
8lrZrbJhfSxWjl+YsYn4LRAcpYStKLSqkjSjNATho4RSFhpS44o4LZmKzZGQhxLK
|
||||
/KvmCBQDAG9BiOTanXz9jHzE3svtHT8p0Hprdt9xrjK9AoHASunTbOq0ZA7JPIvJ
|
||||
9aiosmr20JX+eDJ/6sTOZp/HMlWxNHwDGBeLcyMuMLxKBwPei5F65FUht03GM/jo
|
||||
BtWZlFVDgSa5kx56azJULf35Hb13ToLEM3KHBqXvW3XhOHprLLcAGTxkPh3KpDT3
|
||||
20dk1vqGWBXq0S0i3NkwTbMCq5GDA7IXmG9g5vdEj0q6gaK/C0rMnLmiRFLQZT+2
|
||||
l1/Z2JxJu9FGvRCyQnGoheWLmeYbAJNddqsybKg5F1OcOE2RAoHAY2dQKRBqhaPc
|
||||
UZAgdoaMg47V/6p1/bX4MbCWbBgdW+2kLkeNnMIeLKhtSxClwlNGipqEkdf89cwD
|
||||
zrk9XAHSJ5l7eYlPoRLjBV3uEPaM5s5e2jJWbW/rMrR1e5RJ2C2eTRlZLuQLvJXf
|
||||
32Vnod3GK5n0duif+lcdyvlYqc6bMFxCiroF5+IVRSW86WjBixo3zOGqRS6U9YFH
|
||||
HmR/wMG3qCFYGKmg7eAndb9lgWvkHVq3ft/YKMY2IRnIbtrKntqE
|
||||
-----END RSA PRIVATE KEY-----
|
||||
169
@vates/nbd-client/tests/nbdclient.integ.js
Normal file
169
@vates/nbd-client/tests/nbdclient.integ.js
Normal file
@@ -0,0 +1,169 @@
|
||||
'use strict'
|
||||
const NbdClient = require('../index.js')
|
||||
const { spawn, exec } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const { test } = require('tap')
|
||||
const tmp = require('tmp')
|
||||
const { pFromCallback } = require('promise-toolbox')
|
||||
const { Socket } = require('node:net')
|
||||
const { NBD_DEFAULT_PORT } = require('../constants.js')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
async function createTempFile(size) {
|
||||
const tmpPath = await pFromCallback(cb => tmp.file(cb))
|
||||
const data = Buffer.alloc(size, 0)
|
||||
for (let i = 0; i < size; i += 4) {
|
||||
data.writeUInt32BE(i, i)
|
||||
}
|
||||
await fs.writeFile(tmpPath, data)
|
||||
|
||||
return tmpPath
|
||||
}
|
||||
|
||||
async function spawnNbdKit(path) {
|
||||
let tries = 5
|
||||
// wait for server to be ready
|
||||
|
||||
const nbdServer = spawn(
|
||||
'nbdkit',
|
||||
[
|
||||
'file',
|
||||
path,
|
||||
'--newstyle', //
|
||||
'--exit-with-parent',
|
||||
'--read-only',
|
||||
'--export-name=MY_SECRET_EXPORT',
|
||||
'--tls=on',
|
||||
'--tls-certificates=./tests/',
|
||||
// '--tls-verify-peer',
|
||||
// '--verbose',
|
||||
'--exit-with-parent',
|
||||
],
|
||||
{
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
}
|
||||
)
|
||||
nbdServer.on('error', err => {
|
||||
console.error(err)
|
||||
})
|
||||
do {
|
||||
try {
|
||||
const socket = new Socket()
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.connect(NBD_DEFAULT_PORT, 'localhost')
|
||||
socket.once('error', reject)
|
||||
socket.once('connect', resolve)
|
||||
})
|
||||
socket.destroy()
|
||||
break
|
||||
} catch (err) {
|
||||
tries--
|
||||
if (tries <= 0) {
|
||||
throw err
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
} while (true)
|
||||
return nbdServer
|
||||
}
|
||||
|
||||
async function killNbdKit() {
|
||||
return new Promise((resolve, reject) =>
|
||||
exec('pkill -9 -f -o nbdkit', err => {
|
||||
err ? reject(err) : resolve()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
test('it works with unsecured network', async tap => {
|
||||
const path = await createTempFile(FILE_SIZE)
|
||||
|
||||
let nbdServer = await spawnNbdKit(path)
|
||||
const client = new NbdClient(
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgIUeHpQ0IeD6BmP2zgsv3LV3J4BI/EwDQYJKoZIhvcNAQEL
|
||||
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA1MTcxMzU1MzBaFw0yNDA1
|
||||
MTYxMzU1MzBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQC/8wLopj/iZY6ijmpvgCJsl+zY0hQZQcIoaCs0H75u
|
||||
8PPSzHedtOLURAkJeMmIS40UY/eIvHh7yZolevaSJLNT2Iolscvc2W9NCF4N1V6y
|
||||
zs4pDzP+YPF7Q8ldNaQIX0bAk4PfaMSM+pLh67u+uI40732AfQqD01BNCTD/uHRB
|
||||
lKnQuqQpe9UM9UzRRVejpu1r19D4dJruAm6y2SJVTeT4a1sSJixl6I1YPmt80FJh
|
||||
gq9O2KRGbXp1xIjemWgW99MHg63pTgxEiULwdJOGgmqGRDzgZKJS5UUpxe/ViEO4
|
||||
59I18vIkgibaRYhENgmnP3lIzTOLlUe07tbSML5RGBbBAgMBAAGjUzBRMB0GA1Ud
|
||||
DgQWBBR/8+zYoL0H0LdWfULHg1LynFdSbzAfBgNVHSMEGDAWgBR/8+zYoL0H0LdW
|
||||
fULHg1LynFdSbzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBD
|
||||
OF5bTmbDEGoZ6OuQaI0vyya/T4FeaoWmh22gLeL6dEEmUVGJ1NyMTOvG9GiGJ8OM
|
||||
QhD1uHJei45/bXOYIDGey2+LwLWye7T4vtRFhf8amYh0ReyP/NV4/JoR/U3pTSH6
|
||||
tns7GZ4YWdwUhvOOlm17EQKVO/hP3t9mp74gcjdL4bCe5MYSheKuNACAakC1OR0U
|
||||
ZakJMP9ijvQuq8spfCzrK+NbHKNHR9tEgQw+ax/t1Au4dGVtFbcoxqCrx2kTl0RP
|
||||
CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
|
||||
57ABhfYpMlZ4aHjuN1bL
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
},
|
||||
{
|
||||
readAhead: 2,
|
||||
}
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
tap.equal(client.exportSize, BigInt(FILE_SIZE))
|
||||
const CHUNK_SIZE = 1024 * 1024 // non default size
|
||||
const indexes = []
|
||||
for (let i = 0; i < FILE_SIZE / CHUNK_SIZE; i++) {
|
||||
indexes.push(i)
|
||||
}
|
||||
const nbdIterator = client.readBlocks(function* () {
|
||||
for (const index of indexes) {
|
||||
yield { index, size: CHUNK_SIZE }
|
||||
}
|
||||
})
|
||||
let i = 0
|
||||
for await (const block of nbdIterator) {
|
||||
let blockOk = true
|
||||
let firstFail
|
||||
for (let j = 0; j < CHUNK_SIZE; j += 4) {
|
||||
const wanted = i * CHUNK_SIZE + j
|
||||
const found = block.readUInt32BE(j)
|
||||
blockOk = blockOk && found === wanted
|
||||
if (!blockOk && firstFail === undefined) {
|
||||
firstFail = j
|
||||
}
|
||||
}
|
||||
tap.ok(blockOk, `check block ${i} content`)
|
||||
i++
|
||||
|
||||
// flaky server is flaky
|
||||
if (i % 7 === 0) {
|
||||
// kill the older nbdkit process
|
||||
await killNbdKit()
|
||||
nbdServer = await spawnNbdKit(path)
|
||||
}
|
||||
}
|
||||
|
||||
// we can reuse the conneciton to read other blocks
|
||||
// default iterator
|
||||
const nbdIteratorWithDefaultBlockIterator = client.readBlocks()
|
||||
let nb = 0
|
||||
for await (const block of nbdIteratorWithDefaultBlockIterator) {
|
||||
nb++
|
||||
tap.equal(block.length, 2 * 1024 * 1024)
|
||||
}
|
||||
|
||||
tap.equal(nb, 5)
|
||||
assert.rejects(() => client.readBlock(100, CHUNK_SIZE))
|
||||
|
||||
await client.disconnect()
|
||||
// double disconnection shouldn't pose any problem
|
||||
await client.disconnect()
|
||||
nbdServer.kill()
|
||||
await fs.unlink(path)
|
||||
})
|
||||
21
@vates/nbd-client/tests/server-cert.pem
Normal file
21
@vates/nbd-client/tests/server-cert.pem
Normal file
@@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgIUeHpQ0IeD6BmP2zgsv3LV3J4BI/EwDQYJKoZIhvcNAQEL
|
||||
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA1MTcxMzU1MzBaFw0yNDA1
|
||||
MTYxMzU1MzBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQC/8wLopj/iZY6ijmpvgCJsl+zY0hQZQcIoaCs0H75u
|
||||
8PPSzHedtOLURAkJeMmIS40UY/eIvHh7yZolevaSJLNT2Iolscvc2W9NCF4N1V6y
|
||||
zs4pDzP+YPF7Q8ldNaQIX0bAk4PfaMSM+pLh67u+uI40732AfQqD01BNCTD/uHRB
|
||||
lKnQuqQpe9UM9UzRRVejpu1r19D4dJruAm6y2SJVTeT4a1sSJixl6I1YPmt80FJh
|
||||
gq9O2KRGbXp1xIjemWgW99MHg63pTgxEiULwdJOGgmqGRDzgZKJS5UUpxe/ViEO4
|
||||
59I18vIkgibaRYhENgmnP3lIzTOLlUe07tbSML5RGBbBAgMBAAGjUzBRMB0GA1Ud
|
||||
DgQWBBR/8+zYoL0H0LdWfULHg1LynFdSbzAfBgNVHSMEGDAWgBR/8+zYoL0H0LdW
|
||||
fULHg1LynFdSbzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBD
|
||||
OF5bTmbDEGoZ6OuQaI0vyya/T4FeaoWmh22gLeL6dEEmUVGJ1NyMTOvG9GiGJ8OM
|
||||
QhD1uHJei45/bXOYIDGey2+LwLWye7T4vtRFhf8amYh0ReyP/NV4/JoR/U3pTSH6
|
||||
tns7GZ4YWdwUhvOOlm17EQKVO/hP3t9mp74gcjdL4bCe5MYSheKuNACAakC1OR0U
|
||||
ZakJMP9ijvQuq8spfCzrK+NbHKNHR9tEgQw+ax/t1Au4dGVtFbcoxqCrx2kTl0RP
|
||||
CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
|
||||
57ABhfYpMlZ4aHjuN1bL
|
||||
-----END CERTIFICATE-----
|
||||
28
@vates/nbd-client/tests/server-key.pem
Normal file
28
@vates/nbd-client/tests/server-key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/8wLopj/iZY6i
|
||||
jmpvgCJsl+zY0hQZQcIoaCs0H75u8PPSzHedtOLURAkJeMmIS40UY/eIvHh7yZol
|
||||
evaSJLNT2Iolscvc2W9NCF4N1V6yzs4pDzP+YPF7Q8ldNaQIX0bAk4PfaMSM+pLh
|
||||
67u+uI40732AfQqD01BNCTD/uHRBlKnQuqQpe9UM9UzRRVejpu1r19D4dJruAm6y
|
||||
2SJVTeT4a1sSJixl6I1YPmt80FJhgq9O2KRGbXp1xIjemWgW99MHg63pTgxEiULw
|
||||
dJOGgmqGRDzgZKJS5UUpxe/ViEO459I18vIkgibaRYhENgmnP3lIzTOLlUe07tbS
|
||||
ML5RGBbBAgMBAAECggEATLYiafcTHfgnZmjTOad0WoDnC4n9tVBV948WARlUooLS
|
||||
duL3RQRHCLz9/ZaTuFA1XDpNcYyc/B/IZoU7aJGZR3+JSmJBjowpUphu+klVNNG4
|
||||
i6lDRrzYlUI0hfdLjHsDTDBIKi91KcB0lix/VkvsrVQvDHwsiR2ZAIiVWAWQFKrR
|
||||
5O3DhSTHbqyq47uR58rWr4Zf3zvZaUl841AS1yELzCiZqz7AenvyWphim0c0XA5d
|
||||
I63CEShntHnEAA9OMcP8+BNf/3AmqB4welY+m8elB3aJNH+j7DKq/AWqaM5nl2PC
|
||||
cS6qgpxwOyTxEOyj1xhwK5ZMRR3heW3NfutIxSOPlwKBgQDB9ZkrBeeGVtCISO7C
|
||||
eCANzSLpeVrahTvaCSQLdPHsLRLDUc+5mxdpi3CaRlzYs3S1OWdAtyWX9mBryltF
|
||||
qDPhCNjFDyHok4D3wLEWdS9oUVwEKUM8fOPW3tXLLiMM7p4862Qo7LqnqHzPqsnz
|
||||
22iZo5yjcc7aLJ+VmFrbAowwOwKBgQD9WNCvczTd7Ymn7zEvdiAyNoS0OZ0orwEJ
|
||||
zGaxtjqVguGklNfrb/UB+eKNGE80+YnMiSaFc9IQPetLntZdV0L7kWYdCI8kGDNA
|
||||
DbVRCOp+z8DwAojlrb/zsYu23anQozT3WeHxVU66lNuyEQvSW2tJa8gN1htrD7uY
|
||||
5KLibYrBMwKBgEM0iiHyJcrSgeb2/mO7o7+keJhVSDm3OInP6QFfQAQJihrLWiKB
|
||||
rpcPjbCm+LzNUX8JqNEvpIMHB1nR/9Ye9frfSdzd5W3kzicKSVHywL5wkmWOtpFa
|
||||
5Mcq5wFDtzlf5MxO86GKhRJauwRptRgdyhySKFApuva1x4XaCIEiXNjJAoGBAN82
|
||||
t3c+HCBEv3o05rMYcrmLC1T3Rh6oQlPtwbVmByvfywsFEVCgrc/16MPD3VWhXuXV
|
||||
GRmPuE8THxLbead30M5xhvShq+xzXgRbj5s8Lc9ZIHbW5OLoOS1vCtgtaQcoJOyi
|
||||
Rs4pCVqe+QpktnO6lEZ2Libys+maTQEiwNibBxu9AoGAUG1V5aKMoXa7pmGeuFR6
|
||||
ES+1NDiCt6yDq9BsLZ+e2uqvWTkvTGLLwvH6xf9a0pnnILd0AUTKAAaoUdZS6++E
|
||||
cGob7fxMwEE+UETp0QBgLtfjtExMOFwr2avw8PV4CYEUkPUAm2OFB2Twh+d/PNfr
|
||||
FAxF1rN47SBPNbFI8N4TFsg=
|
||||
-----END PRIVATE KEY-----
|
||||
1
@vates/node-vsphere-soap/.npmignore
Symbolic link
1
@vates/node-vsphere-soap/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
22
@vates/node-vsphere-soap/LICENSE
Normal file
22
@vates/node-vsphere-soap/LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 reedog117
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
127
@vates/node-vsphere-soap/README.md
Normal file
127
@vates/node-vsphere-soap/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
forked from https://github.com/reedog117/node-vsphere-soap
|
||||
|
||||
# node-vsphere-soap
|
||||
|
||||
[](https://gitter.im/reedog117/node-vsphere-soap?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
This is a Node.js module to connect to VMware vCenter servers and/or ESXi hosts and perform operations using the [vSphere Web Services API]. If you're feeling really adventurous, you can use this module to port vSphere operations from other languages (such as the Perl, Python, and Go libraries that exist) and have fully native Node.js code controlling your VMware virtual infrastructure!
|
||||
|
||||
This is very much in alpha.
|
||||
|
||||
## Authors
|
||||
|
||||
- Patrick C - [@reedog117]
|
||||
|
||||
## Version
|
||||
|
||||
0.0.2-5
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
$ npm install node-vsphere-soap --save
|
||||
```
|
||||
|
||||
## Sample Code
|
||||
|
||||
### To connect to a vCenter server:
|
||||
|
||||
var nvs = require('node-vsphere-soap');
|
||||
var vc = new nvs.Client(host, user, password, sslVerify);
|
||||
vc.once('ready', function() {
|
||||
// perform work here
|
||||
});
|
||||
vc.once('error', function(err) {
|
||||
// handle error here
|
||||
});
|
||||
|
||||
#### Arguments
|
||||
|
||||
- host = hostname or IP of vCenter/ESX/ESXi server
|
||||
- user = username
|
||||
- password = password
|
||||
- sslVerify = true|false - set to false if you have self-signed/unverified certificates
|
||||
|
||||
#### Events
|
||||
|
||||
- ready = emits when session authenticated with server
|
||||
- error = emits when there's an error
|
||||
- _err_ contains the error
|
||||
|
||||
#### Client instance variables
|
||||
|
||||
- serviceContent - ServiceContent object retrieved by RetrieveServiceContent API call
|
||||
- userName - username of authenticated user
|
||||
- fullName - full name of authenticated user
|
||||
|
||||
### To run a command:
|
||||
|
||||
var vcCmd = vc.runCommand( commandToRun, arguments );
|
||||
vcCmd.once('result', function( result, raw, soapHeader) {
|
||||
// handle results
|
||||
});
|
||||
vcCmd.once('error', function( err) {
|
||||
// handle errors
|
||||
});
|
||||
|
||||
#### Arguments
|
||||
|
||||
- commandToRun = Method from the vSphere API
|
||||
- arguments = JSON document containing arguments to send
|
||||
|
||||
#### Events
|
||||
|
||||
- result = emits when session authenticated with server
|
||||
- _result_ contains the JSON-formatted result from the server
|
||||
- _raw_ contains the raw SOAP XML response from the server
|
||||
- _soapHeader_ contains any soapHeaders from the server
|
||||
- error = emits when there's an error
|
||||
- _err_ contains the error
|
||||
|
||||
Make sure you check out tests/vsphere-soap.test.js for examples on how to create commands to run
|
||||
|
||||
## Development
|
||||
|
||||
node-vsphere-soap uses a number of open source projects to work properly:
|
||||
|
||||
- [node.js] - evented I/O for the backend
|
||||
- [node-soap] - SOAP client for Node.js
|
||||
- [soap-cookie] - cookie authentication for the node-soap module
|
||||
- [lodash] - for quickly manipulating JSON
|
||||
- [lab] - testing engine
|
||||
- [code] - assertion engine used with lab
|
||||
|
||||
Want to contribute? Great!
|
||||
|
||||
### Todo's
|
||||
|
||||
- Write More Tests
|
||||
- Create Travis CI test harness with a fake vCenter Instance
|
||||
- Add Code Comments
|
||||
|
||||
### Testing
|
||||
|
||||
I have been testing on a Mac with node v0.10.36 and both ESXi and vCenter 5.5.
|
||||
|
||||
To edit tests, edit the file **test/vsphere-soap.test.js**
|
||||
|
||||
To point the module at your own vCenter/ESXi host, edit **config-test.stub.js** and save it as **config-test.js**
|
||||
|
||||
To run test scripts:
|
||||
|
||||
```sh
|
||||
$ npm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
[vSphere Web Services API]: http://pubs.vmware.com/vsphere-55/topic/com.vmware.wssdk.apiref.doc/right-pane.html
|
||||
[node-soap]: https://github.com/vpulim/node-soap
|
||||
[node.js]: http://nodejs.org/
|
||||
[soap-cookie]: https://github.com/shanestillwell/soap-cookie
|
||||
[code]: https://github.com/hapijs/code
|
||||
[lab]: https://github.com/hapijs/lab
|
||||
[lodash]: https://lodash.com/
|
||||
[@reedog117]: http://www.twitter.com/reedog117
|
||||
231
@vates/node-vsphere-soap/lib/client.js
Normal file
231
@vates/node-vsphere-soap/lib/client.js
Normal file
@@ -0,0 +1,231 @@
|
||||
'use strict'
|
||||
/*
|
||||
|
||||
node-vsphere-soap
|
||||
|
||||
client.js
|
||||
|
||||
This file creates the Client class
|
||||
|
||||
- when the class is instantiated, a connection will be made to the ESXi/vCenter server to verify that the creds are good
|
||||
- upon a bad login, the connnection will be terminated
|
||||
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events').EventEmitter
|
||||
const axios = require('axios')
|
||||
const https = require('node:https')
|
||||
const util = require('util')
|
||||
const soap = require('soap')
|
||||
const Cookie = require('soap-cookie') // required for session persistence
|
||||
// Client class
|
||||
// inherits from EventEmitter
|
||||
// possible events: connect, error, ready
|
||||
|
||||
function Client(vCenterHostname, username, password, sslVerify) {
|
||||
this.status = 'disconnected'
|
||||
this.reconnectCount = 0
|
||||
|
||||
sslVerify = typeof sslVerify !== 'undefined' ? sslVerify : false
|
||||
|
||||
EventEmitter.call(this)
|
||||
|
||||
// sslVerify argument handling
|
||||
if (sslVerify) {
|
||||
this.clientopts = {}
|
||||
} else {
|
||||
this.clientopts = {
|
||||
request: axios.create({
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
this.connectionInfo = {
|
||||
host: vCenterHostname,
|
||||
user: username,
|
||||
password,
|
||||
sslVerify,
|
||||
}
|
||||
|
||||
this._loginArgs = {
|
||||
userName: this.connectionInfo.user,
|
||||
password: this.connectionInfo.password,
|
||||
}
|
||||
|
||||
this._vcUrl = 'https://' + this.connectionInfo.host + '/sdk/vimService.wsdl'
|
||||
|
||||
// connect to the vCenter / ESXi host
|
||||
this.on('connect', this._connect)
|
||||
this.emit('connect')
|
||||
|
||||
// close session
|
||||
this.on('close', this._close)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
util.inherits(Client, EventEmitter)
|
||||
|
||||
Client.prototype.runCommand = function (command, args) {
|
||||
const self = this
|
||||
let cmdargs
|
||||
if (!args || args === null) {
|
||||
cmdargs = {}
|
||||
} else {
|
||||
cmdargs = args
|
||||
}
|
||||
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
// check if client has successfully connected
|
||||
if (self.status === 'ready' || self.status === 'connecting') {
|
||||
self.client.VimService.VimPort[command](cmdargs, function (err, result, raw, soapHeader) {
|
||||
if (err) {
|
||||
_soapErrorHandler(self, emitter, command, cmdargs, err)
|
||||
}
|
||||
if (command === 'Logout') {
|
||||
self.status = 'disconnected'
|
||||
process.removeAllListeners('beforeExit')
|
||||
}
|
||||
emitter.emit('result', result, raw, soapHeader)
|
||||
})
|
||||
} else {
|
||||
// if connection not ready or connecting, reconnect to instance
|
||||
if (self.status === 'disconnected') {
|
||||
self.emit('connect')
|
||||
}
|
||||
self.once('ready', function () {
|
||||
self.client.VimService.VimPort[command](cmdargs, function (err, result, raw, soapHeader) {
|
||||
if (err) {
|
||||
_soapErrorHandler(self, emitter, command, cmdargs, err)
|
||||
}
|
||||
if (command === 'Logout') {
|
||||
self.status = 'disconnected'
|
||||
process.removeAllListeners('beforeExit')
|
||||
}
|
||||
emitter.emit('result', result, raw, soapHeader)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return emitter
|
||||
}
|
||||
|
||||
Client.prototype.close = function () {
|
||||
const self = this
|
||||
|
||||
self.emit('close')
|
||||
}
|
||||
|
||||
Client.prototype._connect = function () {
|
||||
const self = this
|
||||
|
||||
if (self.status !== 'disconnected') {
|
||||
return
|
||||
}
|
||||
|
||||
self.status = 'connecting'
|
||||
|
||||
soap.createClient(
|
||||
self._vcUrl,
|
||||
self.clientopts,
|
||||
function (err, client) {
|
||||
if (err) {
|
||||
self.emit('error', err)
|
||||
throw err
|
||||
}
|
||||
|
||||
self.client = client // save client for later use
|
||||
|
||||
self
|
||||
.runCommand('RetrieveServiceContent', { _this: 'ServiceInstance' })
|
||||
.once('result', function (result, raw, soapHeader) {
|
||||
if (!result.returnval) {
|
||||
self.status = 'disconnected'
|
||||
self.emit('error', raw)
|
||||
return
|
||||
}
|
||||
|
||||
self.serviceContent = result.returnval
|
||||
self.sessionManager = result.returnval.sessionManager
|
||||
const loginArgs = { _this: self.sessionManager, ...self._loginArgs }
|
||||
|
||||
self
|
||||
.runCommand('Login', loginArgs)
|
||||
.once('result', function (result, raw, soapHeader) {
|
||||
self.authCookie = new Cookie(client.lastResponseHeaders)
|
||||
self.client.setSecurity(self.authCookie) // needed since vSphere SOAP WS uses cookies
|
||||
|
||||
self.userName = result.returnval.userName
|
||||
self.fullName = result.returnval.fullName
|
||||
self.reconnectCount = 0
|
||||
|
||||
self.status = 'ready'
|
||||
self.emit('ready')
|
||||
process.once('beforeExit', self._close)
|
||||
})
|
||||
.once('error', function (err) {
|
||||
self.status = 'disconnected'
|
||||
self.emit('error', err)
|
||||
})
|
||||
})
|
||||
.once('error', function (err) {
|
||||
self.status = 'disconnected'
|
||||
self.emit('error', err)
|
||||
})
|
||||
},
|
||||
self._vcUrl
|
||||
)
|
||||
}
|
||||
|
||||
Client.prototype._close = function () {
|
||||
const self = this
|
||||
|
||||
if (self.status === 'ready') {
|
||||
self
|
||||
.runCommand('Logout', { _this: self.sessionManager })
|
||||
.once('result', function () {
|
||||
self.status = 'disconnected'
|
||||
})
|
||||
.once('error', function () {
|
||||
/* don't care of error during disconnection */
|
||||
self.status = 'disconnected'
|
||||
})
|
||||
} else {
|
||||
self.status = 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
function _soapErrorHandler(self, emitter, command, args, err) {
|
||||
err = err || { body: 'general error' }
|
||||
|
||||
if (err.body.match(/session is not authenticated/)) {
|
||||
self.status = 'disconnected'
|
||||
process.removeAllListeners('beforeExit')
|
||||
|
||||
if (self.reconnectCount < 10) {
|
||||
self.reconnectCount += 1
|
||||
self
|
||||
.runCommand(command, args)
|
||||
.once('result', function (result, raw, soapHeader) {
|
||||
emitter.emit('result', result, raw, soapHeader)
|
||||
})
|
||||
.once('error', function (err) {
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
})
|
||||
} else {
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
}
|
||||
} else {
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// end
|
||||
exports.Client = Client
|
||||
38
@vates/node-vsphere-soap/package.json
Normal file
38
@vates/node-vsphere-soap/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@vates/node-vsphere-soap",
|
||||
"version": "1.0.0",
|
||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||
"main": "lib/client.js",
|
||||
"author": "reedog117",
|
||||
"repository": {
|
||||
"directory": "@vates/node-vsphere-soap",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"axios": "^1.4.0",
|
||||
"soap": "^1.0.0",
|
||||
"soap-cookie": "^0.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
},
|
||||
"keywords": [
|
||||
"vsphere",
|
||||
"vcenter",
|
||||
"api",
|
||||
"soap",
|
||||
"wsdl"
|
||||
],
|
||||
"preferGlobal": false,
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/node-vsphere-soap",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
}
|
||||
}
|
||||
15
@vates/node-vsphere-soap/test/config-test.stub.js
Normal file
15
@vates/node-vsphere-soap/test/config-test.stub.js
Normal file
@@ -0,0 +1,15 @@
|
||||
'use strict'
|
||||
|
||||
// place your own credentials here for a vCenter or ESXi server
|
||||
// this information will be used for connecting to a vCenter instance
|
||||
// for module testing
|
||||
// name the file config-test.js
|
||||
|
||||
const vCenterTestCreds = {
|
||||
vCenterIP: 'vcsa',
|
||||
vCenterUser: 'vcuser',
|
||||
vCenterPassword: 'vcpw',
|
||||
vCenter: true,
|
||||
}
|
||||
|
||||
exports.vCenterTestCreds = vCenterTestCreds
|
||||
140
@vates/node-vsphere-soap/test/vsphere-soap.test.js
Normal file
140
@vates/node-vsphere-soap/test/vsphere-soap.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
'use strict'
|
||||
|
||||
/*
|
||||
vsphere-soap.test.js
|
||||
|
||||
tests for the vCenterConnectionInstance class
|
||||
*/
|
||||
|
||||
const assert = require('assert')
|
||||
const { describe, it } = require('test')
|
||||
|
||||
const vc = require('../lib/client')
|
||||
|
||||
// eslint-disable-next-line n/no-missing-require
|
||||
const TestCreds = require('../config-test.js').vCenterTestCreds
|
||||
|
||||
const VItest = new vc.Client(TestCreds.vCenterIP, TestCreds.vCenterUser, TestCreds.vCenterPassword, false)
|
||||
|
||||
describe('Client object initialization:', function () {
|
||||
it('provides a successful login', { timeout: 5000 }, function (t, done) {
|
||||
VItest.once('ready', function () {
|
||||
assert.notEqual(VItest.userName, null)
|
||||
assert.notEqual(VItest.fullName, null)
|
||||
assert.notEqual(VItest.serviceContent, null)
|
||||
done()
|
||||
}).once('error', function (err) {
|
||||
console.error(err)
|
||||
// this should fail if there's a problem
|
||||
assert.notEqual(VItest.userName, null)
|
||||
assert.notEqual(VItest.fullName, null)
|
||||
assert.notEqual(VItest.serviceContent, null)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Client reconnection test:', function () {
|
||||
it('can successfully reconnect', { timeout: 5000 }, function (t, done) {
|
||||
VItest.runCommand('Logout', { _this: VItest.serviceContent.sessionManager })
|
||||
.once('result', function (result) {
|
||||
// now we're logged out, so let's try running a command to test automatic re-login
|
||||
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' })
|
||||
.once('result', function (result) {
|
||||
assert(result.returnval instanceof Date)
|
||||
done()
|
||||
})
|
||||
.once('error', function (err) {
|
||||
console.error(err)
|
||||
})
|
||||
})
|
||||
.once('error', function (err) {
|
||||
console.error(err)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// these tests don't work yet
|
||||
describe('Client tests - query commands:', function () {
|
||||
it('retrieves current time', { timeout: 5000 }, function (t, done) {
|
||||
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' }).once('result', function (result) {
|
||||
assert(result.returnval instanceof Date)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('retrieves current time 2 (check for event clobbering)', { timeout: 5000 }, function (t, done) {
|
||||
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' }).once('result', function (result) {
|
||||
assert(result.returnval instanceof Date)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('can obtain the names of all Virtual Machines in the inventory', { timeout: 20000 }, function (t, done) {
|
||||
// get property collector
|
||||
const propertyCollector = VItest.serviceContent.propertyCollector
|
||||
// get view manager
|
||||
const viewManager = VItest.serviceContent.viewManager
|
||||
// get root folder
|
||||
const rootFolder = VItest.serviceContent.rootFolder
|
||||
|
||||
let containerView, objectSpec, traversalSpec, propertySpec, propertyFilterSpec
|
||||
// this is the equivalent to
|
||||
VItest.runCommand('CreateContainerView', {
|
||||
_this: viewManager,
|
||||
container: rootFolder,
|
||||
type: ['VirtualMachine'],
|
||||
recursive: true,
|
||||
}).once('result', function (result) {
|
||||
// build all the data structures needed to query all the vm names
|
||||
containerView = result.returnval
|
||||
|
||||
objectSpec = {
|
||||
attributes: { 'xsi:type': 'ObjectSpec' }, // setting attributes xsi:type is important or else the server may mis-recognize types!
|
||||
obj: containerView,
|
||||
skip: true,
|
||||
}
|
||||
|
||||
traversalSpec = {
|
||||
attributes: { 'xsi:type': 'TraversalSpec' },
|
||||
name: 'traverseEntities',
|
||||
type: 'ContainerView',
|
||||
path: 'view',
|
||||
skip: false,
|
||||
}
|
||||
|
||||
objectSpec = { ...objectSpec, selectSet: [traversalSpec] }
|
||||
|
||||
propertySpec = {
|
||||
attributes: { 'xsi:type': 'PropertySpec' },
|
||||
type: 'VirtualMachine',
|
||||
pathSet: ['name'],
|
||||
}
|
||||
|
||||
propertyFilterSpec = {
|
||||
attributes: { 'xsi:type': 'PropertyFilterSpec' },
|
||||
propSet: [propertySpec],
|
||||
objectSet: [objectSpec],
|
||||
}
|
||||
// TODO: research why it fails if propSet is declared after objectSet
|
||||
|
||||
VItest.runCommand('RetrievePropertiesEx', {
|
||||
_this: propertyCollector,
|
||||
specSet: [propertyFilterSpec],
|
||||
options: { attributes: { type: 'RetrieveOptions' } },
|
||||
})
|
||||
.once('result', function (result, raw) {
|
||||
assert.notEqual(result.returnval.objects, null)
|
||||
if (Array.isArray(result.returnval.objects)) {
|
||||
assert.strictEqual(result.returnval.objects[0].obj.attributes.type, 'VirtualMachine')
|
||||
} else {
|
||||
assert.strictEqual(result.returnval.objects.obj.attributes.type, 'VirtualMachine')
|
||||
}
|
||||
done()
|
||||
})
|
||||
.once('error', function (err) {
|
||||
console.error('\n\nlast request : ' + VItest.client.lastRequest, err)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,8 @@
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
const task = new Task({
|
||||
// data in this object will be sent along the *start* event
|
||||
//
|
||||
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
|
||||
data: {
|
||||
// this object will be sent in the *start* event
|
||||
properties: {
|
||||
name: 'my task',
|
||||
},
|
||||
|
||||
@@ -16,13 +14,15 @@ const task = new Task({
|
||||
// this function is called each time this task or one of it's subtasks change state
|
||||
const { id, timestamp, type } = event
|
||||
if (type === 'start') {
|
||||
const { name, parentId } = event
|
||||
const { name, parentId, properties } = event
|
||||
} else if (type === 'end') {
|
||||
const { result, status } = event
|
||||
} else if (type === 'info' || type === 'warning') {
|
||||
const { data, message } = event
|
||||
} else if (type === 'property') {
|
||||
const { name, value } = event
|
||||
} else if (type === 'abortionRequested') {
|
||||
const { reason } = event
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -36,7 +36,6 @@ task.id
|
||||
// - pending
|
||||
// - success
|
||||
// - failure
|
||||
// - aborted
|
||||
task.status
|
||||
|
||||
// Triggers the abort signal associated to the task.
|
||||
@@ -89,6 +88,30 @@ const onProgress = makeOnProgress({
|
||||
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.
|
||||
|
||||
// timestamp at which the task started
|
||||
taskLog.start
|
||||
|
||||
// current status of the task as described in the previous section
|
||||
taskLog.status
|
||||
|
||||
// undefined or a dictionary of properties attached to the task
|
||||
taskLog.properties
|
||||
|
||||
// timestamp at which the abortion was requested, undefined otherwise
|
||||
taskLog.abortionRequestedAt
|
||||
|
||||
// undefined or an array of infos emitted on the task
|
||||
taskLog.infos
|
||||
|
||||
// undefined or an array of warnings emitted on the task
|
||||
taskLog.warnings
|
||||
|
||||
// timestamp at which the task ended, undefined otherwise
|
||||
taskLog.end
|
||||
|
||||
// undefined or the result value of the task
|
||||
taskLog.result
|
||||
},
|
||||
|
||||
// This function is called each time a root task ends.
|
||||
|
||||
@@ -18,10 +18,8 @@ npm install --save @vates/task
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
const task = new Task({
|
||||
// data in this object will be sent along the *start* event
|
||||
//
|
||||
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
|
||||
data: {
|
||||
// this object will be sent in the *start* event
|
||||
properties: {
|
||||
name: 'my task',
|
||||
},
|
||||
|
||||
@@ -32,13 +30,15 @@ const task = new Task({
|
||||
// this function is called each time this task or one of it's subtasks change state
|
||||
const { id, timestamp, type } = event
|
||||
if (type === 'start') {
|
||||
const { name, parentId } = event
|
||||
const { name, parentId, properties } = event
|
||||
} else if (type === 'end') {
|
||||
const { result, status } = event
|
||||
} else if (type === 'info' || type === 'warning') {
|
||||
const { data, message } = event
|
||||
} else if (type === 'property') {
|
||||
const { name, value } = event
|
||||
} else if (type === 'abortionRequested') {
|
||||
const { reason } = event
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -52,7 +52,6 @@ task.id
|
||||
// - pending
|
||||
// - success
|
||||
// - failure
|
||||
// - aborted
|
||||
task.status
|
||||
|
||||
// Triggers the abort signal associated to the task.
|
||||
@@ -105,6 +104,30 @@ const onProgress = makeOnProgress({
|
||||
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.
|
||||
|
||||
// timestamp at which the task started
|
||||
taskLog.start
|
||||
|
||||
// current status of the task as described in the previous section
|
||||
taskLog.status
|
||||
|
||||
// undefined or a dictionnary of properties attached to the task
|
||||
taskLog.properties
|
||||
|
||||
// timestamp at which the abortion was requested, undefined otherwise
|
||||
taskLog.abortionRequestedAt
|
||||
|
||||
// undefined or an array of infos emitted on the task
|
||||
taskLog.infos
|
||||
|
||||
// undefined or an array of warnings emitted on the task
|
||||
taskLog.warnings
|
||||
|
||||
// timestamp at which the task ended, undefined otherwise
|
||||
taskLog.end
|
||||
|
||||
// undefined or the result value of the task
|
||||
taskLog.result
|
||||
},
|
||||
|
||||
// This function is called each time a root task ends.
|
||||
|
||||
@@ -4,36 +4,18 @@ 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'
|
||||
taskLog = {
|
||||
id,
|
||||
properties: { __proto__: null, ...event.properties },
|
||||
start: event.timestamp,
|
||||
status: 'pending',
|
||||
}
|
||||
taskLogs.set(id, taskLog)
|
||||
|
||||
const { parentId } = event
|
||||
@@ -65,6 +47,8 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
|
||||
taskLog.end = event.timestamp
|
||||
taskLog.result = event.result
|
||||
taskLog.status = event.status
|
||||
} else if (type === 'abortionRequested') {
|
||||
taskLog.abortionRequestedAt = event.timestamp
|
||||
}
|
||||
|
||||
if (type === 'end' && taskLog.$root === taskLog) {
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('makeOnProgress()', function () {
|
||||
const events = []
|
||||
let log
|
||||
const task = new Task({
|
||||
data: { name: 'task' },
|
||||
properties: { name: 'task' },
|
||||
onProgress: makeOnProgress({
|
||||
onRootTaskStart(log_) {
|
||||
assert.equal(log, undefined)
|
||||
@@ -32,36 +32,50 @@ describe('makeOnProgress()', function () {
|
||||
|
||||
assert.equal(events.length, 0)
|
||||
|
||||
let i = 0
|
||||
|
||||
await task.run(async () => {
|
||||
assert.equal(events[0], 'onRootTaskStart')
|
||||
assert.equal(events[1], 'onTaskUpdate')
|
||||
assert.equal(log.name, 'task')
|
||||
assert.equal(events[i++], 'onRootTaskStart')
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert.equal(log.id, task.id)
|
||||
assert.equal(log.properties.name, 'task')
|
||||
assert(Math.abs(log.start - Date.now()) < 10)
|
||||
|
||||
Task.set('name', 'new name')
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert.equal(log.properties.name, 'new name')
|
||||
|
||||
Task.set('progress', 0)
|
||||
assert.equal(events[2], 'onTaskUpdate')
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert.equal(log.properties.progress, 0)
|
||||
|
||||
Task.info('foo', {})
|
||||
assert.equal(events[3], 'onTaskUpdate')
|
||||
assert.equal(events[i++], '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')
|
||||
const subtask = new Task({ properties: { name: 'subtask' } })
|
||||
await subtask.run(() => {
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert.equal(log.tasks[0].properties.name, 'subtask')
|
||||
|
||||
Task.warning('bar', {})
|
||||
assert.equal(events[5], 'onTaskUpdate')
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert.deepEqual(log.tasks[0].warnings, [{ data: {}, message: 'bar' }])
|
||||
|
||||
subtask.abort()
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert(Math.abs(log.tasks[0].abortionRequestedAt - Date.now()) < 10)
|
||||
})
|
||||
assert.equal(events[6], 'onTaskUpdate')
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert.equal(log.tasks[0].status, 'success')
|
||||
|
||||
Task.set('progress', 100)
|
||||
assert.equal(events[7], 'onTaskUpdate')
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert.equal(log.properties.progress, 100)
|
||||
})
|
||||
assert.equal(events[8], 'onRootTaskEnd')
|
||||
assert.equal(events[9], 'onTaskUpdate')
|
||||
assert.equal(events[i++], 'onRootTaskEnd')
|
||||
assert.equal(events[i++], 'onTaskUpdate')
|
||||
assert(Math.abs(log.end - Date.now()) < 10)
|
||||
assert.equal(log.status, 'success')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,11 +10,10 @@ function define(object, property, value) {
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
const ABORTED = 'aborted'
|
||||
const FAILURE = 'failure'
|
||||
const PENDING = 'pending'
|
||||
const SUCCESS = 'success'
|
||||
exports.STATUS = { ABORTED, FAILURE, PENDING, SUCCESS }
|
||||
exports.STATUS = { FAILURE, PENDING, SUCCESS }
|
||||
|
||||
// stored in the global context so that various versions of the library can interact.
|
||||
const asyncStorageKey = '@vates/task@0'
|
||||
@@ -83,8 +82,8 @@ exports.Task = class Task {
|
||||
return this.#status
|
||||
}
|
||||
|
||||
constructor({ data = {}, onProgress } = {}) {
|
||||
this.#startData = data
|
||||
constructor({ properties, onProgress } = {}) {
|
||||
this.#startData = { properties }
|
||||
|
||||
if (onProgress !== undefined) {
|
||||
this.#onProgress = onProgress
|
||||
@@ -105,12 +104,16 @@ exports.Task = class Task {
|
||||
|
||||
const { signal } = this.#abortController
|
||||
signal.addEventListener('abort', () => {
|
||||
if (this.status === PENDING && !this.#running) {
|
||||
if (this.status === PENDING) {
|
||||
this.#maybeStart()
|
||||
|
||||
const status = ABORTED
|
||||
this.#status = status
|
||||
this.#emit('end', { result: signal.reason, status })
|
||||
this.#emit('abortionRequested', { reason: signal.reason })
|
||||
|
||||
if (!this.#running) {
|
||||
const status = FAILURE
|
||||
this.#status = status
|
||||
this.#emit('end', { result: signal.reason, status })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -156,9 +159,7 @@ exports.Task = class Task {
|
||||
this.#running = false
|
||||
return result
|
||||
} catch (result) {
|
||||
const { signal } = this.#abortController
|
||||
const aborted = signal.aborted && result === signal.reason
|
||||
const status = aborted ? ABORTED : FAILURE
|
||||
const status = FAILURE
|
||||
|
||||
this.#status = status
|
||||
this.#emit('end', { status, result })
|
||||
|
||||
@@ -15,7 +15,7 @@ function assertEvent(task, expected, eventIndex = -1) {
|
||||
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])
|
||||
assert.deepEqual(actual[keys], expected[keys])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ function createTask(opts) {
|
||||
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 })
|
||||
const properties = { foo: 0, bar: 1 }
|
||||
const task = createTask({ properties })
|
||||
await task.run(noop)
|
||||
assertEvent(task, { ...data, type: 'start' }, 0)
|
||||
assertEvent(task, { type: 'start', properties }, 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,20 +79,22 @@ describe('Task', function () {
|
||||
})
|
||||
.catch(noop)
|
||||
|
||||
assert.equal(task.status, 'aborted')
|
||||
assert.equal(task.status, 'failure')
|
||||
|
||||
assert.equal(task.$events.length, 2)
|
||||
assert.equal(task.$events.length, 3)
|
||||
assertEvent(task, { type: 'start' }, 0)
|
||||
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
|
||||
assertEvent(task, { type: 'abortionRequested', reason }, 1)
|
||||
assertEvent(task, { type: 'end', status: 'failure', result: reason }, 2)
|
||||
})
|
||||
|
||||
it('does not abort if the task fails without the abort reason', async function () {
|
||||
const task = createTask()
|
||||
const reason = {}
|
||||
const result = new Error()
|
||||
|
||||
await task
|
||||
.run(() => {
|
||||
task.abort({})
|
||||
task.abort(reason)
|
||||
|
||||
throw result
|
||||
})
|
||||
@@ -100,18 +102,20 @@ describe('Task', function () {
|
||||
|
||||
assert.equal(task.status, 'failure')
|
||||
|
||||
assert.equal(task.$events.length, 2)
|
||||
assert.equal(task.$events.length, 3)
|
||||
assertEvent(task, { type: 'start' }, 0)
|
||||
assertEvent(task, { type: 'end', status: 'failure', result }, 1)
|
||||
assertEvent(task, { type: 'abortionRequested', reason }, 1)
|
||||
assertEvent(task, { type: 'end', status: 'failure', result }, 2)
|
||||
})
|
||||
|
||||
it('does not abort if the task succeed', async function () {
|
||||
const task = createTask()
|
||||
const reason = {}
|
||||
const result = {}
|
||||
|
||||
await task
|
||||
.run(() => {
|
||||
task.abort({})
|
||||
task.abort(reason)
|
||||
|
||||
return result
|
||||
})
|
||||
@@ -119,9 +123,10 @@ describe('Task', function () {
|
||||
|
||||
assert.equal(task.status, 'success')
|
||||
|
||||
assert.equal(task.$events.length, 2)
|
||||
assert.equal(task.$events.length, 3)
|
||||
assertEvent(task, { type: 'start' }, 0)
|
||||
assertEvent(task, { type: 'end', status: 'success', result }, 1)
|
||||
assertEvent(task, { type: 'abortionRequested', reason }, 1)
|
||||
assertEvent(task, { type: 'end', status: 'success', result }, 2)
|
||||
})
|
||||
|
||||
it('aborts before task is running', function () {
|
||||
@@ -130,11 +135,12 @@ describe('Task', function () {
|
||||
|
||||
task.abort(reason)
|
||||
|
||||
assert.equal(task.status, 'aborted')
|
||||
assert.equal(task.status, 'failure')
|
||||
|
||||
assert.equal(task.$events.length, 2)
|
||||
assert.equal(task.$events.length, 3)
|
||||
assertEvent(task, { type: 'start' }, 0)
|
||||
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
|
||||
assertEvent(task, { type: 'abortionRequested', reason }, 1)
|
||||
assertEvent(task, { type: 'end', status: 'failure', result: reason }, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -243,7 +249,7 @@ describe('Task', function () {
|
||||
assert.equal(task.status, 'failure')
|
||||
})
|
||||
|
||||
it('changes to aborted after run is complete', async function () {
|
||||
it('changes to failure if aborted after run is complete', async function () {
|
||||
const task = createTask()
|
||||
await task
|
||||
.run(() => {
|
||||
@@ -252,13 +258,13 @@ describe('Task', function () {
|
||||
Task.abortSignal.throwIfAborted()
|
||||
})
|
||||
.catch(noop)
|
||||
assert.equal(task.status, 'aborted')
|
||||
assert.equal(task.status, 'failure')
|
||||
})
|
||||
|
||||
it('changes to aborted if aborted when not running', async function () {
|
||||
it('changes to failure if aborted when not running', function () {
|
||||
const task = createTask()
|
||||
task.abort()
|
||||
assert.equal(task.status, 'aborted')
|
||||
assert.equal(task.status, 'failure')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.38.0",
|
||||
"@xen-orchestra/fs": "^4.0.0",
|
||||
"@xen-orchestra/backups": "^0.39.0",
|
||||
"@xen-orchestra/fs": "^4.0.1",
|
||||
"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.8",
|
||||
"version": "1.0.9",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const { join, resolve } = require('node:path/posix')
|
||||
|
||||
const { DIR_XO_POOL_METADATA_BACKUPS } = require('./RemoteAdapter.js')
|
||||
const { PATH_DB_DUMP } = require('./_runners/_PoolMetadataBackup.js')
|
||||
|
||||
@@ -20,7 +22,8 @@ exports.RestoreMetadataBackup = class RestoreMetadataBackup {
|
||||
task: xapi.task_create('Import pool metadata'),
|
||||
})
|
||||
} else {
|
||||
return String(await handler.readFile(`${backupId}/data.json`))
|
||||
const metadata = JSON.parse(await handler.readFile(join(backupId, 'metadata.json')))
|
||||
return String(await handler.readFile(resolve(backupId, metadata.data ?? 'data.json')))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const DEFAULT_XAPI_VM_SETTINGS = {
|
||||
concurrency: 2,
|
||||
copyRetention: 0,
|
||||
deleteFirst: false,
|
||||
diskPerVmConcurrency: 0, // not limited by default
|
||||
exportRetention: 0,
|
||||
fullInterval: 0,
|
||||
healthCheckSr: undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { join } = require('@xen-orchestra/fs/path')
|
||||
|
||||
const { DIR_XO_CONFIG_BACKUPS } = require('../RemoteAdapter.js')
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
@@ -23,10 +24,11 @@ exports.XoMetadataBackup = class XoMetadataBackup {
|
||||
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
|
||||
|
||||
const data = job.xoMetadata
|
||||
const fileName = `${dir}/data.json`
|
||||
const dataBaseName = './data.json'
|
||||
|
||||
const metadata = JSON.stringify(
|
||||
{
|
||||
data: dataBaseName,
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
scheduleId: schedule.id,
|
||||
@@ -36,6 +38,8 @@ exports.XoMetadataBackup = class XoMetadataBackup {
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
const dataFileName = join(dir, dataBaseName)
|
||||
const metaDataFileName = `${dir}/metadata.json`
|
||||
|
||||
await asyncMap(
|
||||
@@ -52,7 +56,7 @@ exports.XoMetadataBackup = class XoMetadataBackup {
|
||||
async () => {
|
||||
const handler = adapter.handler
|
||||
const dirMode = this._config.dirMode
|
||||
await handler.outputFile(fileName, data, { dirMode })
|
||||
await handler.outputFile(dataFileName, data, { dirMode })
|
||||
await handler.outputFile(metaDataFileName, metadata, {
|
||||
dirMode,
|
||||
})
|
||||
|
||||
@@ -39,7 +39,18 @@ exports.AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstract {
|
||||
...settings,
|
||||
...allSettings[remoteId],
|
||||
}
|
||||
writers.add(new RemoteWriter({ adapter, config, healthCheckSr, job, vmUuid, remoteId, settings: targetSettings }))
|
||||
writers.add(
|
||||
new RemoteWriter({
|
||||
adapter,
|
||||
config,
|
||||
healthCheckSr,
|
||||
job,
|
||||
scheduleId: schedule.id,
|
||||
vmUuid,
|
||||
remoteId,
|
||||
settings: targetSettings,
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,18 @@ class AbstractXapiVmBackupRunner extends Abstract {
|
||||
...allSettings[remoteId],
|
||||
}
|
||||
if (targetSettings.exportRetention !== 0) {
|
||||
writers.add(new BackupWriter({ adapter, config, healthCheckSr, job, vmUuid: vm.uuid, remoteId, settings: targetSettings }))
|
||||
writers.add(
|
||||
new BackupWriter({
|
||||
adapter,
|
||||
config,
|
||||
healthCheckSr,
|
||||
job,
|
||||
scheduleId: schedule.id,
|
||||
vmUuid: vm.uuid,
|
||||
remoteId,
|
||||
settings: targetSettings,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
srs.forEach(sr => {
|
||||
@@ -89,7 +100,17 @@ class AbstractXapiVmBackupRunner extends Abstract {
|
||||
...allSettings[sr.uuid],
|
||||
}
|
||||
if (targetSettings.copyRetention !== 0) {
|
||||
writers.add(new ReplicationWriter({ config, healthCheckSr, job, vmUuid: vm.uuid, sr, settings: targetSettings}))
|
||||
writers.add(
|
||||
new ReplicationWriter({
|
||||
config,
|
||||
healthCheckSr,
|
||||
job,
|
||||
scheduleId: schedule.id,
|
||||
vmUuid: vm.uuid,
|
||||
sr,
|
||||
settings: targetSettings,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ exports.FullXapiWriter = class FullXapiWriter extends MixinXapiWriter(AbstractFu
|
||||
const sr = this._sr
|
||||
const settings = this._settings
|
||||
const job = this._job
|
||||
const scheduleId = this.scheduleId
|
||||
const scheduleId = this._scheduleId
|
||||
|
||||
const { uuid: srUuid, $xapi: xapi } = sr
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const map = require('lodash/map.js')
|
||||
const mapValues = require('lodash/mapValues.js')
|
||||
const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const { asyncEach } = require('@vates/async-each')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
@@ -138,7 +138,7 @@ class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWrite
|
||||
const adapter = this._adapter
|
||||
const job = this._job
|
||||
const scheduleId = this._scheduleId
|
||||
|
||||
const settings = this._settings
|
||||
const jobId = job.id
|
||||
const handler = adapter.handler
|
||||
|
||||
@@ -176,8 +176,9 @@ class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWrite
|
||||
}
|
||||
const { size } = await Task.run({ name: 'transfer' }, async () => {
|
||||
let transferSize = 0
|
||||
await Promise.all(
|
||||
map(deltaExport.vdis, async (vdi, id) => {
|
||||
await asyncEach(
|
||||
Object.entries(deltaExport.vdis),
|
||||
async ([id, vdi]) => {
|
||||
const path = `${this._vmBackupDir}/${vhds[id]}`
|
||||
|
||||
const isDelta = differentialVhds[`${id}.vhd`]
|
||||
@@ -223,8 +224,12 @@ class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWrite
|
||||
await vhd.readBlockAllocationTable() // required by writeFooter()
|
||||
await vhd.writeFooter()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
concurrency: settings.diskPerVmConcurrency,
|
||||
}
|
||||
)
|
||||
|
||||
return { size: transferSize }
|
||||
})
|
||||
metadataContent.size = size
|
||||
|
||||
@@ -70,7 +70,7 @@ exports.MixinRemoteWriter = (BaseClass = Object) =>
|
||||
// add a random suffix to avoid collision in case multiple tasks are created at the same second
|
||||
Math.random().toString(36).slice(2)
|
||||
|
||||
await handler.outputFile(taskFile, this._backup.vm.uuid)
|
||||
await handler.outputFile(taskFile, this._vmUuid)
|
||||
const remotePath = handler.getRealPath()
|
||||
await MergeWorker.run(remotePath)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ exports.MixinXapiWriter = (BaseClass = Object) =>
|
||||
this._sr = sr
|
||||
}
|
||||
|
||||
// check if the base Vm has all its disk on health check sr
|
||||
async #isAlreadyOnHealthCheckSr(baseVm) {
|
||||
const xapi = baseVm.$xapi
|
||||
const vdiRefs = await xapi.VM_getDisks(baseVm.$ref)
|
||||
for (const vdiRef of vdiRefs) {
|
||||
const vdi = xapi.getObject(vdiRef)
|
||||
if (vdi.$SR.uuid !== this._heathCheckSr.uuid) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
healthCheck() {
|
||||
const sr = this._healthCheckSr
|
||||
assert.notStrictEqual(sr, undefined, 'SR should be defined before making a health check')
|
||||
@@ -25,20 +38,35 @@ exports.MixinXapiWriter = (BaseClass = Object) =>
|
||||
},
|
||||
async () => {
|
||||
const { $xapi: xapi } = sr
|
||||
let clonedVm
|
||||
let healthCheckVmRef
|
||||
try {
|
||||
const baseVm = xapi.getObject(this._targetVmRef) ?? (await xapi.waitObject(this._targetVmRef))
|
||||
const clonedRef = await xapi
|
||||
.callAsync('VM.clone', this._targetVmRef, `Health Check - ${baseVm.name_label}`)
|
||||
.then(extractOpaqueRef)
|
||||
clonedVm = xapi.getObject(clonedRef) ?? (await xapi.waitObject(clonedRef))
|
||||
|
||||
if (await this.#isAlreadyOnHealthCheckSr(baseVm)) {
|
||||
healthCheckVmRef = await Task.run(
|
||||
{ name: 'cloning-vm' },
|
||||
async () =>
|
||||
await xapi
|
||||
.callAsync('VM.clone', this._targetVmRef, `Health Check - ${baseVm.name_label}`)
|
||||
.then(extractOpaqueRef)
|
||||
)
|
||||
} else {
|
||||
healthCheckVmRef = await Task.run(
|
||||
{ name: 'copying-vm' },
|
||||
async () =>
|
||||
await xapi
|
||||
.callAsync('VM.copy', this._targetVmRef, `Health Check - ${baseVm.name_label}`, sr.$ref)
|
||||
.then(extractOpaqueRef)
|
||||
)
|
||||
}
|
||||
const healthCheckVm = xapi.getObject(healthCheckVmRef) ?? (await xapi.waitObject(healthCheckVmRef))
|
||||
|
||||
await new HealthCheckVmBackup({
|
||||
restoredVm: clonedVm,
|
||||
restoredVm: healthCheckVm,
|
||||
xapi,
|
||||
}).run()
|
||||
} finally {
|
||||
clonedVm && (await xapi.VM_destroy(clonedVm.$ref))
|
||||
healthCheckVmRef && (await xapi.VM_destroy(healthCheckVmRef))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -171,13 +171,16 @@ job:
|
||||
# For replication jobs, indicates which SRs to use
|
||||
srs: IdPattern
|
||||
|
||||
# Here for historical reasons
|
||||
type: 'backup'
|
||||
type: 'backup' | 'mirrorBackup'
|
||||
|
||||
# Indicates which VMs to backup/replicate
|
||||
# Indicates which VMs to backup/replicate for a xapi to remote backup job
|
||||
vms: IdPattern
|
||||
|
||||
# Indicates which remote to read from for a mirror backup job
|
||||
sourceRemote: IdPattern
|
||||
|
||||
# Indicates which XAPI to use to connect to a specific VM or SR
|
||||
# for remote to remote backup job,this is only needed if there is healtcheck
|
||||
recordToXapi:
|
||||
[ObjectId]: XapiId
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.38.0",
|
||||
"version": "0.39.0",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
"node": ">=14.18"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
@@ -24,10 +24,10 @@
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@vates/fuse-vhd": "^1.0.0",
|
||||
"@vates/nbd-client": "^1.2.0",
|
||||
"@vates/nbd-client": "^1.2.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.0.0",
|
||||
"@xen-orchestra/fs": "^4.0.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^5.0.1",
|
||||
@@ -43,6 +43,7 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.5.0",
|
||||
"xen-api": "^1.3.3",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^1.3.1"
|
||||
"xen-api": "^1.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
@@ -30,7 +30,6 @@
|
||||
"@vates/coalesce-calls": "^0.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@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",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy'
|
||||
import assert from 'assert'
|
||||
import getStream from 'get-stream'
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { coalesceCalls } from '@vates/coalesce-calls'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { fromCallback, fromEvent, ignoreErrors, timeout } from 'promise-toolbox'
|
||||
@@ -623,7 +623,7 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
|
||||
const files = await this._list(dir)
|
||||
await asyncMapSettled(files, file =>
|
||||
await asyncEach(files, file =>
|
||||
this._unlink(`${dir}/${file}`).catch(error => {
|
||||
// Unlink dir behavior is not consistent across platforms
|
||||
// https://github.com/nodejs/node-v0.x-archive/issues/5791
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# ChangeLog
|
||||
|
||||
## **0.2.0**
|
||||
## **next**
|
||||
|
||||
## **0.1.1** (2023-07-03)
|
||||
|
||||
- Invalidate sessionId token after logout (PR [#6480](https://github.com/vatesfr/xen-orchestra/pull/6480))
|
||||
- Settings page (PR [#6418](https://github.com/vatesfr/xen-orchestra/pull/6418))
|
||||
@@ -15,6 +17,9 @@
|
||||
- Add a star icon near the pool master (PR [#6712](https://github.com/vatesfr/xen-orchestra/pull/6712))
|
||||
- Display an error message if the data cannot be fetched (PR [#6525](https://github.com/vatesfr/xen-orchestra/pull/6525))
|
||||
- Add "Under Construction" views (PR [#6673](https://github.com/vatesfr/xen-orchestra/pull/6673))
|
||||
- Ability to change the state of selected VMs from the pool's list of VMs (PR [#6782](https://github.com/vatesfr/xen-orchestra/pull/6782))
|
||||
- Ability to copy selected VMs from the pool's list of VMs (PR [#6847](https://github.com/vatesfr/xen-orchestra/pull/6847))
|
||||
- Ability to delete selected VMs from the pool's list of VMs (PR [#6673](https://github.com/vatesfr/xen-orchestra/pull/6860))
|
||||
|
||||
## **0.1.0**
|
||||
|
||||
|
||||
@@ -157,35 +157,6 @@ export const useFoobarStore = defineStore("foobar", () => {
|
||||
});
|
||||
```
|
||||
|
||||
#### Xen Api Collection Stores
|
||||
|
||||
When creating a store for a Xen Api objects collection, use the `createXenApiCollectionStoreContext` helper.
|
||||
|
||||
```typescript
|
||||
export const useConsoleStore = defineStore("console", () =>
|
||||
createXenApiCollectionStoreContext("console")
|
||||
);
|
||||
```
|
||||
|
||||
##### Extending the base context
|
||||
|
||||
Here is how to extend the base context:
|
||||
|
||||
```typescript
|
||||
import { computed } from "vue";
|
||||
|
||||
export const useFoobarStore = defineStore("foobar", () => {
|
||||
const baseContext = createXenApiCollectionStoreContext("foobar");
|
||||
|
||||
const myCustomGetter = computed(() => baseContext.ids.reverse());
|
||||
|
||||
return {
|
||||
...baseContext,
|
||||
myCustomGetter,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### I18n
|
||||
|
||||
Internationalization of the app is done with [Vue-i18n](https://vue-i18n.intlify.dev/).
|
||||
|
||||
144
@xen-orchestra/lite/docs/xen-api-record-stores.md
Normal file
144
@xen-orchestra/lite/docs/xen-api-record-stores.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Stores for XenApiRecord collections
|
||||
|
||||
All collections of `XenApiRecord` are stored inside the `xapiCollectionStore`.
|
||||
|
||||
To retrieve a collection, invoke `useXapiCollectionStore().get(type)`.
|
||||
|
||||
## Accessing a collection
|
||||
|
||||
In order to use a collection, you'll need to subscribe to it.
|
||||
|
||||
```typescript
|
||||
const consoleStore = useXapiCollectionStore().get("console");
|
||||
const { records, getByUuid /* ... */ } = consoleStore.subscribe();
|
||||
```
|
||||
|
||||
## Deferred subscription
|
||||
|
||||
If you wish to initialize the subscription on demand, you can pass `{ immediate: false }` as options to `subscribe()`.
|
||||
|
||||
```typescript
|
||||
const consoleStore = useXapiCollectionStore().get("console");
|
||||
const { records, start, isStarted /* ... */ } = consoleStore.subscribe({
|
||||
immediate: false,
|
||||
});
|
||||
|
||||
// Later, you can then use start() to initialize the subscription.
|
||||
```
|
||||
|
||||
## Create a dedicated store for a collection
|
||||
|
||||
To create a dedicated store for a specific `XenApiRecord`, simply return the collection from the XAPI Collection Store:
|
||||
|
||||
```typescript
|
||||
export const useConsoleStore = defineStore("console", () =>
|
||||
useXapiCollectionStore().get("console")
|
||||
);
|
||||
```
|
||||
|
||||
## Extending the base Subscription
|
||||
|
||||
To extend the base Subscription, you'll need to override the `subscribe` method.
|
||||
|
||||
For that, you can use the `createSubscribe<XenApiRecord, Extensions>((options) => { /* ... */})` helper.
|
||||
|
||||
### Define the extensions
|
||||
|
||||
Subscription extensions are defined as `(object | [object, RequiredOptions])[]`.
|
||||
|
||||
When using a tuple (`[object, RequiredOptions]`), the corresponding `object` type will be added to the subscription if
|
||||
the `RequiredOptions` for that tuple are present in the options passed to `subscribe`.
|
||||
|
||||
```typescript
|
||||
// Always present extension
|
||||
type DefaultExtension = {
|
||||
propA: string;
|
||||
propB: ComputedRef<number>;
|
||||
};
|
||||
|
||||
// Conditional extension 1
|
||||
type FirstConditionalExtension = [
|
||||
{ propC: ComputedRef<string> }, // <- This signature will be added
|
||||
{ optC: string } // <- if this condition is met
|
||||
];
|
||||
|
||||
// Conditional extension 2
|
||||
type SecondConditionalExtension = [
|
||||
{ propD: () => void }, // <- This signature will be added
|
||||
{ optD: number } // <- if this condition is met
|
||||
];
|
||||
|
||||
// Create the extensions array
|
||||
type Extensions = [
|
||||
DefaultExtension,
|
||||
FirstConditionalExtension,
|
||||
SecondConditionalExtension
|
||||
];
|
||||
```
|
||||
|
||||
### Define the subscription
|
||||
|
||||
```typescript
|
||||
export const useConsoleStore = defineStore("console", () => {
|
||||
const consoleCollection = useXapiCollectionStore().get("console");
|
||||
|
||||
const subscribe = createSubscribe<XenApiConsole, Extensions>((options) => {
|
||||
const originalSubscription = consoleCollection.subscribe(options);
|
||||
|
||||
const extendedSubscription = {
|
||||
propA: "Some string",
|
||||
propB: computed(() => 42),
|
||||
};
|
||||
|
||||
const propCSubscription = options?.optC !== undefined && {
|
||||
propC: computed(() => "Some other string"),
|
||||
};
|
||||
|
||||
const propDSubscription = options?.optD !== undefined && {
|
||||
propD: () => console.log("Hello"),
|
||||
};
|
||||
|
||||
return {
|
||||
...originalSubscription,
|
||||
...extendedSubscription,
|
||||
...propCSubscription,
|
||||
...propDSubscription,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...consoleCollection,
|
||||
subscribe,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
The generated `subscribe` method will then automatically have the following `options` signature:
|
||||
|
||||
```typescript
|
||||
type Options = {
|
||||
immediate?: false;
|
||||
optC?: string;
|
||||
optD?: number;
|
||||
};
|
||||
```
|
||||
|
||||
### Use the subscription
|
||||
|
||||
In each case, all the default properties (`records`, `getByUuid`, etc.) will be present.
|
||||
|
||||
```typescript
|
||||
const store = useConsoleStore();
|
||||
|
||||
// No options (propA and propB will be present)
|
||||
const subscription = store.subscribe();
|
||||
|
||||
// optC option (propA, propB and propC will be present)
|
||||
const subscription = store.subscribe({ optC: "Hello" });
|
||||
|
||||
// optD option (propA, propB and propD will be present)
|
||||
const subscription = store.subscribe({ optD: 12 });
|
||||
|
||||
// optC and optD options (propA, propB, propC and propD will be present)
|
||||
const subscription = store.subscribe({ optC: "Hello", optD: 12 });
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
"build": "run-p type-check build-only",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
|
||||
import { logicAnd } from "@vueuse/math";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
let link = document.querySelector(
|
||||
"link[rel~='icon']"
|
||||
@@ -48,10 +49,11 @@ useChartTheme();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const { locale } = useI18n();
|
||||
const activeElement = useActiveElement();
|
||||
const { D } = useMagicKeys();
|
||||
const { D, L } = useMagicKeys();
|
||||
|
||||
const canToggleDarkMode = computed(() => {
|
||||
const canToggle = computed(() => {
|
||||
if (activeElement.value == null) {
|
||||
return true;
|
||||
}
|
||||
@@ -60,9 +62,14 @@ if (import.meta.env.DEV) {
|
||||
});
|
||||
|
||||
whenever(
|
||||
logicAnd(D, canToggleDarkMode),
|
||||
logicAnd(D, canToggle),
|
||||
() => (uiStore.colorMode = uiStore.colorMode === "dark" ? "light" : "dark")
|
||||
);
|
||||
|
||||
whenever(
|
||||
logicAnd(L, canToggle),
|
||||
() => (locale.value = locale.value === "en" ? "fr" : "en")
|
||||
);
|
||||
}
|
||||
|
||||
whenever(
|
||||
|
||||
@@ -13,13 +13,10 @@
|
||||
v-model="password"
|
||||
:placeholder="$t('password')"
|
||||
:readonly="isConnecting"
|
||||
required
|
||||
/>
|
||||
</FormInputWrapper>
|
||||
<UiButton
|
||||
type="submit"
|
||||
:busy="isConnecting"
|
||||
:disabled="password.trim().length < 1"
|
||||
>
|
||||
<UiButton type="submit" :busy="isConnecting">
|
||||
{{ $t("login") }}
|
||||
</UiButton>
|
||||
</form>
|
||||
|
||||
@@ -6,23 +6,26 @@
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script
|
||||
generic="T extends XenApiRecord<string>, I extends T['uuid']"
|
||||
lang="ts"
|
||||
setup
|
||||
>
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import type { XenApiRecord } from "@/libs/xen-api";
|
||||
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const props = defineProps<{
|
||||
isReady: boolean;
|
||||
uuidChecker: (uuid: string) => boolean;
|
||||
id?: string;
|
||||
uuidChecker: (uuid: I) => boolean;
|
||||
id?: I;
|
||||
}>();
|
||||
|
||||
const { currentRoute } = useRouter();
|
||||
|
||||
const id = computed(
|
||||
() => props.id ?? (currentRoute.value.params.uuid as string)
|
||||
);
|
||||
const id = computed(() => props.id ?? (currentRoute.value.params.uuid as I));
|
||||
|
||||
const isRecordNotFound = computed(
|
||||
() => props.isReady && !props.uuidChecker(id.value)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import type { PowerState } from "@/libs/xen-api";
|
||||
import { POWER_STATE } from "@/libs/xen-api";
|
||||
import {
|
||||
faMoon,
|
||||
faPause,
|
||||
@@ -15,14 +15,14 @@ import {
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
state: PowerState;
|
||||
state: POWER_STATE;
|
||||
}>();
|
||||
|
||||
const icons = {
|
||||
Running: faPlay,
|
||||
Paused: faPause,
|
||||
Suspended: faMoon,
|
||||
Halted: faStop,
|
||||
[POWER_STATE.RUNNING]: faPlay,
|
||||
[POWER_STATE.PAUSED]: faPause,
|
||||
[POWER_STATE.SUSPENDED]: faMoon,
|
||||
[POWER_STATE.HALTED]: faStop,
|
||||
};
|
||||
|
||||
const icon = computed(() => icons[props.state] ?? faQuestion);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { fibonacci } from "iterable-backoff";
|
||||
import { computed, onBeforeUnmount, ref, watch, watchEffect } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import VncClient from "@novnc/novnc/core/rfb";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { promiseTimeout } from "@vueuse/shared";
|
||||
@@ -87,7 +87,6 @@ const createVncConnection = async () => {
|
||||
vncClient.addEventListener("connect", handleConnectionEvent);
|
||||
};
|
||||
|
||||
watch(url, clearVncClient);
|
||||
watchEffect(() => {
|
||||
if (
|
||||
url.value === undefined ||
|
||||
@@ -98,6 +97,8 @@ watchEffect(() => {
|
||||
}
|
||||
|
||||
nConnectionAttempts = 0;
|
||||
|
||||
clearVncClient();
|
||||
createVncConnection();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-model="value"
|
||||
:class="inputClass"
|
||||
:disabled="disabled || isLabelDisabled"
|
||||
:required="required"
|
||||
class="select"
|
||||
ref="inputElement"
|
||||
v-bind="$attrs"
|
||||
@@ -21,6 +22,7 @@
|
||||
v-model="value"
|
||||
:class="inputClass"
|
||||
:disabled="disabled || isLabelDisabled"
|
||||
:required="required"
|
||||
class="textarea"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
@@ -29,6 +31,7 @@
|
||||
v-model="value"
|
||||
:class="inputClass"
|
||||
:disabled="disabled || isLabelDisabled"
|
||||
:required="required"
|
||||
class="input"
|
||||
ref="inputElement"
|
||||
v-bind="$attrs"
|
||||
@@ -70,6 +73,7 @@ const props = withDefaults(
|
||||
beforeWidth?: string;
|
||||
afterWidth?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
right?: boolean;
|
||||
wrapperAttrs?: HTMLAttributes;
|
||||
}>(),
|
||||
@@ -88,7 +92,7 @@ const isEmpty = computed(
|
||||
);
|
||||
const inputType = inject("inputType", "input");
|
||||
const isLabelDisabled = inject("isLabelDisabled", ref(false));
|
||||
const color = inject(
|
||||
const parentColor = inject(
|
||||
"color",
|
||||
computed(() => undefined)
|
||||
);
|
||||
@@ -102,7 +106,7 @@ const wrapperClass = computed(() => [
|
||||
]);
|
||||
|
||||
const inputClass = computed(() => [
|
||||
color.value ?? props.color,
|
||||
parentColor.value ?? props.color,
|
||||
{
|
||||
right: props.right,
|
||||
"has-before": props.before !== undefined,
|
||||
|
||||
@@ -29,6 +29,7 @@ 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 type { XenApiHost } from "@/libs/xen-api";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
@@ -42,7 +43,7 @@ import { useToggle } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
hostOpaqueRef: string;
|
||||
hostOpaqueRef: XenApiHost["$ref"];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef } = useHostStore().subscribe();
|
||||
|
||||
@@ -19,13 +19,14 @@
|
||||
import InfraAction from "@/components/infra/InfraAction.vue";
|
||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import type { XenApiVm } from "@/libs/xen-api";
|
||||
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;
|
||||
vmOpaqueRef: XenApiVm["$ref"];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef } = useVmStore().subscribe();
|
||||
|
||||
@@ -11,18 +11,21 @@
|
||||
<script lang="ts" setup>
|
||||
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
||||
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
|
||||
import type { XenApiHost } from "@/libs/xen-api";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
hostOpaqueRef?: string;
|
||||
hostOpaqueRef?: XenApiHost["$ref"];
|
||||
}>();
|
||||
|
||||
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
|
||||
|
||||
const vms = computed(() =>
|
||||
recordsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
|
||||
recordsByHostRef.value.get(
|
||||
props.hostOpaqueRef ?? ("OpaqueRef:NULL" as XenApiHost["$ref"])
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<slot :is-open="isOpen" :open="open" name="trigger" />
|
||||
<Teleport to="body" :disabled="!isRoot || !slots.trigger">
|
||||
<Teleport to="body" :disabled="!shouldTeleport">
|
||||
<ul
|
||||
v-if="!$slots.trigger || isOpen"
|
||||
v-if="!hasTrigger || isOpen"
|
||||
ref="menu"
|
||||
:class="{ horizontal, shadow }"
|
||||
class="app-menu"
|
||||
@@ -14,7 +14,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import placement, { type Options } from "placement.js";
|
||||
import { IK_MENU_TELEPORTED } from "@/types/injection-keys";
|
||||
import placementJs, { type Options } from "placement.js";
|
||||
import { inject, nextTick, provide, ref, toRef, unref, useSlots } from "vue";
|
||||
import { onClickOutside, unrefElement, whenever } from "@vueuse/core";
|
||||
|
||||
@@ -24,8 +25,11 @@ const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
placement?: Options["placement"];
|
||||
}>();
|
||||
const isRoot = inject("isMenuRoot", true);
|
||||
provide("isMenuRoot", false);
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const isOpen = ref(false);
|
||||
const menu = ref();
|
||||
@@ -34,6 +38,14 @@ provide("isMenuHorizontal", toRef(props, "horizontal"));
|
||||
provide("isMenuDisabled", toRef(props, "disabled"));
|
||||
let clearClickOutsideEvent: (() => void) | undefined;
|
||||
|
||||
const hasTrigger = useSlots().trigger !== undefined;
|
||||
|
||||
const shouldTeleport = hasTrigger && !inject(IK_MENU_TELEPORTED, false);
|
||||
|
||||
if (shouldTeleport) {
|
||||
provide(IK_MENU_TELEPORTED, true);
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => !isOpen.value,
|
||||
() => clearClickOutsideEvent?.()
|
||||
@@ -59,7 +71,7 @@ const open = (event: MouseEvent) => {
|
||||
}
|
||||
);
|
||||
|
||||
placement(event.currentTarget as HTMLElement, unrefElement(menu), {
|
||||
placementJs(event.currentTarget as HTMLElement, unrefElement(menu), {
|
||||
placement:
|
||||
props.placement ??
|
||||
(unref(isParentHorizontal) !== false ? "bottom-start" : "right-start"),
|
||||
|
||||
@@ -38,6 +38,7 @@ import UiCardFooter from "@/components/ui/UiCardFooter.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { percent } from "@/libs/utils";
|
||||
import { POWER_STATE } from "@/libs/xen-api";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useVmMetricsStore } from "@/stores/vm-metrics.store";
|
||||
@@ -45,7 +46,7 @@ import { useVmStore } from "@/stores/vm.store";
|
||||
import { logicAnd } from "@vueuse/math";
|
||||
import { computed } from "vue";
|
||||
|
||||
const ACTIVE_STATES = new Set(["Running", "Paused"]);
|
||||
const ACTIVE_STATES = new Set([POWER_STATE.RUNNING, POWER_STATE.PAUSED]);
|
||||
|
||||
const {
|
||||
hasError: hostStoreHasError,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<UiTable class="tasks-table">
|
||||
<UiTable class="tasks-table" :color="hasError ? 'error' : undefined">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("name") }}</th>
|
||||
@@ -10,13 +10,25 @@
|
||||
</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" />
|
||||
<tr v-if="hasError">
|
||||
<td colspan="5">
|
||||
<span class="text-error">{{ $t("error-no-data") }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="isFetching">
|
||||
<td colspan="5">
|
||||
<UiSpinner class="loader" />
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<TaskRow
|
||||
v-for="task in pendingTasks"
|
||||
:key="task.uuid"
|
||||
:task="task"
|
||||
is-pending
|
||||
/>
|
||||
<TaskRow v-for="task in finishedTasks" :key="task.uuid" :task="task" />
|
||||
</template>
|
||||
</tbody>
|
||||
</UiTable>
|
||||
</template>
|
||||
@@ -24,12 +36,34 @@
|
||||
<script lang="ts" setup>
|
||||
import TaskRow from "@/components/tasks/TaskRow.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { useTaskStore } from "@/stores/task.store";
|
||||
import type { XenApiTask } from "@/libs/xen-api";
|
||||
|
||||
defineProps<{
|
||||
pendingTasks: XenApiTask[];
|
||||
finishedTasks: XenApiTask[];
|
||||
}>();
|
||||
|
||||
const { hasError, isFetching } = useTaskStore().subscribe();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
<style lang="postcss" scoped>
|
||||
td[colspan="5"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 150%;
|
||||
color: var(--color-red-vates-base);
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: var(--color-extra-blue-base);
|
||||
display: block;
|
||||
font-size: 4rem;
|
||||
margin: 2rem auto 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,6 @@ defineProps<{
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-card {
|
||||
height: fit-content;
|
||||
padding: 2.1rem;
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
30
@xen-orchestra/lite/src/components/ui/UiCardComingSoon.vue
Normal file
30
@xen-orchestra/lite/src/components/ui/UiCardComingSoon.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<UiCard class="ui-card-coming-soon">
|
||||
<UiCardTitle>{{ title }}</UiCardTitle>
|
||||
<div class="content">
|
||||
<img alt="" src="@/assets/under-construction.svg" />
|
||||
</div>
|
||||
<div class="content">{{ $t("coming-soon") }}</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.ui-card-coming-soon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
28
@xen-orchestra/lite/src/components/ui/UiCardGroup.vue
Normal file
28
@xen-orchestra/lite/src/components/ui/UiCardGroup.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div :class="{ vertical }" class="ui-card-group">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, provide } from "vue";
|
||||
|
||||
const vertical = inject("isCardGroupVertical", false);
|
||||
|
||||
provide("isCardGroupVertical", !vertical);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-card-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
.ui-card-group:not(.vertical) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<form
|
||||
<Teleport :disabled="isNested" to="body">
|
||||
<component
|
||||
:is="isNested ? 'div' : 'form'"
|
||||
:class="className"
|
||||
class="ui-modal"
|
||||
v-bind="$attrs"
|
||||
@click.self="emit('close')"
|
||||
@click.self="!isNested && emit('close')"
|
||||
>
|
||||
<div class="container">
|
||||
<div :class="{ nested: isNested }" class="container">
|
||||
<span v-if="onClose" class="close-icon" @click="emit('close')">
|
||||
<UiIcon :icon="faXmark" />
|
||||
</span>
|
||||
@@ -24,22 +25,23 @@
|
||||
<div v-if="$slots.default" class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<UiButtonGroup :color="color">
|
||||
<UiButtonGroup v-if="!isNested" :color="color">
|
||||
<slot name="buttons" />
|
||||
</UiButtonGroup>
|
||||
</div>
|
||||
</form>
|
||||
</component>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import { IK_MODAL_NESTED } from "@/types/injection-keys";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMagicKeys, whenever } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
import { computed, inject, provide } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -54,27 +56,39 @@ const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const isNested = inject(IK_MODAL_NESTED, false);
|
||||
provide(IK_MODAL_NESTED, true);
|
||||
|
||||
const { escape } = useMagicKeys();
|
||||
whenever(escape, () => emit("close"));
|
||||
|
||||
const className = computed(() => {
|
||||
return [`color-${props.color}`, { "has-icon": props.icon !== undefined }];
|
||||
return [
|
||||
`color-${props.color}`,
|
||||
{
|
||||
"has-icon": props.icon !== undefined,
|
||||
nested: isNested,
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-modal {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #00000080;
|
||||
|
||||
&:not(.nested) {
|
||||
background-color: #00000080;
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.color-success {
|
||||
@@ -103,11 +117,23 @@ const className = computed(() => {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 40rem;
|
||||
padding: 4.2rem;
|
||||
text-align: center;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--modal-background-color);
|
||||
box-shadow: var(--shadow-400);
|
||||
margin: 1rem 2rem;
|
||||
|
||||
&.nested {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(.nested) {
|
||||
box-shadow: var(--shadow-400);
|
||||
padding: 4.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.container > div:last-child {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
@@ -120,7 +146,7 @@ const className = computed(() => {
|
||||
color: var(--modal-color);
|
||||
}
|
||||
|
||||
.container :slotted(.accent) {
|
||||
.container :deep(.accent) {
|
||||
color: var(--modal-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<table :class="{ 'vertical-border': verticalBorder }" class="ui-table">
|
||||
<table
|
||||
:class="{ 'vertical-border': verticalBorder, error: color === 'error' }"
|
||||
class="ui-table"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
color?: "error";
|
||||
verticalBorder?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
@@ -52,4 +56,8 @@ defineProps<{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: var(--background-color-red-vates);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
v-tooltip="!areAllSelectedVmsHalted && $t('selected-vms-in-execution')"
|
||||
:busy="areSomeSelectedVmsCloning"
|
||||
:disabled="!areAllSelectedVmsHalted"
|
||||
:icon="faCopy"
|
||||
@click="handleCopy"
|
||||
>
|
||||
{{ $t("copy") }}
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import { isOperationsPending } from "@/libs/utils";
|
||||
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef } = useVmStore().subscribe();
|
||||
|
||||
const selectedVms = computed(() =>
|
||||
props.selectedRefs
|
||||
.map((vmRef) => getByOpaqueRef(vmRef))
|
||||
.filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
);
|
||||
|
||||
const areAllSelectedVmsHalted = computed(() =>
|
||||
selectedVms.value.every(
|
||||
(selectedVm) => selectedVm.power_state === POWER_STATE.HALTED
|
||||
)
|
||||
);
|
||||
|
||||
const areSomeSelectedVmsCloning = computed(() =>
|
||||
selectedVms.value.some((vm) => isOperationsPending(vm, VM_OPERATION.CLONE))
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const xapiStore = useXenApiStore();
|
||||
|
||||
const vmRefsToClone = Object.fromEntries(
|
||||
selectedVms.value.map((vm) => [vm.$ref, `${vm.name_label} (COPY)`])
|
||||
);
|
||||
|
||||
await xapiStore.getXapi().vm.clone(vmRefsToClone);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
:disabled="areSomeVmsInExecution"
|
||||
:icon="faTrashCan"
|
||||
v-tooltip="areSomeVmsInExecution && $t('selected-vms-in-execution')"
|
||||
@click="openDeleteModal"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</MenuItem>
|
||||
<UiModal
|
||||
v-if="isDeleteModalOpen"
|
||||
:icon="faSatellite"
|
||||
@close="closeDeleteModal"
|
||||
>
|
||||
<template #title>
|
||||
<i18n-t keypath="confirm-delete" scope="global" tag="div">
|
||||
<span class="accent">
|
||||
{{ $t("n-vms", { n: vmRefs.length }) }}
|
||||
</span>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{ $t("please-confirm") }}
|
||||
</template>
|
||||
<template #buttons>
|
||||
<UiButton outlined @click="closeDeleteModal">
|
||||
{{ $t("go-back") }}
|
||||
</UiButton>
|
||||
<UiButton @click="deleteVms">
|
||||
{{ $t("delete-vms", { n: vmRefs.length }) }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { POWER_STATE } from "@/libs/xen-api";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import type { XenApiVm } from "@/libs/xen-api";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const xenApi = useXenApiStore().getXapi();
|
||||
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
|
||||
const {
|
||||
open: openDeleteModal,
|
||||
close: closeDeleteModal,
|
||||
isOpen: isDeleteModalOpen,
|
||||
} = useModal();
|
||||
|
||||
const vms = computed<XenApiVm[]>(() =>
|
||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
);
|
||||
|
||||
const areSomeVmsInExecution = computed(() =>
|
||||
vms.value.some((vm) => vm.power_state !== POWER_STATE.HALTED)
|
||||
);
|
||||
|
||||
const deleteVms = async () => {
|
||||
await xenApi.vm.delete(props.vmRefs);
|
||||
closeDeleteModal();
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToStart"
|
||||
:disabled="!areVmsHalted"
|
||||
:icon="faPlay"
|
||||
@click="xenApi.vm.start(vmRefs)"
|
||||
>
|
||||
{{ $t("start") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToStartOnHost"
|
||||
:disabled="!areVmsHalted"
|
||||
:icon="faServer"
|
||||
>
|
||||
{{ $t("start-on-host") }}
|
||||
<template #submenu>
|
||||
<MenuItem
|
||||
v-for="host in hosts"
|
||||
v-bind:key="host.$ref"
|
||||
:icon="faServer"
|
||||
@click="xenApi.vm.startOn(vmRefs, host.$ref)"
|
||||
>
|
||||
<div class="wrapper">
|
||||
{{ host.name_label }}
|
||||
<div>
|
||||
<UiIcon
|
||||
:icon="host.$ref === pool?.master ? faStar : undefined"
|
||||
class="star"
|
||||
/>
|
||||
<PowerStateIcon :state="getHostState(host)" />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</template>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToPause"
|
||||
:disabled="!areVmsRunning"
|
||||
:icon="faPause"
|
||||
@click="xenApi.vm.pause(vmRefs)"
|
||||
>
|
||||
{{ $t("pause") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToSuspend"
|
||||
:disabled="!areVmsRunning"
|
||||
:icon="faMoon"
|
||||
@click="xenApi.vm.suspend(vmRefs)"
|
||||
>
|
||||
{{ $t("suspend") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToResume"
|
||||
:disabled="!areVmsSuspended && !areVmsPaused"
|
||||
:icon="faCirclePlay"
|
||||
@click="xenApi.vm.resume(vmRefsWithPowerState)"
|
||||
>
|
||||
{{ $t("resume") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToReboot"
|
||||
:disabled="!areVmsRunning"
|
||||
:icon="faRotateLeft"
|
||||
@click="xenApi.vm.reboot(vmRefs)"
|
||||
>
|
||||
{{ $t("reboot") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToForceReboot"
|
||||
:disabled="!areVmsRunning && !areVmsPaused"
|
||||
:icon="faRepeat"
|
||||
@click="xenApi.vm.reboot(vmRefs, true)"
|
||||
>
|
||||
{{ $t("force-reboot") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToShutdown"
|
||||
:disabled="!areVmsRunning"
|
||||
:icon="faPowerOff"
|
||||
@click="xenApi.vm.shutdown(vmRefs)"
|
||||
>
|
||||
{{ $t("shutdown") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="areVmsBusyToForceShutdown"
|
||||
:disabled="!areVmsRunning && !areVmsSuspended && !areVmsPaused"
|
||||
:icon="faPlug"
|
||||
@click="xenApi.vm.shutdown(vmRefs, true)"
|
||||
>
|
||||
{{ $t("force-shutdown") }}
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import { isHostRunning, isOperationsPending } from "@/libs/utils";
|
||||
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
|
||||
import { POWER_STATE, VM_OPERATION } from "@/libs/xen-api";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import {
|
||||
faCirclePlay,
|
||||
faMoon,
|
||||
faPause,
|
||||
faPlay,
|
||||
faPlug,
|
||||
faPowerOff,
|
||||
faRepeat,
|
||||
faRotateLeft,
|
||||
faServer,
|
||||
faStar,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
|
||||
const { records: hosts } = useHostStore().subscribe();
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||
|
||||
const vms = computed<XenApiVm[]>(() =>
|
||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
);
|
||||
|
||||
const vmRefsWithPowerState = computed(() =>
|
||||
vms.value.reduce((acc, vm) => ({ ...acc, [vm.$ref]: vm.power_state }), {})
|
||||
);
|
||||
|
||||
const xenApi = useXenApiStore().getXapi();
|
||||
|
||||
const areVmsRunning = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.RUNNING)
|
||||
);
|
||||
const areVmsHalted = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.HALTED)
|
||||
);
|
||||
const areVmsSuspended = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.SUSPENDED)
|
||||
);
|
||||
const areVmsPaused = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.PAUSED)
|
||||
);
|
||||
|
||||
const areOperationsPending = (operation: VM_OPERATION | VM_OPERATION[]) =>
|
||||
vms.value.some((vm) => isOperationsPending(vm, operation));
|
||||
|
||||
const areVmsBusyToStart = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.START)
|
||||
);
|
||||
const areVmsBusyToStartOnHost = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.START_ON)
|
||||
);
|
||||
const areVmsBusyToPause = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.PAUSE)
|
||||
);
|
||||
const areVmsBusyToSuspend = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.SUSPEND)
|
||||
);
|
||||
const areVmsBusyToResume = computed(() =>
|
||||
areOperationsPending([VM_OPERATION.UNPAUSE, VM_OPERATION.RESUME])
|
||||
);
|
||||
const areVmsBusyToReboot = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.CLEAN_REBOOT)
|
||||
);
|
||||
const areVmsBusyToForceReboot = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.HARD_REBOOT)
|
||||
);
|
||||
const areVmsBusyToShutdown = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.CLEAN_SHUTDOWN)
|
||||
);
|
||||
const areVmsBusyToForceShutdown = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.HARD_SHUTDOWN)
|
||||
);
|
||||
const getHostState = (host: XenApiHost) =>
|
||||
isHostRunning(host, hostMetricsSubscription)
|
||||
? POWER_STATE.RUNNING
|
||||
: POWER_STATE.HALTED;
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.star {
|
||||
margin: 0 1rem;
|
||||
color: var(--color-orange-world-base);
|
||||
}
|
||||
</style>
|
||||
@@ -9,103 +9,7 @@
|
||||
<UiIcon :icon="faAngleDown" />
|
||||
</UiButton>
|
||||
</template>
|
||||
<MenuItem
|
||||
:busy="isOperationsPending(vm, 'start')"
|
||||
:disabled="!isHalted"
|
||||
:icon="faPlay"
|
||||
@click="xenApi.vm.start(vm!.$ref)"
|
||||
>
|
||||
{{ $t("start") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="isOperationsPending(vm, 'start_on')"
|
||||
:disabled="!isHalted"
|
||||
:icon="faServer"
|
||||
>
|
||||
{{ $t("start-on-host") }}
|
||||
<template #submenu>
|
||||
<MenuItem
|
||||
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 === pool?.master ? faStar : undefined"
|
||||
class="star"
|
||||
/>
|
||||
<PowerStateIcon
|
||||
:state="
|
||||
isHostRunning(host, hostMetricsSubscription)
|
||||
? 'Running'
|
||||
: 'Halted'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</template>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="isOperationsPending(vm, 'pause')"
|
||||
:disabled="!isRunning"
|
||||
:icon="faPause"
|
||||
@click="xenApi.vm.pause(vm!.$ref)"
|
||||
>
|
||||
{{ $t("pause") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
: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
|
||||
:busy="isOperationsPending(vm, ['unpause', 'resume'])"
|
||||
:disabled="!isSuspended && !isPaused"
|
||||
:icon="faCirclePlay"
|
||||
@click="xenApi.vm.resume({ [vm!.$ref]: vm!.power_state })"
|
||||
>
|
||||
{{ $t("resume") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="isOperationsPending(vm, 'clean_reboot')"
|
||||
:disabled="!isRunning"
|
||||
:icon="faRotateLeft"
|
||||
@click="xenApi.vm.reboot(vm!.$ref)"
|
||||
>
|
||||
{{ $t("reboot") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="isOperationsPending(vm, 'hard_reboot')"
|
||||
:disabled="!isRunning && !isPaused"
|
||||
:icon="faRepeat"
|
||||
@click="xenApi.vm.reboot(vm!.$ref, true)"
|
||||
>
|
||||
{{ $t("force-reboot") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="isOperationsPending(vm, 'clean_shutdown')"
|
||||
:disabled="!isRunning"
|
||||
:icon="faPowerOff"
|
||||
@click="xenApi.vm.shutdown(vm!.$ref)"
|
||||
>
|
||||
{{ $t("shutdown") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:busy="isOperationsPending(vm, 'hard_shutdown')"
|
||||
:disabled="!isRunning && !isSuspended && !isPaused"
|
||||
:icon="faPlug"
|
||||
@click="xenApi.vm.shutdown(vm!.$ref, true)"
|
||||
>
|
||||
{{ $t("force-shutdown") }}
|
||||
</MenuItem>
|
||||
<VmActionPowerStateItems :vm-refs="[vm.$ref]" />
|
||||
</AppMenu>
|
||||
</template>
|
||||
</TitleBar>
|
||||
@@ -113,62 +17,26 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import AppMenu from "@/components/menu/AppMenu.vue";
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import TitleBar from "@/components/TitleBar.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { 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";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
||||
import type { XenApiVm } from "@/libs/xen-api";
|
||||
import {
|
||||
faAngleDown,
|
||||
faCirclePlay,
|
||||
faDisplay,
|
||||
faMoon,
|
||||
faPause,
|
||||
faPlay,
|
||||
faPlug,
|
||||
faPowerOff,
|
||||
faRepeat,
|
||||
faRotateLeft,
|
||||
faServer,
|
||||
faStar,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
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(() =>
|
||||
getVmByUuid(currentRoute.value.params.uuid as string)
|
||||
getVmByUuid(currentRoute.value.params.uuid as XenApiVm["uuid"])
|
||||
);
|
||||
|
||||
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>
|
||||
.star {
|
||||
margin: 0 1rem;
|
||||
color: var(--color-orange-world-base);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,43 +9,41 @@
|
||||
<template v-if="isMobile" #trigger="{ isOpen, open }">
|
||||
<UiButton :active="isOpen" :icon="faEllipsis" transparent @click="open" />
|
||||
</template>
|
||||
|
||||
<MenuItem :icon="faPowerOff" v-tooltip="$t('coming-soon')">
|
||||
{{ $t("change-power-state") }}
|
||||
<MenuItem :icon="faPowerOff">
|
||||
{{ $t("change-state") }}
|
||||
<template #submenu>
|
||||
<VmActionPowerStateItems :vm-refs="selectedRefs" />
|
||||
</template>
|
||||
</MenuItem>
|
||||
<MenuItem :icon="faRoute" v-tooltip="$t('coming-soon')">{{
|
||||
$t("migrate")
|
||||
}}</MenuItem>
|
||||
<MenuItem :icon="faCopy" v-tooltip="$t('coming-soon')">{{
|
||||
$t("copy")
|
||||
}}</MenuItem>
|
||||
<MenuItem :icon="faEdit" v-tooltip="$t('coming-soon')">{{
|
||||
$t("edit-config")
|
||||
}}</MenuItem>
|
||||
<MenuItem :icon="faCamera" v-tooltip="$t('coming-soon')">{{
|
||||
$t("snapshot")
|
||||
}}</MenuItem>
|
||||
<MenuItem :icon="faTrashCan" v-tooltip="$t('coming-soon')">{{
|
||||
$t("delete")
|
||||
}}</MenuItem>
|
||||
<MenuItem v-tooltip="$t('coming-soon')" :icon="faRoute">
|
||||
{{ $t("migrate") }}
|
||||
</MenuItem>
|
||||
<VmActionCopyItem :selected-refs="selectedRefs" />
|
||||
<MenuItem v-tooltip="$t('coming-soon')" :icon="faEdit">
|
||||
{{ $t("edit-config") }}
|
||||
</MenuItem>
|
||||
<MenuItem v-tooltip="$t('coming-soon')" :icon="faCamera">
|
||||
{{ $t("snapshot") }}
|
||||
</MenuItem>
|
||||
<VmActionDeleteItem :vm-refs="selectedRefs" />
|
||||
<MenuItem :icon="faFileExport">
|
||||
{{ $t("export") }}
|
||||
<template #submenu>
|
||||
<MenuItem
|
||||
:icon="faDisplay"
|
||||
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
|
||||
:icon="faDisplay"
|
||||
>
|
||||
{{ $t("export-vms") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:icon="faCode"
|
||||
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
|
||||
:icon="faCode"
|
||||
>
|
||||
{{ $t("export-table-to", { type: ".json" }) }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
:icon="faFileCsv"
|
||||
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
|
||||
:icon="faFileCsv"
|
||||
>
|
||||
{{ $t("export-table-to", { type: ".csv" }) }}
|
||||
</MenuItem>
|
||||
@@ -58,11 +56,15 @@
|
||||
import AppMenu from "@/components/menu/AppMenu.vue";
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
|
||||
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
|
||||
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import type { XenApiVm } from "@/libs/xen-api";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import {
|
||||
faCamera,
|
||||
faCode,
|
||||
faCopy,
|
||||
faDisplay,
|
||||
faEdit,
|
||||
faEllipsis,
|
||||
@@ -70,14 +72,12 @@ import {
|
||||
faFileExport,
|
||||
faPowerOff,
|
||||
faRoute,
|
||||
faTrashCan,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
selectedRefs: string[];
|
||||
selectedRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { isMobile } = storeToRefs(useUiStore());
|
||||
|
||||
@@ -16,10 +16,13 @@ export type Stat<T> = {
|
||||
pausable: Pausable;
|
||||
};
|
||||
|
||||
type GetStats<T extends HostStats | VmStats> = (
|
||||
uuid: string,
|
||||
type GetStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
S extends HostStats | VmStats
|
||||
> = (
|
||||
uuid: T["uuid"],
|
||||
granularity: GRANULARITY
|
||||
) => Promise<XapiStatsResponse<T>> | undefined;
|
||||
) => Promise<XapiStatsResponse<S>> | undefined;
|
||||
|
||||
export type FetchedStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
@@ -35,7 +38,7 @@ export type FetchedStats<
|
||||
export default function useFetchStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
S extends HostStats | VmStats
|
||||
>(getStats: GetStats<S>, granularity: GRANULARITY): FetchedStats<T, S> {
|
||||
>(getStats: GetStats<T, S>, granularity: GRANULARITY): FetchedStats<T, S> {
|
||||
const stats = ref<Map<string, Stat<S>>>(new Map());
|
||||
const timestamp = ref<number[]>([0, 0]);
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import HLJS from "highlight.js";
|
||||
import { marked } from "marked";
|
||||
|
||||
enum VUE_TAG {
|
||||
TEMPLATE = "vue-template",
|
||||
SCRIPT = "vue-script",
|
||||
STYLE = "vue-style",
|
||||
}
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
code(str: string, lang: string) {
|
||||
const code = highlight(str, HLJS.getLanguage(lang) ? lang : "plaintext");
|
||||
const code = highlight(
|
||||
str,
|
||||
Object.values(VUE_TAG).includes(lang as VUE_TAG) ||
|
||||
HLJS.getLanguage(lang)
|
||||
? lang
|
||||
: "plaintext"
|
||||
);
|
||||
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
|
||||
},
|
||||
},
|
||||
@@ -12,45 +24,48 @@ marked.use({
|
||||
|
||||
function highlight(str: string, lang: string) {
|
||||
switch (lang) {
|
||||
case "vue-template": {
|
||||
case VUE_TAG.TEMPLATE: {
|
||||
const indented = str
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((s) => ` ${s}`)
|
||||
.join("\n");
|
||||
return wrap(indented, "template");
|
||||
return wrap(indented, lang);
|
||||
}
|
||||
case "vue-script":
|
||||
return wrap(str.trim(), "script");
|
||||
case "vue-style":
|
||||
return wrap(str.trim(), "style");
|
||||
case VUE_TAG.SCRIPT:
|
||||
case VUE_TAG.STYLE:
|
||||
return wrap(str.trim(), lang);
|
||||
default: {
|
||||
return copyable(HLJS.highlight(str, { language: lang }).value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrap(str: string, tag: "template" | "script" | "style") {
|
||||
function wrap(str: string, lang: VUE_TAG) {
|
||||
let openTag;
|
||||
let closeTag;
|
||||
let code;
|
||||
|
||||
switch (tag) {
|
||||
case "template":
|
||||
switch (lang) {
|
||||
case VUE_TAG.TEMPLATE:
|
||||
openTag = "<template>";
|
||||
closeTag = "</template>";
|
||||
code = HLJS.highlight(str, { language: "xml" }).value;
|
||||
break;
|
||||
case "script":
|
||||
case VUE_TAG.SCRIPT:
|
||||
openTag = '<script lang="ts" setup>';
|
||||
closeTag = "</script>";
|
||||
code = HLJS.highlight(str, { language: "typescript" }).value;
|
||||
break;
|
||||
case "style":
|
||||
case VUE_TAG.STYLE:
|
||||
openTag = '<style lang="postcss" scoped>';
|
||||
closeTag = "</style>";
|
||||
code = HLJS.highlight(str, { language: "scss" }).value;
|
||||
break;
|
||||
}
|
||||
|
||||
const openTagHtml = HLJS.highlight(openTag, { language: "xml" }).value;
|
||||
const closeTagHtml = HLJS.highlight(`</${tag}>`, { language: "xml" }).value;
|
||||
const closeTagHtml = HLJS.highlight(closeTag, { language: "xml" }).value;
|
||||
|
||||
return `${openTagHtml}${copyable(code)}${closeTagHtml}`;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
XenApiHostMetrics,
|
||||
XenApiRecord,
|
||||
XenApiVm,
|
||||
VM_OPERATION,
|
||||
} from "@/libs/xen-api";
|
||||
import type { Filter } from "@/types/filter";
|
||||
import type { CollectionSubscription } from "@/types/xapi-collection";
|
||||
import type { Subscription } from "@/types/xapi-collection";
|
||||
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
|
||||
import { utcParse } from "d3-time-format";
|
||||
@@ -115,14 +116,14 @@ export function getStatsLength(stats?: object | any[]) {
|
||||
|
||||
export function isHostRunning(
|
||||
host: XenApiHost,
|
||||
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
|
||||
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
|
||||
) {
|
||||
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
|
||||
}
|
||||
|
||||
export function getHostMemory(
|
||||
host: XenApiHost,
|
||||
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
|
||||
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
|
||||
) {
|
||||
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
|
||||
|
||||
@@ -135,9 +136,9 @@ export function getHostMemory(
|
||||
}
|
||||
}
|
||||
|
||||
export const buildXoObject = <T extends XenApiRecord>(
|
||||
export const buildXoObject = <T extends XenApiRecord<string>>(
|
||||
record: RawXenApiRecord<T>,
|
||||
params: { opaqueRef: string }
|
||||
params: { opaqueRef: T["$ref"] }
|
||||
) => {
|
||||
return {
|
||||
...record,
|
||||
@@ -184,7 +185,7 @@ export const getFirst = <T>(value: T | T[]): T | undefined =>
|
||||
|
||||
export const isOperationsPending = (
|
||||
obj: XenApiVm,
|
||||
operations: string[] | string
|
||||
operations: VM_OPERATION[] | VM_OPERATION
|
||||
) => {
|
||||
const currentOperations = Object.values(obj.current_operations);
|
||||
return castArray(operations).some((operation) =>
|
||||
|
||||
@@ -46,6 +46,7 @@ const OBJECT_TYPES = {
|
||||
host_crashdump: "host_crashdump",
|
||||
host_metrics: "host_metrics",
|
||||
host_patch: "host_patch",
|
||||
message: "message",
|
||||
network: "network",
|
||||
network_sriov: "network_sriov",
|
||||
pool: "pool",
|
||||
@@ -65,85 +66,112 @@ export const getRawObjectType = (type: ObjectType): RawObjectType => {
|
||||
return OBJECT_TYPES[type];
|
||||
};
|
||||
|
||||
export type PowerState = "Running" | "Paused" | "Halted" | "Suspended";
|
||||
|
||||
export interface XenApiRecord {
|
||||
$ref: string;
|
||||
uuid: string;
|
||||
export enum POWER_STATE {
|
||||
RUNNING = "Running",
|
||||
PAUSED = "Paused",
|
||||
HALTED = "Halted",
|
||||
SUSPENDED = "Suspended",
|
||||
}
|
||||
|
||||
export type RawXenApiRecord<T extends XenApiRecord> = Omit<T, "$ref">;
|
||||
export enum VM_OPERATION {
|
||||
START = "start",
|
||||
START_ON = "start_on",
|
||||
RESUME = "resume",
|
||||
UNPAUSE = "unpause",
|
||||
CLONE = "clone",
|
||||
SHUTDOWN = "shutdown",
|
||||
CLEAN_SHUTDOWN = "clean_shutdown",
|
||||
HARD_SHUTDOWN = "hard_shutdown",
|
||||
CLEAN_REBOOT = "clean_reboot",
|
||||
HARD_REBOOT = "hard_reboot",
|
||||
PAUSE = "pause",
|
||||
SUSPEND = "suspend",
|
||||
}
|
||||
|
||||
export interface XenApiPool extends XenApiRecord {
|
||||
declare const __brand: unique symbol;
|
||||
|
||||
export interface XenApiRecord<Name extends string> {
|
||||
$ref: string & { [__brand]: `${Name}Ref` };
|
||||
uuid: string & { [__brand]: `${Name}Uuid` };
|
||||
}
|
||||
|
||||
export type RawXenApiRecord<T extends XenApiRecord<string>> = Omit<T, "$ref">;
|
||||
|
||||
export interface XenApiPool extends XenApiRecord<"Pool"> {
|
||||
cpu_info: {
|
||||
cpu_count: string;
|
||||
};
|
||||
master: string;
|
||||
master: XenApiHost["$ref"];
|
||||
name_label: string;
|
||||
}
|
||||
|
||||
export interface XenApiHost extends XenApiRecord {
|
||||
export interface XenApiHost extends XenApiRecord<"Host"> {
|
||||
address: string;
|
||||
name_label: string;
|
||||
metrics: string;
|
||||
resident_VMs: string[];
|
||||
metrics: XenApiHostMetrics["$ref"];
|
||||
resident_VMs: XenApiVm["$ref"][];
|
||||
cpu_info: { cpu_count: string };
|
||||
software_version: { product_version: string };
|
||||
}
|
||||
|
||||
export interface XenApiSr extends XenApiRecord {
|
||||
export interface XenApiSr extends XenApiRecord<"Sr"> {
|
||||
name_label: string;
|
||||
physical_size: number;
|
||||
physical_utilisation: number;
|
||||
}
|
||||
|
||||
export interface XenApiVm extends XenApiRecord {
|
||||
current_operations: Record<string, string>;
|
||||
export interface XenApiVm extends XenApiRecord<"Vm"> {
|
||||
current_operations: Record<string, VM_OPERATION>;
|
||||
guest_metrics: string;
|
||||
metrics: string;
|
||||
metrics: XenApiVmMetrics["$ref"];
|
||||
name_label: string;
|
||||
name_description: string;
|
||||
power_state: PowerState;
|
||||
resident_on: string;
|
||||
consoles: string[];
|
||||
power_state: POWER_STATE;
|
||||
resident_on: XenApiHost["$ref"];
|
||||
consoles: XenApiConsole["$ref"][];
|
||||
is_control_domain: boolean;
|
||||
is_a_snapshot: boolean;
|
||||
is_a_template: boolean;
|
||||
VCPUs_at_startup: number;
|
||||
}
|
||||
|
||||
export interface XenApiConsole extends XenApiRecord {
|
||||
export interface XenApiConsole extends XenApiRecord<"Console"> {
|
||||
protocol: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface XenApiHostMetrics extends XenApiRecord {
|
||||
export interface XenApiHostMetrics extends XenApiRecord<"HostMetrics"> {
|
||||
live: boolean;
|
||||
memory_free: number;
|
||||
memory_total: number;
|
||||
}
|
||||
|
||||
export interface XenApiVmMetrics extends XenApiRecord {
|
||||
export interface XenApiVmMetrics extends XenApiRecord<"VmMetrics"> {
|
||||
VCPUs_number: number;
|
||||
}
|
||||
|
||||
export type XenApiVmGuestMetrics = XenApiRecord;
|
||||
export type XenApiVmGuestMetrics = XenApiRecord<"VmGuestMetrics">;
|
||||
|
||||
export interface XenApiTask extends XenApiRecord {
|
||||
export interface XenApiTask extends XenApiRecord<"Task"> {
|
||||
name_label: string;
|
||||
resident_on: string;
|
||||
resident_on: XenApiHost["$ref"];
|
||||
created: string;
|
||||
finished: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface XenApiMessage extends XenApiRecord<"Message"> {
|
||||
name: string;
|
||||
cls: RawObjectType;
|
||||
}
|
||||
|
||||
type WatchCallbackResult = {
|
||||
id: string;
|
||||
class: ObjectType;
|
||||
operation: "add" | "mod" | "del";
|
||||
ref: string;
|
||||
snapshot: RawXenApiRecord<XenApiRecord>;
|
||||
ref: XenApiRecord<string>["$ref"];
|
||||
snapshot: RawXenApiRecord<XenApiRecord<string>>;
|
||||
};
|
||||
|
||||
type WatchCallback = (results: WatchCallbackResult[]) => void;
|
||||
@@ -214,8 +242,7 @@ export default class XenApi {
|
||||
async loadTypes() {
|
||||
this.#types = (await this.#call<string[]>("system.listMethods"))
|
||||
.filter((method: string) => method.endsWith(".get_all_records"))
|
||||
.map((method: string) => method.slice(0, method.indexOf(".")))
|
||||
.filter((type: string) => type !== "message");
|
||||
.map((method: string) => method.slice(0, method.indexOf(".")));
|
||||
}
|
||||
|
||||
get sessionId() {
|
||||
@@ -253,14 +280,16 @@ export default class XenApi {
|
||||
return fetch(url);
|
||||
}
|
||||
|
||||
async loadRecords<T extends XenApiRecord>(type: RawObjectType): Promise<T[]> {
|
||||
async loadRecords<T extends XenApiRecord<string>>(
|
||||
type: RawObjectType
|
||||
): Promise<T[]> {
|
||||
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
|
||||
`${type}.get_all_records`,
|
||||
[this.sessionId]
|
||||
);
|
||||
|
||||
return Object.entries(result).map(([opaqueRef, record]) =>
|
||||
buildXoObject(record, { opaqueRef })
|
||||
buildXoObject(record, { opaqueRef: opaqueRef as T["$ref"] })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,7 +328,7 @@ export default class XenApi {
|
||||
this.#watchCallBack = callback;
|
||||
}
|
||||
|
||||
async injectWatchEvent(poolRef: string) {
|
||||
async injectWatchEvent(poolRef: XenApiPool["$ref"]) {
|
||||
this.#fromToken = await this.#call("event.inject", [
|
||||
this.sessionId,
|
||||
"pool",
|
||||
@@ -313,8 +342,13 @@ export default class XenApi {
|
||||
XenApiVm["$ref"],
|
||||
XenApiVm["power_state"]
|
||||
>;
|
||||
type VmRefsToClone = Record<XenApiVm["$ref"], /* Cloned VM name */ string>;
|
||||
|
||||
return {
|
||||
delete: (vmRefs: VmRefs) =>
|
||||
Promise.all(
|
||||
castArray(vmRefs).map((vmRef) => this._call("VM.destroy", [vmRef]))
|
||||
),
|
||||
start: (vmRefs: VmRefs) =>
|
||||
Promise.all(
|
||||
castArray(vmRefs).map((vmRef) =>
|
||||
@@ -337,7 +371,7 @@ export default class XenApi {
|
||||
);
|
||||
},
|
||||
resume: (vmRefsWithPowerState: VmRefsWithPowerState) => {
|
||||
const vmRefs = Object.keys(vmRefsWithPowerState);
|
||||
const vmRefs = Object.keys(vmRefsWithPowerState) as XenApiVm["$ref"][];
|
||||
|
||||
return Promise.all(
|
||||
vmRefs.map((vmRef) => {
|
||||
@@ -363,6 +397,15 @@ export default class XenApi {
|
||||
)
|
||||
);
|
||||
},
|
||||
clone: (vmRefsToClone: VmRefsToClone) => {
|
||||
const vmRefs = Object.keys(vmRefsToClone) as XenApiVm["$ref"][];
|
||||
|
||||
return Promise.all(
|
||||
vmRefs.map((vmRef) =>
|
||||
this._call("VM.clone", [vmRef, vmRefsToClone[vmRef]])
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"back-pool-dashboard": "Go back to your Pool dashboard",
|
||||
"backup": "Backup",
|
||||
"cancel": "Cancel",
|
||||
"change-power-state": "Change power state",
|
||||
"change-state": "Change state",
|
||||
"confirm-delete": "You're about to delete {0}",
|
||||
"coming-soon": "Coming soon!",
|
||||
"community": "Community",
|
||||
"community-name": "{name} community",
|
||||
@@ -23,6 +23,7 @@
|
||||
"cpu-usage": "CPU usage",
|
||||
"dashboard": "Dashboard",
|
||||
"delete": "Delete",
|
||||
"delete-vms": "Delete 1 VM | Delete {n} VMs",
|
||||
"descending": "descending",
|
||||
"description": "Description",
|
||||
"display": "Display",
|
||||
@@ -56,6 +57,7 @@
|
||||
"following-hosts-unreachable": "The following hosts are unreachable",
|
||||
"force-reboot": "Force reboot",
|
||||
"force-shutdown": "Force shutdown",
|
||||
"go-back": "Go back",
|
||||
"here": "Here",
|
||||
"hosts": "Hosts",
|
||||
"language": "Language",
|
||||
@@ -64,6 +66,7 @@
|
||||
"log-out": "Log out",
|
||||
"login": "Login",
|
||||
"migrate": "Migrate",
|
||||
"n-vms": "1 VM | {n} VMs",
|
||||
"name": "Name",
|
||||
"network": "Network",
|
||||
"network-download": "Download",
|
||||
@@ -79,6 +82,7 @@
|
||||
"password": "Password",
|
||||
"password-invalid": "Password invalid",
|
||||
"pause": "Pause",
|
||||
"please-confirm": "Please confirm",
|
||||
"pool-cpu-usage": "Pool CPU Usage",
|
||||
"pool-ram-usage": "Pool RAM Usage",
|
||||
"power-state": "Power state",
|
||||
@@ -99,10 +103,12 @@
|
||||
},
|
||||
"resume": "Resume",
|
||||
"save": "Save",
|
||||
"selected-vms-in-execution": "Some selected VMs are running",
|
||||
"send-us-feedback": "Send us feedback",
|
||||
"settings": "Settings",
|
||||
"shutdown": "Shutdown",
|
||||
"snapshot": "Snapshot",
|
||||
"selected-vms-in-execution": "Some selected VMs are running",
|
||||
"sort-by": "Sort by",
|
||||
"stacked-cpu-usage": "Stacked CPU usage",
|
||||
"stacked-ram-usage": "Stacked RAM usage",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"back-pool-dashboard": "Revenez au tableau de bord de votre pool",
|
||||
"backup": "Sauvegarde",
|
||||
"cancel": "Annuler",
|
||||
"change-power-state": "Changer l'état d'alimentation",
|
||||
"confirm-delete": "Vous êtes sur le point de supprimer {0}",
|
||||
"change-state": "Changer l'état",
|
||||
"coming-soon": "Bientôt disponible !",
|
||||
"community": "Communauté",
|
||||
@@ -23,6 +23,7 @@
|
||||
"cpu-usage": "Utilisation CPU",
|
||||
"dashboard": "Tableau de bord",
|
||||
"delete": "Supprimer",
|
||||
"delete-vms": "Supprimer 1 VM | Supprimer {n} VMs",
|
||||
"descending": "descendant",
|
||||
"description": "Description",
|
||||
"display": "Affichage",
|
||||
@@ -56,6 +57,7 @@
|
||||
"following-hosts-unreachable": "Les hôtes suivants sont inaccessibles",
|
||||
"force-reboot": "Forcer le redémarrage",
|
||||
"force-shutdown": "Forcer l'arrêt",
|
||||
"go-back": "Revenir en arrière",
|
||||
"here": "Ici",
|
||||
"hosts": "Hôtes",
|
||||
"language": "Langue",
|
||||
@@ -64,6 +66,7 @@
|
||||
"log-out": "Se déconnecter",
|
||||
"login": "Connexion",
|
||||
"migrate": "Migrer",
|
||||
"n-vms": "1 VM | {n} VMs",
|
||||
"name": "Nom",
|
||||
"network": "Réseau",
|
||||
"network-download": "Descendant",
|
||||
@@ -79,6 +82,7 @@
|
||||
"password": "Mot de passe",
|
||||
"password-invalid": "Mot de passe incorrect",
|
||||
"pause": "Pause",
|
||||
"please-confirm": "Veuillez confirmer",
|
||||
"pool-cpu-usage": "Utilisation CPU du Pool",
|
||||
"pool-ram-usage": "Utilisation RAM du Pool",
|
||||
"power-state": "État d'alimentation",
|
||||
@@ -99,10 +103,12 @@
|
||||
},
|
||||
"resume": "Reprendre",
|
||||
"save": "Enregistrer",
|
||||
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
|
||||
"send-us-feedback": "Envoyez-nous vos commentaires",
|
||||
"settings": "Paramètres",
|
||||
"shutdown": "Arrêter",
|
||||
"snapshot": "Instantané",
|
||||
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
|
||||
"sort-by": "Trier par",
|
||||
"stacked-cpu-usage": "Utilisation CPU empilée",
|
||||
"stacked-ram-usage": "Utilisation RAM empilée",
|
||||
|
||||
31
@xen-orchestra/lite/src/stores/alarm.store.ts
Normal file
31
@xen-orchestra/lite/src/stores/alarm.store.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { XenApiMessage } from "@/libs/xen-api";
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { createSubscribe } from "@/types/xapi-collection";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed } from "vue";
|
||||
|
||||
export const useAlarmStore = defineStore("alarm", () => {
|
||||
const messageCollection = useXapiCollectionStore().get("message");
|
||||
|
||||
const subscribe = createSubscribe<XenApiMessage, []>((options) => {
|
||||
const originalSubscription = messageCollection.subscribe(options);
|
||||
|
||||
const extendedSubscription = {
|
||||
records: computed(() =>
|
||||
originalSubscription.records.value.filter(
|
||||
(record) => record.name === "alarm"
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
...originalSubscription,
|
||||
...extendedSubscription,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...messageCollection,
|
||||
subscribe,
|
||||
};
|
||||
});
|
||||
39
@xen-orchestra/lite/src/stores/closing-confirmation.store.ts
Normal file
39
@xen-orchestra/lite/src/stores/closing-confirmation.store.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const beforeUnloadListener = function (e: BeforeUnloadEvent) {
|
||||
e.preventDefault();
|
||||
e.returnValue = ""; // Required to trigger the modal on some browser. https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#browser_compatibility
|
||||
};
|
||||
|
||||
export const useClosingConfirmationStore = defineStore(
|
||||
"closing-confirmation",
|
||||
() => {
|
||||
const registeredIds = ref(new Set<symbol>());
|
||||
watch(
|
||||
() => registeredIds.value.size > 0,
|
||||
(isConfirmationNeeded) => {
|
||||
const eventMethod = isConfirmationNeeded
|
||||
? "addEventListener"
|
||||
: "removeEventListener";
|
||||
|
||||
window[eventMethod]("beforeunload", beforeUnloadListener);
|
||||
}
|
||||
);
|
||||
|
||||
const register = () => {
|
||||
const id = Symbol();
|
||||
registeredIds.value.add(id);
|
||||
|
||||
const unregister = () => registeredIds.value.delete(id);
|
||||
|
||||
onBeforeUnmount(unregister);
|
||||
|
||||
return unregister;
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -3,26 +3,24 @@ import type { GRANULARITY, XapiStatsResponse } from "@/libs/xapi-stats";
|
||||
import type { XenApiHost, XenApiHostMetrics } from "@/libs/xen-api";
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import type { CollectionSubscription } from "@/types/xapi-collection";
|
||||
import type { Subscription } from "@/types/xapi-collection";
|
||||
import { createSubscribe } from "@/types/xapi-collection";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
|
||||
type MetricsSubscription = CollectionSubscription<XenApiHostMetrics>;
|
||||
|
||||
interface HostSubscribeOptions<M extends undefined | MetricsSubscription> {
|
||||
hostMetricsSubscription?: M;
|
||||
}
|
||||
|
||||
interface HostSubscription extends CollectionSubscription<XenApiHost> {
|
||||
type GetStatsExtension = {
|
||||
getStats: (
|
||||
hostUuid: string,
|
||||
hostUuid: XenApiHost["uuid"],
|
||||
granularity: GRANULARITY
|
||||
) => Promise<XapiStatsResponse<any>>;
|
||||
}
|
||||
) => Promise<XapiStatsResponse<any>> | undefined;
|
||||
};
|
||||
|
||||
interface HostSubscriptionWithRunningHosts extends HostSubscription {
|
||||
runningHosts: ComputedRef<XenApiHost[]>;
|
||||
}
|
||||
type RunningHostsExtension = [
|
||||
{ runningHosts: ComputedRef<XenApiHost[]> },
|
||||
{ hostMetricsSubscription: Subscription<XenApiHostMetrics, any> }
|
||||
];
|
||||
|
||||
type Extensions = [GetStatsExtension, RunningHostsExtension];
|
||||
|
||||
export const useHostStore = defineStore("host", () => {
|
||||
const xenApiStore = useXenApiStore();
|
||||
@@ -30,21 +28,14 @@ export const useHostStore = defineStore("host", () => {
|
||||
|
||||
hostCollection.setSort(sortRecordsByNameLabel);
|
||||
|
||||
function subscribe(
|
||||
options?: HostSubscribeOptions<undefined>
|
||||
): HostSubscription;
|
||||
const subscribe = createSubscribe<XenApiHost, Extensions>((options) => {
|
||||
const originalSubscription = hostCollection.subscribe(options);
|
||||
|
||||
function subscribe(
|
||||
options?: HostSubscribeOptions<MetricsSubscription>
|
||||
): HostSubscriptionWithRunningHosts;
|
||||
|
||||
function subscribe({
|
||||
hostMetricsSubscription,
|
||||
}: HostSubscribeOptions<undefined | MetricsSubscription> = {}) {
|
||||
const hostSubscription = hostCollection.subscribe();
|
||||
|
||||
const getStats = (hostUuid: string, granularity: GRANULARITY) => {
|
||||
const host = hostSubscription.getByUuid(hostUuid);
|
||||
const getStats = (
|
||||
hostUuid: XenApiHost["uuid"],
|
||||
granularity: GRANULARITY
|
||||
) => {
|
||||
const host = originalSubscription.getByUuid(hostUuid);
|
||||
|
||||
if (host === undefined) {
|
||||
throw new Error(`Host ${hostUuid} could not be found.`);
|
||||
@@ -61,26 +52,25 @@ export const useHostStore = defineStore("host", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const subscription = {
|
||||
...hostSubscription,
|
||||
const extendedSubscription = {
|
||||
getStats,
|
||||
};
|
||||
|
||||
if (hostMetricsSubscription === undefined) {
|
||||
return subscription;
|
||||
}
|
||||
const hostMetricsSubscription = options?.hostMetricsSubscription;
|
||||
|
||||
const runningHosts = computed(() =>
|
||||
hostSubscription.records.value.filter((host) =>
|
||||
isHostRunning(host, hostMetricsSubscription)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
runningHosts,
|
||||
const runningHostsSubscription = hostMetricsSubscription !== undefined && {
|
||||
runningHosts: computed(() =>
|
||||
originalSubscription.records.value.filter((host) =>
|
||||
isHostRunning(host, hostMetricsSubscription)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...originalSubscription,
|
||||
...extendedSubscription,
|
||||
...runningHostsSubscription,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...hostCollection,
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import { getFirst } from "@/libs/utils";
|
||||
import type { XenApiPool } from "@/libs/xen-api";
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { createSubscribe } from "@/types/xapi-collection";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
|
||||
type PoolExtension = {
|
||||
pool: ComputedRef<XenApiPool | undefined>;
|
||||
};
|
||||
|
||||
type Extensions = [PoolExtension];
|
||||
|
||||
export const usePoolStore = defineStore("pool", () => {
|
||||
const poolCollection = useXapiCollectionStore().get("pool");
|
||||
|
||||
const subscribe = () => {
|
||||
const subscription = poolCollection.subscribe();
|
||||
const subscribe = createSubscribe<XenApiPool, Extensions>((options) => {
|
||||
const originalSubscription = poolCollection.subscribe(options);
|
||||
|
||||
const pool = computed(() => getFirst(subscription.records.value));
|
||||
const extendedSubscription = {
|
||||
pool: computed(() => getFirst(originalSubscription.records.value)),
|
||||
};
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
pool,
|
||||
...originalSubscription,
|
||||
...extendedSubscription,
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...poolCollection,
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { sortRecordsByNameLabel } from "@/libs/utils";
|
||||
import type { GRANULARITY, XapiStatsResponse } from "@/libs/xapi-stats";
|
||||
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
|
||||
import { POWER_STATE } from "@/libs/xen-api";
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import type { CollectionSubscription } from "@/types/xapi-collection";
|
||||
import { createSubscribe, type Subscription } from "@/types/xapi-collection";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
|
||||
type HostSubscription = CollectionSubscription<XenApiHost>;
|
||||
|
||||
type VmSubscribeOptions<H extends undefined | HostSubscription> = {
|
||||
hostSubscription?: H;
|
||||
type DefaultExtension = {
|
||||
recordsByHostRef: ComputedRef<Map<XenApiHost["$ref"], XenApiVm[]>>;
|
||||
runningVms: ComputedRef<XenApiVm[]>;
|
||||
};
|
||||
|
||||
interface VmSubscription extends CollectionSubscription<XenApiVm> {
|
||||
recordsByHostRef: ComputedRef<Map<string, XenApiVm[]>>;
|
||||
runningVms: ComputedRef<XenApiVm[]>;
|
||||
}
|
||||
type GetStatsExtension = [
|
||||
{
|
||||
getStats: (
|
||||
id: XenApiVm["uuid"],
|
||||
granularity: GRANULARITY
|
||||
) => Promise<XapiStatsResponse<any>>;
|
||||
},
|
||||
{ hostSubscription: Subscription<XenApiHost, object> }
|
||||
];
|
||||
|
||||
interface VmSubscriptionWithGetStats extends VmSubscription {
|
||||
getStats: (
|
||||
id: string,
|
||||
granularity: GRANULARITY
|
||||
) => Promise<XapiStatsResponse<any>>;
|
||||
}
|
||||
type Extensions = [DefaultExtension, GetStatsExtension];
|
||||
|
||||
export const useVmStore = defineStore("vm", () => {
|
||||
const vmCollection = useXapiCollectionStore().get("VM");
|
||||
@@ -34,76 +34,66 @@ export const useVmStore = defineStore("vm", () => {
|
||||
|
||||
vmCollection.setSort(sortRecordsByNameLabel);
|
||||
|
||||
function subscribe(options?: VmSubscribeOptions<undefined>): VmSubscription;
|
||||
const subscribe = createSubscribe<XenApiVm, Extensions>((options) => {
|
||||
const originalSubscription = vmCollection.subscribe(options);
|
||||
|
||||
function subscribe(
|
||||
options?: VmSubscribeOptions<HostSubscription>
|
||||
): VmSubscriptionWithGetStats;
|
||||
const extendedSubscription = {
|
||||
recordsByHostRef: computed(() => {
|
||||
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
|
||||
|
||||
function subscribe({
|
||||
hostSubscription,
|
||||
}: VmSubscribeOptions<undefined | HostSubscription> = {}) {
|
||||
const vmSubscription = vmCollection.subscribe();
|
||||
originalSubscription.records.value.forEach((vm) => {
|
||||
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
|
||||
vmsByHostOpaqueRef.set(vm.resident_on, []);
|
||||
}
|
||||
|
||||
const recordsByHostRef = computed(() => {
|
||||
const vmsByHostOpaqueRef = new Map<string, XenApiVm[]>();
|
||||
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
|
||||
});
|
||||
|
||||
vmSubscription.records.value.forEach((vm) => {
|
||||
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
|
||||
vmsByHostOpaqueRef.set(vm.resident_on, []);
|
||||
}
|
||||
|
||||
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
|
||||
});
|
||||
|
||||
return vmsByHostOpaqueRef;
|
||||
});
|
||||
|
||||
const runningVms = computed(() =>
|
||||
vmSubscription.records.value.filter((vm) => vm.power_state === "Running")
|
||||
);
|
||||
|
||||
const subscription = {
|
||||
...vmSubscription,
|
||||
recordsByHostRef,
|
||||
runningVms,
|
||||
return vmsByHostOpaqueRef;
|
||||
}),
|
||||
runningVms: computed(() =>
|
||||
originalSubscription.records.value.filter(
|
||||
(vm) => vm.power_state === POWER_STATE.RUNNING
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
if (hostSubscription === undefined) {
|
||||
return subscription;
|
||||
}
|
||||
const hostSubscription = options?.hostSubscription;
|
||||
|
||||
const getStats = (id: string, granularity: GRANULARITY) => {
|
||||
const xenApiStore = useXenApiStore();
|
||||
const getStatsSubscription = hostSubscription !== undefined && {
|
||||
getStats: (vmUuid: XenApiVm["uuid"], granularity: GRANULARITY) => {
|
||||
const xenApiStore = useXenApiStore();
|
||||
|
||||
if (!xenApiStore.isConnected) {
|
||||
return undefined;
|
||||
}
|
||||
if (!xenApiStore.isConnected) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const vm = vmSubscription.getByUuid(id);
|
||||
const vm = originalSubscription.getByUuid(vmUuid);
|
||||
|
||||
if (vm === undefined) {
|
||||
throw new Error(`VM ${id} could not be found.`);
|
||||
}
|
||||
if (vm === undefined) {
|
||||
throw new Error(`VM ${vmUuid} could not be found.`);
|
||||
}
|
||||
|
||||
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
|
||||
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
|
||||
|
||||
if (host === undefined) {
|
||||
throw new Error(`VM ${id} is halted or host could not be found.`);
|
||||
}
|
||||
if (host === undefined) {
|
||||
throw new Error(`VM ${vmUuid} is halted or host could not be found.`);
|
||||
}
|
||||
|
||||
return xenApiStore.getXapiStats()._getAndUpdateStats({
|
||||
host,
|
||||
uuid: vm.uuid,
|
||||
granularity,
|
||||
});
|
||||
return xenApiStore.getXapiStats()._getAndUpdateStats({
|
||||
host,
|
||||
uuid: vm.uuid,
|
||||
granularity,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
getStats,
|
||||
...originalSubscription,
|
||||
...extendedSubscription,
|
||||
...getStatsSubscription,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...vmCollection,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import type {
|
||||
CollectionSubscription,
|
||||
DeferredCollectionSubscription,
|
||||
RawTypeToObject,
|
||||
SubscribeOptions,
|
||||
Subscription,
|
||||
} from "@/types/xapi-collection";
|
||||
import { tryOnUnmounted, whenever } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
@@ -17,7 +16,7 @@ export const useXapiCollectionStore = defineStore("xapiCollection", () => {
|
||||
|
||||
function get<
|
||||
T extends RawObjectType,
|
||||
S extends XenApiRecord = RawTypeToObject[T]
|
||||
S extends XenApiRecord<string> = RawTypeToObject[T]
|
||||
>(type: T): ReturnType<typeof createXapiCollection<S>> {
|
||||
if (!collections.value.has(type)) {
|
||||
collections.value.set(type, createXapiCollection<S>(type));
|
||||
@@ -29,15 +28,17 @@ export const useXapiCollectionStore = defineStore("xapiCollection", () => {
|
||||
return { get };
|
||||
});
|
||||
|
||||
const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
|
||||
const createXapiCollection = <T extends XenApiRecord<string>>(
|
||||
type: RawObjectType
|
||||
) => {
|
||||
const isReady = ref(false);
|
||||
const isFetching = ref(false);
|
||||
const isReloading = computed(() => isReady.value && isFetching.value);
|
||||
const lastError = ref<string>();
|
||||
const hasError = computed(() => lastError.value !== undefined);
|
||||
const subscriptions = ref(new Set<symbol>());
|
||||
const recordsByOpaqueRef = ref(new Map<string, T>());
|
||||
const recordsByUuid = ref(new Map<string, T>());
|
||||
const recordsByOpaqueRef = ref(new Map<T["$ref"], T>());
|
||||
const recordsByUuid = ref(new Map<T["uuid"], T>());
|
||||
const filter = ref<(record: T) => boolean>();
|
||||
const sort = ref<(record1: T, record2: T) => 1 | 0 | -1>();
|
||||
const xenApiStore = useXenApiStore();
|
||||
@@ -55,12 +56,12 @@ const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
|
||||
return filter.value !== undefined ? records.filter(filter.value) : records;
|
||||
});
|
||||
|
||||
const getByOpaqueRef = (opaqueRef: string) =>
|
||||
const getByOpaqueRef = (opaqueRef: T["$ref"]) =>
|
||||
recordsByOpaqueRef.value.get(opaqueRef);
|
||||
|
||||
const getByUuid = (uuid: string) => recordsByUuid.value.get(uuid);
|
||||
const getByUuid = (uuid: T["uuid"]) => recordsByUuid.value.get(uuid);
|
||||
|
||||
const hasUuid = (uuid: string) => recordsByUuid.value.has(uuid);
|
||||
const hasUuid = (uuid: T["uuid"]) => recordsByUuid.value.has(uuid);
|
||||
|
||||
const hasSubscriptions = computed(() => subscriptions.value.size > 0);
|
||||
|
||||
@@ -90,7 +91,7 @@ const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
|
||||
recordsByUuid.value.set(record.uuid, record);
|
||||
};
|
||||
|
||||
const remove = (opaqueRef: string) => {
|
||||
const remove = (opaqueRef: T["$ref"]) => {
|
||||
if (!recordsByOpaqueRef.value.has(opaqueRef)) {
|
||||
return;
|
||||
}
|
||||
@@ -105,25 +106,11 @@ const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
|
||||
() => fetchAll()
|
||||
);
|
||||
|
||||
function subscribe(
|
||||
options?: SubscribeOptions<true>
|
||||
): CollectionSubscription<T>;
|
||||
|
||||
function subscribe(
|
||||
options: SubscribeOptions<false>
|
||||
): DeferredCollectionSubscription<T>;
|
||||
|
||||
function subscribe(
|
||||
options: SubscribeOptions<boolean>
|
||||
): CollectionSubscription<T> | DeferredCollectionSubscription<T>;
|
||||
|
||||
function subscribe({ immediate = true }: SubscribeOptions<boolean> = {}) {
|
||||
function subscribe<O extends SubscribeOptions<any>>(
|
||||
options?: O
|
||||
): Subscription<T, O> {
|
||||
const id = Symbol();
|
||||
|
||||
if (immediate) {
|
||||
subscriptions.value.add(id);
|
||||
}
|
||||
|
||||
tryOnUnmounted(() => {
|
||||
unsubscribe(id);
|
||||
});
|
||||
@@ -140,15 +127,18 @@ const createXapiCollection = <T extends XenApiRecord>(type: RawObjectType) => {
|
||||
lastError: readonly(lastError),
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
return subscription;
|
||||
const start = () => subscriptions.value.add(id);
|
||||
|
||||
if (options?.immediate !== false) {
|
||||
start();
|
||||
return subscription as unknown as Subscription<T, O>;
|
||||
}
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
start: () => subscriptions.value.add(id),
|
||||
start,
|
||||
isStarted: computed(() => subscriptions.value.has(id)),
|
||||
};
|
||||
} as unknown as Subscription<T, O>;
|
||||
}
|
||||
|
||||
const unsubscribe = (id: symbol) => subscriptions.value.delete(id);
|
||||
|
||||
@@ -39,17 +39,16 @@ export const useXenApiStore = defineStore("xen-api", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const buildObject = () =>
|
||||
buildXoObject(result.snapshot, { opaqueRef: result.ref }) as any;
|
||||
|
||||
switch (result.operation) {
|
||||
case "add":
|
||||
return collection.add(
|
||||
buildXoObject(result.snapshot, { opaqueRef: result.ref })
|
||||
);
|
||||
return collection.add(buildObject());
|
||||
case "mod":
|
||||
return collection.update(
|
||||
buildXoObject(result.snapshot, { opaqueRef: result.ref })
|
||||
);
|
||||
return collection.update(buildObject());
|
||||
case "del":
|
||||
return collection.remove(result.ref);
|
||||
return collection.remove(result.ref as any);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<ComponentStory
|
||||
:params="[
|
||||
prop('state')
|
||||
.enum('Running', 'Suspended', 'Halted', 'Paused')
|
||||
.enum(...Object.values(POWER_STATE))
|
||||
.required()
|
||||
.preset('Running')
|
||||
.preset(POWER_STATE.RUNNING)
|
||||
.widget(),
|
||||
]"
|
||||
v-slot="{ properties }"
|
||||
@@ -17,6 +17,7 @@
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import { prop } from "@/libs/story/story-param";
|
||||
import { POWER_STATE } from "@/libs/xen-api";
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties, settings }"
|
||||
:params="[
|
||||
colorProp(),
|
||||
iconProp(),
|
||||
@@ -11,17 +12,31 @@
|
||||
slot('buttons').help('Meant to receive UiButton components'),
|
||||
setting('title').preset('Modal Title').widget(),
|
||||
setting('subtitle').preset('Modal Subtitle').widget(),
|
||||
setting('nested_modal').widget(boolean()),
|
||||
]"
|
||||
v-slot="{ properties, settings }"
|
||||
>
|
||||
<UiButton type="button" @click="open">Open Modal</UiButton>
|
||||
|
||||
<UiModal v-bind="properties" v-if="isOpen">
|
||||
<UiModal v-if="isOpen" v-bind="properties">
|
||||
<template #title>{{ settings.title }}</template>
|
||||
<template #subtitle>{{ settings.subtitle }}</template>
|
||||
<template #buttons>
|
||||
<UiButton @click="close">Discard</UiButton>
|
||||
</template>
|
||||
<template v-if="settings.nested_modal">
|
||||
<UiModal :icon="faWarning" color="warning">
|
||||
<template #title>Warning</template>
|
||||
<template #subtitle> This is a warning "nested" modal.</template>
|
||||
<UiModal :icon="faInfoCircle" color="info">
|
||||
<template #title>Info</template>
|
||||
<template #subtitle> This is an info "nested" modal.</template>
|
||||
</UiModal>
|
||||
</UiModal>
|
||||
<UiModal :icon="faCheck" color="success">
|
||||
<template #title>Success</template>
|
||||
<template #subtitle> This is a success "deep nested" modal.</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
</UiModal>
|
||||
</ComponentStory>
|
||||
</template>
|
||||
@@ -38,6 +53,12 @@ import {
|
||||
setting,
|
||||
slot,
|
||||
} from "@/libs/story/story-param";
|
||||
import {
|
||||
faCheck,
|
||||
faInfoCircle,
|
||||
faWarning,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { boolean } from "@/libs/story/story-widget";
|
||||
|
||||
const { open, close, isOpen } = useModal();
|
||||
</script>
|
||||
|
||||
4
@xen-orchestra/lite/src/types/injection-keys.ts
Normal file
4
@xen-orchestra/lite/src/types/injection-keys.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { InjectionKey } from "vue";
|
||||
|
||||
export const IK_MENU_TELEPORTED = Symbol() as InjectionKey<boolean>;
|
||||
export const IK_MODAL_NESTED = Symbol() as InjectionKey<boolean>;
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
XenApiConsole,
|
||||
XenApiHost,
|
||||
XenApiHostMetrics,
|
||||
XenApiMessage,
|
||||
XenApiPool,
|
||||
XenApiRecord,
|
||||
XenApiSr,
|
||||
@@ -12,26 +13,73 @@ import type {
|
||||
} from "@/libs/xen-api";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
export interface SubscribeOptions<Immediate extends boolean> {
|
||||
immediate?: Immediate;
|
||||
}
|
||||
|
||||
export interface CollectionSubscription<T extends XenApiRecord> {
|
||||
type DefaultExtension<T extends XenApiRecord<string>> = {
|
||||
records: ComputedRef<T[]>;
|
||||
getByOpaqueRef: (opaqueRef: string) => T | undefined;
|
||||
getByUuid: (uuid: string) => T | undefined;
|
||||
hasUuid: (uuid: string) => boolean;
|
||||
getByOpaqueRef: (opaqueRef: T["$ref"]) => T | undefined;
|
||||
getByUuid: (uuid: T["uuid"]) => T | undefined;
|
||||
hasUuid: (uuid: T["uuid"]) => boolean;
|
||||
isReady: Readonly<Ref<boolean>>;
|
||||
isFetching: Readonly<Ref<boolean>>;
|
||||
isReloading: ComputedRef<boolean>;
|
||||
hasError: ComputedRef<boolean>;
|
||||
lastError: Readonly<Ref<string | undefined>>;
|
||||
}
|
||||
};
|
||||
|
||||
export interface DeferredCollectionSubscription<T extends XenApiRecord>
|
||||
extends CollectionSubscription<T> {
|
||||
start: () => void;
|
||||
isStarted: ComputedRef<boolean>;
|
||||
type DeferExtension = [
|
||||
{
|
||||
start: () => void;
|
||||
isStarted: ComputedRef<boolean>;
|
||||
},
|
||||
{ immediate: false }
|
||||
];
|
||||
|
||||
type DefaultExtensions<T extends XenApiRecord<string>> = [
|
||||
DefaultExtension<T>,
|
||||
DeferExtension
|
||||
];
|
||||
|
||||
type GenerateSubscribeOptions<Extensions extends any[]> = Extensions extends [
|
||||
infer FirstExtension,
|
||||
...infer RestExtension
|
||||
]
|
||||
? FirstExtension extends [object, infer FirstCondition]
|
||||
? FirstCondition & GenerateSubscribeOptions<RestExtension>
|
||||
: GenerateSubscribeOptions<RestExtension>
|
||||
: object;
|
||||
|
||||
export type SubscribeOptions<Extensions extends any[]> = Partial<
|
||||
GenerateSubscribeOptions<Extensions> &
|
||||
GenerateSubscribeOptions<DefaultExtensions<any>>
|
||||
>;
|
||||
|
||||
type GenerateSubscription<
|
||||
Options extends object,
|
||||
Extensions extends any[]
|
||||
> = Extensions extends [infer FirstExtension, ...infer RestExtension]
|
||||
? FirstExtension extends [infer FirstObject, infer FirstCondition]
|
||||
? Options extends FirstCondition
|
||||
? FirstObject & GenerateSubscription<Options, RestExtension>
|
||||
: GenerateSubscription<Options, RestExtension>
|
||||
: FirstExtension & GenerateSubscription<Options, RestExtension>
|
||||
: object;
|
||||
|
||||
export type Subscription<
|
||||
T extends XenApiRecord<string>,
|
||||
Options extends object,
|
||||
Extensions extends any[] = []
|
||||
> = GenerateSubscription<Options, Extensions> &
|
||||
GenerateSubscription<Options, DefaultExtensions<T>>;
|
||||
|
||||
export function createSubscribe<
|
||||
T extends XenApiRecord<string>,
|
||||
Extensions extends any[],
|
||||
Options extends object = SubscribeOptions<Extensions>
|
||||
>(builder: (options?: Options) => Subscription<T, Options, Extensions>) {
|
||||
return function subscribe<O extends Options>(
|
||||
options?: O
|
||||
): Subscription<T, O, Extensions> {
|
||||
return builder(options);
|
||||
};
|
||||
}
|
||||
|
||||
export type RawTypeToObject = {
|
||||
@@ -78,6 +126,7 @@ export type RawTypeToObject = {
|
||||
host_crashdump: never;
|
||||
host_metrics: XenApiHostMetrics;
|
||||
host_patch: never;
|
||||
message: XenApiMessage;
|
||||
network: never;
|
||||
network_sriov: never;
|
||||
pool: XenApiPool;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
|
||||
import type { XenApiHost } from "@/libs/xen-api";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { watchEffect } from "vue";
|
||||
@@ -16,6 +17,8 @@ const route = useRoute();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
watchEffect(() => {
|
||||
uiStore.currentHostOpaqueRef = getByUuid(route.params.uuid as string)?.$ref;
|
||||
uiStore.currentHostOpaqueRef = getByUuid(
|
||||
route.params.uuid as XenApiHost["uuid"]
|
||||
)?.$ref;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
<template>
|
||||
<div class="pool-dashboard-view">
|
||||
<div class="item">
|
||||
<UiCardGroup>
|
||||
<PoolDashboardStatus />
|
||||
</div>
|
||||
<div class="item">
|
||||
<PoolDashboardStorageUsage />
|
||||
</div>
|
||||
<div class="item">
|
||||
<PoolDashboardCpuUsage />
|
||||
</div>
|
||||
<div class="item">
|
||||
<PoolDashboardRamUsage />
|
||||
</div>
|
||||
<div class="item">
|
||||
<PoolDashboardCpuProvisioning />
|
||||
</div>
|
||||
<div class="item">
|
||||
<PoolDashboardNetworkChart />
|
||||
</div>
|
||||
<div class="item">
|
||||
<PoolDashboardRamUsageChart />
|
||||
</div>
|
||||
<div class="item">
|
||||
<PoolCpuUsageChart />
|
||||
</div>
|
||||
<UiCardComingSoon class="alarms" title="Alarms" />
|
||||
<UiCardComingSoon title="Patches" />
|
||||
</UiCardGroup>
|
||||
<UiCardGroup>
|
||||
<UiCardGroup>
|
||||
<PoolDashboardStorageUsage />
|
||||
<PoolDashboardNetworkChart />
|
||||
</UiCardGroup>
|
||||
<UiCardGroup>
|
||||
<PoolDashboardRamUsage />
|
||||
<PoolDashboardRamUsageChart />
|
||||
</UiCardGroup>
|
||||
<UiCardGroup>
|
||||
<PoolDashboardCpuProvisioning />
|
||||
<PoolDashboardCpuUsage />
|
||||
<PoolCpuUsageChart />
|
||||
</UiCardGroup>
|
||||
</UiCardGroup>
|
||||
<UiCardGroup>
|
||||
<UiCardComingSoon class="tasks" title="Tasks" />
|
||||
</UiCardGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,9 +31,11 @@ export const N_ITEMS = 5;
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCardGroup from "@/components/ui/UiCardGroup.vue";
|
||||
import { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { differenceBy } from "lodash-es";
|
||||
import { provide, watch } from "vue";
|
||||
import UiCardComingSoon from "@/components/ui/UiCardComingSoon.vue";
|
||||
import PoolCpuUsageChart from "@/components/pool/dashboard/cpuUsage/PoolCpuUsageChart.vue";
|
||||
import PoolDashboardCpuUsage from "@/components/pool/dashboard/PoolDashboardCpuUsage.vue";
|
||||
import PoolDashboardNetworkChart from "@/components/pool/dashboard/PoolDashboardNetworkChart.vue";
|
||||
@@ -112,32 +113,14 @@ runningVms.value.forEach((vm) => vmRegister(vm));
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.pool-dashboard-view {
|
||||
column-gap: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.pool-dashboard-view {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
.pool-dashboard-view {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.item {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.alarms,
|
||||
.tasks {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiCard :color="hasError ? 'error' : undefined">
|
||||
<UiTitle class="title-with-counter" type="h4">
|
||||
{{ $t("tasks") }}
|
||||
<UiCounter :value="pendingTasks.length" color="info" />
|
||||
</UiTitle>
|
||||
|
||||
<TasksTable :finished-tasks="finishedTasks" :pending-tasks="pendingTasks" />
|
||||
<UiCardSpinner v-if="!isReady" />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TasksTable from "@/components/tasks/TasksTable.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
|
||||
@@ -27,7 +25,7 @@ import { useTitle } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { records, isReady } = useTaskStore().subscribe();
|
||||
const { records, hasError } = useTaskStore().subscribe();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { compareFn } = useCollectionSorter<XenApiTask>({
|
||||
|
||||
@@ -37,6 +37,7 @@ import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import VmsActionsBar from "@/components/vm/VmsActionsBar.vue";
|
||||
import { POWER_STATE } from "@/libs/xen-api";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import type { Filters } from "@/types/filter";
|
||||
@@ -56,7 +57,7 @@ const filters: Filters = {
|
||||
label: t("power-state"),
|
||||
icon: faPowerOff,
|
||||
type: "enum",
|
||||
choices: ["Running", "Halted", "Paused", "Suspended"],
|
||||
choices: Object.values(POWER_STATE),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import RemoteConsole from "@/components/RemoteConsole.vue";
|
||||
@@ -17,13 +18,13 @@ import { useVmStore } from "@/stores/vm.store";
|
||||
import { isOperationsPending } from "@/libs/utils";
|
||||
|
||||
const STOP_OPERATIONS = [
|
||||
"shutdown",
|
||||
"clean_shutdown",
|
||||
"hard_shutdown",
|
||||
"clean_reboot",
|
||||
"hard_reboot",
|
||||
"pause",
|
||||
"suspend",
|
||||
VM_OPERATION.SHUTDOWN,
|
||||
VM_OPERATION.CLEAN_SHUTDOWN,
|
||||
VM_OPERATION.HARD_SHUTDOWN,
|
||||
VM_OPERATION.CLEAN_REBOOT,
|
||||
VM_OPERATION.HARD_REBOOT,
|
||||
VM_OPERATION.PAUSE,
|
||||
VM_OPERATION.SUSPEND,
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
@@ -35,9 +36,11 @@ const { isReady: isConsoleReady, getByOpaqueRef: getConsoleByOpaqueRef } =
|
||||
|
||||
const isReady = computed(() => isVmReady.value && isConsoleReady.value);
|
||||
|
||||
const vm = computed(() => getVmByUuid(route.params.uuid as string));
|
||||
const vm = computed(() => getVmByUuid(route.params.uuid as XenApiVm["uuid"]));
|
||||
|
||||
const isVmRunning = computed(() => vm.value?.power_state === "Running");
|
||||
const isVmRunning = computed(
|
||||
() => vm.value?.power_state === POWER_STATE.RUNNING
|
||||
);
|
||||
|
||||
const vmConsole = computed(() => {
|
||||
const consoleOpaqueRef = vm.value?.consoles[0];
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
|
||||
import VmHeader from "@/components/vm/VmHeader.vue";
|
||||
import VmTabBar from "@/components/vm/VmTabBar.vue";
|
||||
import type { XenApiVm } from "@/libs/xen-api";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { whenever } from "@vueuse/core";
|
||||
@@ -19,6 +20,6 @@ import { useRoute } from "vue-router";
|
||||
const route = useRoute();
|
||||
const { getByUuid, hasUuid, isReady } = useVmStore().subscribe();
|
||||
const uiStore = useUiStore();
|
||||
const vm = computed(() => getByUuid(route.params.uuid as string));
|
||||
const vm = computed(() => getByUuid(route.params.uuid as XenApiVm["uuid"]));
|
||||
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on));
|
||||
</script>
|
||||
|
||||
@@ -45,6 +45,7 @@ export default class HttpProxy {
|
||||
if (enabled) {
|
||||
events.add('connect', this.#handleConnect.bind(this)).add('request', this.#handleRequest.bind(this))
|
||||
}
|
||||
debug(enabled ? 'enabled' : 'disabled')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,6 +91,9 @@ export default class HttpProxy {
|
||||
|
||||
try {
|
||||
await this.#handleAuthentication(req, res, async () => {
|
||||
// ServerResponse is no longer necessary
|
||||
res.detachSocket(clientSocket)
|
||||
|
||||
const { port, hostname } = new URL('http://' + req.url)
|
||||
const serverSocket = net.connect(port || 80, hostname)
|
||||
|
||||
@@ -97,12 +101,15 @@ export default class HttpProxy {
|
||||
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
||||
serverSocket.write(head)
|
||||
fromCallback(pipeline, clientSocket, serverSocket).catch(warn)
|
||||
fromCallback(pipeline, serverSocket, clientSocket).catch(warn)
|
||||
|
||||
await fromCallback(pipeline, serverSocket, clientSocket, serverSocket)
|
||||
})
|
||||
} catch (error) {
|
||||
warn(error)
|
||||
clientSocket.end()
|
||||
// Ignore premature close errors, which simply means that either the client or server
|
||||
// socket has closed the connection without waiting proper connection termination
|
||||
if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
warn(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export default class Tasks extends EventEmitter {
|
||||
create({ name, objectId, type }) {
|
||||
const tasks = this.#tasks
|
||||
|
||||
const task = new Task({ data: { name, objectId, type }, onProgress: this.#onProgress })
|
||||
const task = new Task({ properties: { name, objectId, type }, onProgress: this.#onProgress })
|
||||
|
||||
// Use a compact, sortable, string representation of the creation date
|
||||
//
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"engines": {
|
||||
"node": ">=15.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/event-listeners-manager": "^1.0.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@vates/task": "^0.1.2",
|
||||
"@vates/task": "^0.2.0",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"acme-client": "^5.0.0",
|
||||
"app-conf": "^2.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.26.25",
|
||||
"version": "0.26.29",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -32,11 +32,11 @@
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.38.0",
|
||||
"@xen-orchestra/fs": "^4.0.0",
|
||||
"@xen-orchestra/backups": "^0.39.0",
|
||||
"@xen-orchestra/fs": "^4.0.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.10.1",
|
||||
"@xen-orchestra/mixins": "^0.10.2",
|
||||
"@xen-orchestra/self-signed": "^0.1.3",
|
||||
"@xen-orchestra/xapi": "^2.2.1",
|
||||
"ajv": "^8.0.3",
|
||||
@@ -60,7 +60,7 @@
|
||||
"source-map-support": "^0.5.16",
|
||||
"stoppable": "^1.0.6",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"xen-api": "^1.3.1",
|
||||
"xen-api": "^1.3.3",
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Client } from 'node-vsphere-soap'
|
||||
import { Client } from '@vates/node-vsphere-soap'
|
||||
import { dirname } from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { strictEqual, notStrictEqual } from 'node:assert'
|
||||
import fetch from 'node-fetch'
|
||||
import https from 'https'
|
||||
|
||||
import parseVmdk from './parsers/vmdk.mjs'
|
||||
import parseVmsd from './parsers/vmsd.mjs'
|
||||
@@ -13,6 +14,7 @@ export default class Esxi extends EventEmitter {
|
||||
#cookies
|
||||
#dcPath
|
||||
#host
|
||||
#httpsAgent
|
||||
#user
|
||||
#password
|
||||
#ready = false
|
||||
@@ -22,9 +24,12 @@ export default class Esxi extends EventEmitter {
|
||||
this.#host = host
|
||||
this.#user = user
|
||||
this.#password = password
|
||||
// @FIXME this module inject NODE_TLS_REJECT_UNAUTHORIZED into the process env, which is problematic because it disables globally SSL certificate verification
|
||||
//
|
||||
// we need to find a fix for this, maybe forking the library
|
||||
if (!sslVerify) {
|
||||
this.#httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
})
|
||||
}
|
||||
|
||||
this.#client = new Client(host, user, password, sslVerify)
|
||||
this.#client.once('ready', async () => {
|
||||
try {
|
||||
@@ -78,6 +83,7 @@ export default class Esxi extends EventEmitter {
|
||||
headers.Range = 'bytes=' + range
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
agent: this.#httpsAgent,
|
||||
method: 'GET',
|
||||
headers,
|
||||
highWaterMark: 10 * 1024 * 1024,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"name": "@xen-orchestra/vmware-explorer",
|
||||
"dependencies": {
|
||||
"@vates/task": "^0.1.2",
|
||||
"@vates/task": "^0.2.0",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^3.3.0",
|
||||
"node-vsphere-soap": "^0.0.2-5",
|
||||
"@vates/node-vsphere-soap": "^1.0.0",
|
||||
"vhd-lib": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"xen-api": "^1.3.1"
|
||||
"xen-api": "^1.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/nbd-client": "^1.2.0",
|
||||
"@vates/nbd-client": "^1.2.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"d3-time-format": "^3.0.0",
|
||||
|
||||
105
CHANGELOG.md
105
CHANGELOG.md
@@ -1,20 +1,109 @@
|
||||
# ChangeLog
|
||||
|
||||
## **next**
|
||||
## **5.84.0** (2023-06-30)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [XO Tasks] Abortion can now be requested, note that not all tasks will respond to it
|
||||
- [Home/Pool] `No XCP-ng Pro support enabled on this pool` alert is considered a warning instead of an error (PR [#6849](https://github.com/vatesfr/xen-orchestra/pull/6849))
|
||||
- [Plugin/auth-iodc] OpenID Connect scopes are now configurable and `profile` is included by default
|
||||
- [Dashboard/Health] Button to copy UUID of an orphan VDI to the clipboard (PR [#6893](https://github.com/vatesfr/xen-orchestra/pull/6893))
|
||||
- [Kubernetes recipe] Add the possibility to choose the version for the cluster [#6842](https://github.com/vatesfr/xen-orchestra/issues/6842) (PR [#6880](https://github.com/vatesfr/xen-orchestra/pull/6880))
|
||||
- [New VM] cloud-init drives are now bootable in a Windows VM (PR [#6889](https://github.com/vatesfr/xen-orchestra/pull/6889))
|
||||
- [Backups] Add setting `backups.metadata.defaultSettings.diskPerVmConcurrency` in xo-server's configuration file to limit the number of disks transferred in parallel per VM, this is useful to avoid transfer overloading remote and Sr (PR [#6787](https://github.com/vatesfr/xen-orchestra/pull/6787))
|
||||
- [Settings/Config] Add the possibility to backup/import/download XO config from/to the XO cloud (PR [#6917](https://github.com/vatesfr/xen-orchestra/pull/6917))
|
||||
- [Import/Disk] Enhance clarity for importing ISO files [Forum#61480](https://xcp-ng.org/forum/post/61480) (PR [#6874](https://github.com/vatesfr/xen-orchestra/pull/6874))
|
||||
- [Import/Disk] Ability to import ISO from a URL (PR [#6924](https://github.com/vatesfr/xen-orchestra/pull/6924))
|
||||
- [Import/export VDI] Ability to export/import disks in RAW format (PR [#6925](https://github.com/vatesfr/xen-orchestra/pull/6925))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Home/Host] Fix "isHostTimeConsistentWithXoaTime.then is not a function" (PR [#6896](https://github.com/vatesfr/xen-orchestra/pull/6896))
|
||||
- [ESXi Import] was depending on an older unmaintened library that was downgrading the global security level of XO (PR [#6859](https://github.com/vatesfr/xen-orchestra/pull/6859))
|
||||
- [Backup] Fix memory consumption when deleting _VHD directory_ incremental backups
|
||||
- [Remote] Fix `remote is disabled` error when editing a disabled remote
|
||||
- [Settings/Servers] Fix connectiong using an explicit IPv6 address
|
||||
- [Backups/Health check] Use the right SR for health check during replication job (PR [#6902](https://github.com/vatesfr/xen-orchestra/pull/6902))
|
||||
- [RRD stats] Improve RRD stats performance (PR [#6903](https://github.com/vatesfr/xen-orchestra/pull/6903))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/fs 4.0.1
|
||||
- xen-api 1.3.3
|
||||
- @vates/nbd-client 1.2.1
|
||||
- @vates/node-vsphere-soap 1.0.0
|
||||
- @vates/task 0.2.0
|
||||
- @xen-orchestra/backups 0.39.0
|
||||
- @xen-orchestra/backups-cli 1.0.9
|
||||
- @xen-orchestra/mixins 0.10.2
|
||||
- @xen-orchestra/proxy 0.26.29
|
||||
- @xen-orchestra/vmware-explorer 0.2.3
|
||||
- xo-cli 0.20.0
|
||||
- xo-server-auth-oidc 0.3.0
|
||||
- xo-server-perf-alert 0.3.6
|
||||
- xo-server 5.118.0
|
||||
- xo-web 5.121.0
|
||||
|
||||
## **5.83.3** (2023-06-23)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Settings/Servers] Fix connecting using an explicit IPv6 address
|
||||
- [Full Replication] Fix garbage collecting previous replications
|
||||
|
||||
### Released packages
|
||||
|
||||
- xen-api 1.3.2
|
||||
- @xen-orchestra/backups 0.38.3
|
||||
- @xen-orchestra/proxy 0.26.28
|
||||
- xo-server 5.116.4
|
||||
|
||||
## **5.83.2** (2023-06-01)
|
||||
|
||||
## Bug fixes
|
||||
|
||||
- [Backup] Fix `Cannot read properties of undefined (reading 'vm')` (PR [#6873](https://github.com/vatesfr/xen-orchestra/pull/6873))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/backups 0.38.2
|
||||
- @xen-orchestra/proxy 0.26.27
|
||||
- xo-server 5.116.3
|
||||
|
||||
## **5.83.1** (2023-06-01)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Delta Replication] Fix not deleting older replications [Forum#62783](https://xcp-ng.org/forum/post/62783) (PR [#6871](https://github.com/vatesfr/xen-orchestra/pull/6871))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @xen-orchestra/backups 0.38.1
|
||||
- @xen-orchestra/proxy 0.26.26
|
||||
- xo-server 5.116.2
|
||||
|
||||
## **5.83.0** (2023-05-31)
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Backup] Implementation of mirror backup (Entreprise plan) (PRs [#6858](https://github.com/vatesfr/xen-orchestra/pull/6858), [#6854](https://github.com/vatesfr/xen-orchestra/pull/6854))
|
||||
- [Self service] Add default tags to all VMs that will be created by a Self Service (PRs [#6810](https://github.com/vatesfr/xen-orchestra/pull/6810), [#6812](https://github.com/vatesfr/xen-orchestra/pull/6812))
|
||||
- [Self Service] Ability to set a default value for the "Share VM" feature for Self Service users during creation/edition (PR [#6838](https://github.com/vatesfr/xen-orchestra/pull/6838))
|
||||
- [REST API] Add endpoints to display missing patches for pools and hosts (PR [#6855](https://github.com/vatesfr/xen-orchestra/pull/6855))
|
||||
- [REST API] _Rolling Pool Update_ action available `pools/<uuid>/actions/rolling_update`
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Proxy] Make proxy address editable (PR [#6816](https://github.com/vatesfr/xen-orchestra/pull/6816))
|
||||
- [Home/Host] Displays a warning for hosts with HVM disabled [#6823](https://github.com/vatesfr/xen-orchestra/issues/6823) (PR [#6834](https://github.com/vatesfr/xen-orchestra/pull/6834))
|
||||
- [OVA import] Workaround for OVA generated by Oracle VM with faulty size in metadata [#6824](https://github.com/vatesfr/xen-orchestra/issues/6824)
|
||||
- [REST API] _Rolling Pool Update_ action available `pools/<uuid>/actions/rolling_update`
|
||||
- [Self Service] Ability to set a default value for the "Share VM" feature for Self Service users during creation/edition (PR [#6838](https://github.com/vatesfr/xen-orchestra/pull/6838))
|
||||
- [Self service] Add default tags to all VMs that will be created by a Self Service (PRs [#6810](https://github.com/vatesfr/xen-orchestra/pull/6810), [#6812](https://github.com/vatesfr/xen-orchestra/pull/6812))
|
||||
- [Kubernetes] Add the possibility to choose the number of fault tolerance for the control planes (PR [#6809](https://github.com/vatesfr/xen-orchestra/pull/6809))
|
||||
- [REST API] Add endpoints to display missing patches for pools and hosts (PR [#6855](https://github.com/vatesfr/xen-orchestra/pull/6855))
|
||||
- [Tasks] New type of tasks created by XO ("XO Tasks" section) (PRs [#6861](https://github.com/vatesfr/xen-orchestra/pull/6861) [#6869](https://github.com/vatesfr/xen-orchestra/pull/6869))
|
||||
- [Backup/Health check] Add basic XO task for manual health check
|
||||
- [Backup] Implementation of mirror backup (Entreprise plan) (PRs [#6858](https://github.com/vatesfr/xen-orchestra/pull/6858), [#6854](https://github.com/vatesfr/xen-orchestra/pull/6854))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -42,8 +131,6 @@
|
||||
|
||||
## **5.82.2** (2023-05-17)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [New/VM] Fix stuck Cloud Config import ([GitHub comment](https://github.com/vatesfr/xen-orchestra/issues/5896#issuecomment-1465253774))
|
||||
@@ -127,8 +214,6 @@
|
||||
|
||||
## **5.81** (2023-03-31)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [VM] Show distro icon for opensuse-microos [Forum#6965](https://xcp-ng.org/forum/topic/6965) (PR [#6746](https://github.com/vatesfr/xen-orchestra/pull/6746))
|
||||
|
||||
@@ -373,6 +373,10 @@ In Netbox 2.x, custom fields can be created from the Admin panel > Custom fields
|
||||
- Load the plugin (button next to the plugin's name)
|
||||
- Manual synchronization: if you correctly configured and loaded the plugin, a "Synchronize with Netbox" button will appear in every pool's Advanced tab, which allows you to manually synchronize it with Netbox
|
||||
|
||||
:::tip
|
||||
If you get a `403 Forbidden` error when testing the plugin, make sure you correctly configured the "Allowed IPs" for the token you are using.
|
||||
:::
|
||||
|
||||
## Recipes
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -8,11 +8,12 @@ Alternatively, here is a video recap on different backup capabilities:
|
||||
|
||||
- [Rolling Snapshots](rolling_snapshots.md)
|
||||
- [Full Backups](full_backups.md)
|
||||
- [Delta Backups](delta_backups.md)
|
||||
- [Disaster Recovery](disaster_recovery.md)
|
||||
- [Incremental Backups](incremental_backups.md)
|
||||
- [Full Replication](full_replication.md)
|
||||
- [Metadata Backups](metadata_backup.md)
|
||||
- [Continuous Replication](continuous_replication.md)
|
||||
- [Incremental Replication](incremental_replication.md)
|
||||
- [File Level Restore](file_level_restore.md)
|
||||
- [Mirror backup](mirror_backup.md)
|
||||
|
||||
:::tip
|
||||
Don't forget to take a look at the [backup troubleshooting](backup_troubleshooting.md) section. You can also take a look at the [backup reports](backup_reports.md) section for configuring notifications.
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Continuous Replication
|
||||
|
||||
This feature is a continuous replication system for your XenServer VMs **without any storage vendor lock-in**. You can replicate a VM every _X_ minutes/hours to any storage repository. It could be to a distant XenServer host or just another local storage target.
|
||||
|
||||
This feature covers multiple objectives:
|
||||
|
||||
- no storage vendor lock-in
|
||||
- no configuration (agent-less)
|
||||
- low Recovery Point Objective, from 10 minutes to 24 hours (or more)
|
||||
- flexibility
|
||||
- no intermediate storage needed
|
||||
- atomic replication
|
||||
- efficient DR (disaster recovery) process
|
||||
|
||||
If you lose your main pool, you can start the copy on the other side, with very recent data.
|
||||
|
||||

|
||||
|
||||
:::warning
|
||||
It is normal that you can't boot the copied VM directly: we protect it. The normal workflow is to make a clone and then work on it.
|
||||
|
||||
This also affects VMs with "Auto Power On" enabled, because of our protections you can ensure these won't start on your CR destination if you happen to reboot it.
|
||||
:::
|
||||
|
||||
## Configure it
|
||||
|
||||
As you'll see, it is trivial to configure. Inside the "Backup/new" section, select "Continuous Replication".
|
||||
|
||||
Then:
|
||||
|
||||
1. Select the VMs you want to protect
|
||||
1. Schedule the replication interval
|
||||
1. Select the destination storage (could be any storage connected to any XenServer host!)
|
||||
|
||||
That's it! Your VMs are protected and replicated as requested.
|
||||
|
||||
To protect the replication, we removed the possibility to boot your copied VM directly, because if you do that, it will break the next delta. The solution is to clone it if you need it (a clone is really quick). You can then do whatever you want with this clone!
|
||||
|
||||
## Manual initial seed
|
||||
|
||||
**If you can't transfer the first backup through your network because it's too large**, you can make a seed locally. In order to do this, follow this procedure (until we make it accessible directly in XO).
|
||||
|
||||
:::tip
|
||||
This is **only** if you need to make the initial copy without making the whole transfer through your network. Otherwise, **you don't need this**. These instructions are for Backup-NG jobs, and will not work to seed a legacy backup job. Please migrate any legacy jobs to Backup-NG!
|
||||
:::
|
||||
|
||||
### Job creation
|
||||
|
||||
Create the Continuous Replication backup job, and leave it disabled for now. On the main Backup-NG page, copy the job's `backupJobId` by hovering to the left of the shortened ID and clicking the copy to clipboard button:
|
||||
|
||||

|
||||
|
||||
Copy it somewhere temporarily. Now we need to also copy the ID of the job schedule, `backupScheduleId`. Do this by hovering over the schedule name in the same panel as before, and clicking the copy to clipboard button. Keep it with the `backupJobId` you copied previously as we will need them all later:
|
||||
|
||||

|
||||
|
||||
### Seed creation
|
||||
|
||||
Manually create a snapshot on the VM being backed up, then copy this snapshot UUID, `snapshotUuid` from the snapshot panel of the VM:
|
||||
|
||||

|
||||
|
||||
:::warning
|
||||
DO NOT ever delete or alter this snapshot, feel free to rename it to make that clear.
|
||||
:::
|
||||
|
||||
### Seed copy
|
||||
|
||||
Export this snapshot to a file, then import it on the target SR.
|
||||
|
||||
We need to copy the UUID of this newly created VM as well, `targetVmUuid`:
|
||||
|
||||

|
||||
|
||||
:::warning
|
||||
DO not start this VM or it will break the Continuous Replication job! You can rename this VM to more easily remember this.
|
||||
:::
|
||||
|
||||
### Set up metadata
|
||||
|
||||
The XOA backup system requires metadata to correctly associate the source snapshot and the target VM to the backup job. We're going to use the `xo-cr-seed` utility to help us set them up.
|
||||
|
||||
First install the tool (all the following is done from the XOA VM CLI):
|
||||
|
||||
```sh
|
||||
sudo npm i -g --unsafe-perm @xen-orchestra/cr-seed-cli
|
||||
```
|
||||
|
||||
Here is an example of how the utility expects the UUIDs and info passed to it:
|
||||
|
||||
```console
|
||||
$ xo-cr-seed
|
||||
Usage: xo-cr-seed <source XAPI URL> <source snapshot UUID> <target XAPI URL> <target VM UUID> <backup job id> <backup schedule id>
|
||||
|
||||
xo-cr-seed v0.2.0
|
||||
```
|
||||
|
||||
Putting it altogether and putting our values and UUID's into the command, it will look like this (it is a long command):
|
||||
|
||||
```console
|
||||
$ xo-cr-seed https://root:password@xen1.company.tld 4a21c1cd-e8bd-4466-910a-f7524ecc07b1 https://root:password@xen2.company.tld 5aaf86ca-ae06-4a4e-b6e1-d04f0609e64d 90d11a94-a88f-4a84-b7c1-ed207d3de2f9 369a26f0-da77-41ab-a998-fa6b02c69b9a
|
||||
```
|
||||
|
||||
:::warning
|
||||
If the username or the password for your XCP-ng/XenServer hosts contains special characters, they must use [percent encoding](https://en.wikipedia.org/wiki/Percent-encoding).
|
||||
|
||||
An easy way to do this with Node in command line:
|
||||
|
||||
```console
|
||||
$ node -p 'encodeURIComponent(process.argv[1])' -- 'password with special chars :#@'
|
||||
password%20with%20special%20chars%20%3A%23%40
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### Finished
|
||||
|
||||
Your backup job should now be working correctly! Manually run the job the first time to check if everything is OK. Then, enable the job. **Now, only the deltas are sent, your initial seed saved you a LOT of time if you have a slow network.**
|
||||
|
||||
### Failover process
|
||||
|
||||
In the situation where you need to failover to your destination host, you simply need to start all your VMs on the destination host.
|
||||
|
||||
:::tip
|
||||
If you want to start a VM on your destination host without breaking the CR jobs on the other side, you will need to make a copy of the VM and start the copy. Otherwise, you will be asked if you would like to force start the VMs.
|
||||
:::
|
||||
|
||||

|
||||
1
docs/continuous_replication.md
Symbolic link
1
docs/continuous_replication.md
Symbolic link
@@ -0,0 +1 @@
|
||||
incremental_replication.md
|
||||
@@ -1,66 +0,0 @@
|
||||
# Continuous Delta backups
|
||||
|
||||
You can export only the delta (difference) between your current VM disks and a previous snapshot (called here the _reference_). They are called _continuous_ because you'll **never export a full backup** after the first one.
|
||||
|
||||
## Introduction
|
||||
|
||||
Full backups can be represented like this:
|
||||
|
||||

|
||||
|
||||
It means huge files for each backup. Delta backups will only export the difference between the previous backup:
|
||||
|
||||

|
||||
|
||||
You can imagine making your first initial full backup during a weekend, and then only delta backups every night. It combines the flexibility of snapshots and the power of full backups, because:
|
||||
|
||||
- delta are stored somewhere else than the current VM storage
|
||||
- they are small
|
||||
- quick to create
|
||||
- easy to restore
|
||||
|
||||
So, if you want to rollback your VM to a previous state, the cost is only one snapshot on your SR (far less than the [rolling snapshot](rolling_snapshot.md) mechanism).
|
||||
|
||||
Even if you lost your whole SR or VM, XOA will restore your VM entirely and automatically, at any date of backup.
|
||||
|
||||
You can even imagine using this to backup more often! Because deltas will be smaller, and will **always be deltas**.
|
||||
|
||||
### Continuous
|
||||
|
||||
They are called continuous because you'll **never export a full backup** after the first one. We'll merge the oldest delta into the full:
|
||||
|
||||

|
||||
|
||||
This way we can go "forward" and remove this oldest VHD after the merge:
|
||||
|
||||

|
||||
|
||||
## Create Delta backup
|
||||
|
||||
Just go into your "Backup" view, and select Delta Backup. Then, it's the same as a normal backup.
|
||||
|
||||
## Snapshots
|
||||
|
||||
Unlike other types of backup jobs which delete the associated snapshot when the job is done and it has been exported, delta backups always keep a snapshot of every VM in the backup job, and uses it for the delta. Do not delete these snapshots!
|
||||
|
||||
## Delta backup initial seed
|
||||
|
||||
If you don't want to do an initial full directly toward the destination, you can create a local delta backup first, then transfer the files to your destination.
|
||||
|
||||
Then, only the diff will be sent.
|
||||
|
||||
1. create a delta backup job to the first remote
|
||||
1. run the backup (full)
|
||||
1. edit the job to target the other remote
|
||||
1. copy files from the first remote to the other one
|
||||
1. run the backup (delta)
|
||||
|
||||
## Full backup interval
|
||||
|
||||
This advanced setting defines the number of backups after which a full backup is triggered, ie the maximum length of a delta chain.
|
||||
|
||||
For example, with a value of 2, the first two backups will be a full and a delta, and the third will start a new chain with a full backup.
|
||||
|
||||
This is important because on rare occasions a backup can be corrupted, and in the case of delta backups, this corruption might impact all the following backups in the chain. Occasionally performing a full backup limits how far a corrupted delta backup can propagate.
|
||||
|
||||
The value to use depends on your storage constraints and the frequency of your backups, but a value of 20 is a good start.
|
||||
1
docs/delta_backups.md
Symbolic link
1
docs/delta_backups.md
Symbolic link
@@ -0,0 +1 @@
|
||||
incremental_backups.md
|
||||
@@ -1,41 +0,0 @@
|
||||
# Disaster recovery
|
||||
|
||||
Disaster Recovery (DR) encompasses all the ways to recover after losing hosts or storage repositories.
|
||||
|
||||
In this guide we'll only see the technical aspect of DR, which is a small part of this vast topic.
|
||||
|
||||
## Best practices
|
||||
|
||||
We strongly encourage you to read some literature on this topic. Basically, you should be able to recover from a major disaster within an appropriate amount of time and minimal acceptable data loss.
|
||||
|
||||
To avoid a potentially very long import process (restoring all your backup VMs), we implemented a streaming feature. [Streaming allows exporting and importing at the same time](https://xen-orchestra.com/blog/vm-streaming-export-in-xenserver/).
|
||||
|
||||
**The goal is to have your DR VMs ready to boot on a dedicated host. This also provides a way to check if you export was successful (if the VM boots).**
|
||||
|
||||

|
||||
|
||||
## Schedule a DR task
|
||||
|
||||
Planning a DR task is very similar to planning a backup or a snapshot. The only difference is that you select a storage destination.
|
||||
|
||||
You DR VMs will be visible "on the other side" as soon the task is done.
|
||||
|
||||
### Retention
|
||||
|
||||
Retention, or **depth**, applies to the VM name. **If you change the VM name for any reason, it won't be rotated anymore.** This way, you can play with your DR VM without the fear of losing it.
|
||||
|
||||
Also, by default, the DR VM will have a "Disaster Recovery" tag.
|
||||
|
||||
:::warning
|
||||
A higher retention number will lead to huge space occupation on your SR.
|
||||
:::
|
||||
|
||||
## Network conflicts
|
||||
|
||||
If you boot a copy of your production VM, be careful: if they share the same static IP, you'll have troubles.
|
||||
|
||||
A good way to avoid this kind of problem is to remove the network interface on the DR VM and check if the export is correctly done.
|
||||
|
||||
:::warning
|
||||
For each DR replicated VM, we add "start" as a blocked operation, meaning even VMs with "Auto power on" enabled will not be started on your DR destination if it reboots.
|
||||
:::
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user