Compare commits

..

114 Commits

Author SHA1 Message Date
Thierry
677a9c958c feat(lite/component): add support for nested modals 2023-07-04 08:59:16 +02:00
Pierre Donias
2978ad1486 feat(lite): 0.1.1 (#6930) 2023-07-03 15:58:17 +02:00
Julien Fontanet
c0d6dc48de feat(xo-web/XO tasks): better display of start date and duration 2023-07-01 10:30:44 +02:00
Julien Fontanet
f327422254 feat: release 5.84.0 2023-06-30 20:09:44 +02:00
Julien Fontanet
938d15d31b feat(xo-web): 5.121.0 2023-06-30 19:22:38 +02:00
Julien Fontanet
5ab1ddb9cb feat(xo-server): 5.118.0 2023-06-30 19:20:29 +02:00
Mathieu
01302d7a60 feat(xo-web/settings/config): cloud backup (#6917) 2023-06-30 19:09:56 +02:00
Julien Fontanet
c68630e2d6 feat(xo-server/rest-api): provide a way to extend it 2023-06-30 18:19:09 +02:00
Julien Fontanet
db082bfbe9 fix(xo-server/rest-api): handle ids that are numbers instead of strings 2023-06-30 18:19:09 +02:00
Julien Fontanet
650d88db46 feat(xo-server/configurePlugin): can update instead of replace existing config 2023-06-30 18:19:09 +02:00
Julien Fontanet
7d1ecca669 feat(xo-server): consider *passphrase* a sensitive value 2023-06-30 18:19:09 +02:00
Thierry Goettelmann
5f71e629ae fix(lite/components): app-menu doesn't allow more than 1 submenu (#6897) 2023-06-30 15:47:56 +02:00
rbarhtaoui
68205d4676 feat(xo-web/export,import VDI): explicit import/export raw VDI (#6925)
See zammad#15254
2023-06-30 15:10:30 +02:00
Mathieu
cdb466225d feat(xo-web,xo-server): import ISO VDI from url (#6924)
Related to zammad#15254
2023-06-30 13:47:43 +02:00
Julien Fontanet
0e7fbd598f feat(docs/rest-api): alpha → beta 2023-06-30 12:00:14 +02:00
Mathieu
99147c893d feat(xo-web): add tooltip on BulkIcons (#6895) 2023-06-29 10:56:26 +02:00
Mathieu
c63fb6173d feat(xo-web/import/disk): UI improvement for ISO files (#6874)
See https://xcp-ng.org/forum/topic/7243
2023-06-29 10:51:16 +02:00
Pierre Donias
5932ada717 chore(node-vsphere-soap): make pkg public (#6923)
Make package public and run normalize-packages on it to add the `postversion`
script to its `package.json`.
2023-06-29 10:45:06 +02:00
Mathieu
0d579748d6 fix(lite): replace 'change-power-state' by 'change-state' (#6922) 2023-06-29 10:13:02 +02:00
Pierre Donias
8c5ee4eafe feat: technical release (#6921)
* feat(@xen-orchestra/fs): 4.0.1

* feat(xen-api): 1.3.3

* feat(@vates/nbd-client): 1.2.1

* feat(@vates/node-vsphere-soap): 1.0.0

* feat(@vates/task): 0.2.0

* feat(@xen-orchestra/backups): 0.39.0

* feat(@xen-orchestra/backups-cli): 1.0.9

* feat(@xen-orchestra/mixins): 0.10.2

* feat(@xen-orchestra/proxy): 0.26.29

* feat(@xen-orchestra/vmware-explorer): 0.2.3

* feat(xo-cli): 0.20.0

* feat(xo-server): 5.117.0

* feat(xo-server-auth-oidc): 0.3.0

* feat(xo-server-perf-alert): 0.3.6

* feat(xo-web): 5.120.0

* chore(CHANGELOG): update next
2023-06-28 17:10:22 +02:00
Florent BEAUCHAMP
b03935ad2f feat(backups): can limit parallel VDI transfers per VM per job (#6787) 2023-06-28 16:47:39 +02:00
Mathieu
38439cbc43 fix(xo-web): enhance RRD stats (#6903)
- fix infinite requests
- avoid duplicate requests
2023-06-28 15:17:00 +02:00
Florent BEAUCHAMP
161c20b534 feat(xo-server): add MBR to cloud-init drive (#6889) 2023-06-28 10:42:01 +02:00
Julien Fontanet
603696dad1 fix(xo-server/rest-api): reply with 204 when non content 2023-06-27 14:43:27 +02:00
Julien Fontanet
6b2ad5a7cc feat(xo-cli rest get): new --output parameter
It can be used to save the response in a file instead of parsing it.
2023-06-27 14:43:27 +02:00
Julien Fontanet
88063d4d87 fix(xo-cli rest): params now support the json: prefix
So that any values can be passed.
2023-06-26 16:21:01 +02:00
Julien Fontanet
8956a99745 feat(xo-cli rest): support patch method 2023-06-26 16:09:32 +02:00
Florent BEAUCHAMP
0f0c0ec0d0 fix(vmware-explorer): handle selef signed certifictae during download (#6908) 2023-06-26 14:24:37 +02:00
Florent BEAUCHAMP
e5932e2c33 fix(node-vsphere-soap): don't disable TLS1.2 used by ESXi (#6913) 2023-06-26 11:31:24 +02:00
Julien Fontanet
84ec8f5f3c fix(mixins/HttpProxy): fix premature close warning 2023-06-26 10:47:34 +02:00
Julien Fontanet
661c5a269f fix(mixins/HttpProxy): fix excess event listeners warning 2023-06-26 10:47:31 +02:00
Julien Fontanet
5c6d7cae66 feat(mixins/HttpProxy): debug when proxy is enabled/disabled 2023-06-26 10:41:57 +02:00
Julien Fontanet
fcc73859b7 test(node-vsphere-soap): use test
Instead of old `lab` which has a lot of vulnerable dependencies.
2023-06-23 17:42:28 +02:00
Julien Fontanet
36645b0319 test(node-vsphere-soap): use native assert
Instead of old `code` which has a lot of vulnerable dependencies.
2023-06-23 17:35:22 +02:00
Florent BEAUCHAMP
a62575e3cf docs(backups): new terminology and mirror backups (#6837)
Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>
Co-authored-by: Jon Sands <fohdeesha@gmail.com>
2023-06-23 16:33:10 +02:00
Julien Fontanet
d7af3d3c03 fix(CHANGELOG): 5.83.4 → 5.83.3 2023-06-23 14:21:45 +02:00
Julien Fontanet
130ebb7d5f Merge remote-tracking branch 'origin/5.83' 2023-06-23 14:15:34 +02:00
Julien Fontanet
2af845ebd3 feat: release 5.83.3 2023-06-23 11:11:33 +02:00
Julien Fontanet
8e4d1701e6 feat(xo-server): 5.116.4 2023-06-23 11:09:21 +02:00
Julien Fontanet
4d16b6708f feat(@xen-orchestra/proxy): 0.26.28 2023-06-23 11:09:21 +02:00
Julien Fontanet
34ee08be25 feat(@xen-orchestra/backups): 0.38.3 2023-06-23 11:09:20 +02:00
Julien Fontanet
d66a76a09e feat(xen-api): 1.3.2 2023-06-23 11:09:02 +02:00
Florent BEAUCHAMP
0d801c9766 fix(backups): fix DR not deleting older VM (#6912)
Introduced by aa36629def
2023-06-23 10:59:51 +02:00
Julien Fontanet
b82b676fdb fix(xen-api/transports/json-rpc): fix IPv6 address support
Introduced by ab96c549a
2023-06-23 10:59:27 +02:00
Gabriel Gunullu
3494c0f64f fix(xo-server-perf-alert): add conditional statement on entry (#6900)
* fix(xo-server-perf-alert): add conditional statement on entry

Test if the entry is null to handle the case where the object cannot be found,
which can happen when the user forgets to remove an element that doesn't exist anymore from
the list of the monitored machines.

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.fr>

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.fr>
2023-06-23 09:04:32 +02:00
Florent BEAUCHAMP
311098adc2 feat(backups): use the right SR for health check during replication (#6902) 2023-06-22 11:35:47 +02:00
Julien Fontanet
58182e2083 fix(xen-api/transports/json-rpc): fix IPv6 address support
Introduced by ab96c549a
2023-06-22 11:08:50 +02:00
Julien Fontanet
a62ae43274 feat(xen-api/cli): allow specifying transport 2023-06-22 11:02:15 +02:00
Julien Fontanet
f256610e08 fix(xo-web): don't test a disabled remote after editing
Fixes https://team.vates.fr/vates/pl/xxezjup7efr7idcur9qtftcgfe
2023-06-22 08:43:04 +02:00
Gabriel Gunullu
983d048219 feat(xo-web/kubernetes): add version selection (#6880)
Fixes #6842
See xoa#122
2023-06-21 14:10:47 +02:00
Julien Fontanet
3c6033f904 fix(xo-server): close connections of deleted users
Fixes #5235
2023-06-21 12:03:06 +02:00
Julien Fontanet
ef2bd2b59d fix(xo-server): better token check on HTTP request
It now checks that the user associated with the authentication token really exists.

This fixes xo-web infinite refresh when the token stored in cookies belongs to a missing user.
2023-06-21 12:03:06 +02:00
Julien Fontanet
04d70e9aa8 chore: update dev deps 2023-06-20 18:09:09 +02:00
Julien Fontanet
a2587ffc0a fix(CHANGELOG.unreleased): missing release type for vmware-explorer
Introduced by 4c0506429
2023-06-19 09:40:33 +02:00
Julien Fontanet
6776e7bb3d fix(CHANGELOG.unreleased): missing release type for vmware-explorer
Introduced by 4c0506429
2023-06-19 09:39:53 +02:00
Florent BEAUCHAMP
4c05064294 feat(vmware-exporer): use @vates/node-vsphere-soap 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP
c135f1394f fix(node-vsphere-soap): disable tests since they need a running vsphere/esxi 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP
d68f4215f1 fix(node-vsphere-soap): better handling of self signed cert 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP
af562f3c3a chore(node-vsphere-soap): fix lint issues 2023-06-19 09:31:07 +02:00
Julien Fontanet
7b949716bc chore(node-vsphere-soap): format with Prettier 2023-06-19 09:31:07 +02:00
Florent BEAUCHAMP
d3e256289b feat(node-vsphere-soap): fork 2023-06-19 09:31:07 +02:00
Gabriel Gunullu
3688e762b1 fix(xo-web/kubernetes): change recipe description (#6878)
Introduced by eb84d4a7ef
2023-06-16 11:37:35 +02:00
Julien Fontanet
249f1a7af4 feat(backups/XO metadata): store data filename in metadata 2023-06-16 10:40:04 +02:00
Thierry Goettelmann
2de26030ff chore(lite): add type branding to XAPI record's $ref & uuid (#6884)
Type branding enhances our type safety by preventing the incorrect usage of
`XenApiRecord`'s `$ref` and `uuid`. It ensures that these types are not
interchangeable.
2023-06-15 14:01:27 +02:00
Mathieu
fcc76fb8d0 fix(xo-web/home): fix 'isHostTimeConsistentWithXoaTime.then is not a function' (#6896)
See xoa-support#15250
Introduced by 132b1a41db
2023-06-15 10:07:56 +02:00
Julien Fontanet
88d5b7095e feat(xo-web/dashboard/health): copiable orphan VDI UUIDs (#6893)
Fixes internal request by @Fohdeesha https://team.vates.fr/vates/pl/p1nsuy8gzpgxtxwrqhdzocpiaw
2023-06-15 09:45:19 +02:00
Julien Fontanet
b0e55d88de feat(xo-web): clearer display to choose new backup job type (#6894)
Fixes https://team.vates.fr/vates/pl/xsj49jtmdfgp5god81ninumr6o

- explicit replication
- separate VM and metadata backup types
- homogenize button labels
2023-06-14 10:50:59 +02:00
Mathieu
370ad3e928 feat(lite): implement "closing-confirmation" store (#6883) 2023-06-14 10:45:44 +02:00
rbarhtaoui
07bf77d2dd feat(lite/pool/VMs): ability to delete selected VMs (#6860) 2023-06-14 10:32:15 +02:00
Thierry Goettelmann
a5ec65f3c0 fix(lite): eslint error "duplicate key" (#6891) 2023-06-13 13:58:35 +02:00
Thierry Goettelmann
522b318fd9 feat(lite/dev): add keyboard shortcut to toggle language (#6888)
To make development easier, add the ability to toggle language between FR and EN
while in development mode by pressing the `L` key (the same way we can toggle
light/dark theme with `D` key)
2023-06-13 10:45:26 +02:00
Julien Fontanet
9eb2a4033f feat(xo-server-auth-oidc): make scopes configurable and include profile by default
Fixes https://xcp-ng.org/forum/post/62185
2023-06-12 22:22:47 +02:00
Julien Fontanet
e87b0c393a chore: update dev deps 2023-06-12 22:00:52 +02:00
Mathieu
1fb7e665fa fix(xo-web/home/pool): switch alert support from 'danger' to 'warning' (#6849)
Harmonize with the host home view.
2023-06-12 11:49:47 +02:00
Thierry Goettelmann
7ea476d787 feat(lite): add alarm store (#6814) 2023-06-12 10:39:37 +02:00
Thierry Goettelmann
8260d07d61 fix(lite/i18n): "coming soon" (#6887) 2023-06-12 10:39:11 +02:00
rbarhtaoui
ac0b4e6514 fix(lite/login): fix transparent login button (#6879) 2023-06-12 10:37:47 +02:00
Pierre Donias
27b2f8cf27 docs(netbox): troubleshooting tip for 403 Forbidden (#6882) 2023-06-12 09:42:25 +02:00
Thierry Goettelmann
27b5737f65 feat(lite/pool/VMs): ability to copy selected VMs (#6847) 2023-06-09 14:59:39 +02:00
Julien Fontanet
55b2e0292f docs(task): describe combined task log 2023-06-09 09:45:46 +02:00
Julien Fontanet
464d83e70f feat(xo-web): implement XO task abortion 2023-06-09 09:45:46 +02:00
Julien Fontanet
614255a73a chore(xo-web): remove now unused aborted task status 2023-06-09 09:45:46 +02:00
Julien Fontanet
90d15e1346 feat(task): remove aborted status and add abortionRequested event
BREAKING CHANGE.
2023-06-09 09:45:46 +02:00
Julien Fontanet
b0e2ea64e9 feat(xo-server/test.createTask): dynamic name and progress 2023-06-08 14:38:22 +02:00
Julien Fontanet
1da05e239d feat(task): merge custom data into properties
BREAKING CHANGE.

This makes these entries mutable during the life of the task.
2023-06-08 14:38:22 +02:00
Thierry Goettelmann
fe7f0db81f feat(lite): revamp XAPI subscription and add immediate option (#6877)
`subscribe()` now accepts an `{ immediate: false }` option.
In this case, the subscription is deferred and can be initialized later with `.start()`.
A `createSubscribe` helper has been added to create an overridden `subscribe` function.
Full documentation has been added to `docs/xen-api-record-stores.md`.
2023-06-08 14:33:38 +02:00
rbarhtaoui
983153e620 feat(lite/pool/tasks): display an error msg if data cannot be fetched (#6777) 2023-06-08 09:21:39 +02:00
Thierry Goettelmann
6fe791dcf2 feat(lite/dashboard): revamp pool dashboard (#6815)
Reworked the pool dashboard to reorder components, simplify the code, and make
the design closer to the Figma mockups.
Added a new `PoolDashboardComingSoon` component for dashboard items that are not
ready yet.
Removed `height: fit-content` from UiCard which should not be needed anymore and
have only recent (~1.4 year) support on Firefox.
2023-06-07 14:41:08 +02:00
Florent BEAUCHAMP
1ad406c7dd test(nbd-client): test secure connection 2023-06-07 10:24:14 +02:00
Florent BEAUCHAMP
4e032e11b1 fix(nbd-client/readBlocks): BigInt handling for default generator 2023-06-07 10:24:14 +02:00
Julien Fontanet
ea34516d73 test(vhd-lib): from Jest to test 2023-06-07 10:24:14 +02:00
Thierry Goettelmann
e1145f35ee feat(lite): introduce POWER_STATE and VM_OPERATION enums (#6846) 2023-06-07 10:13:29 +02:00
Thierry Goettelmann
6864775b8a fix(lite/AppMenu): AppMenu is not displayed correctly (#6819)
The visibility of AppMenu was previously constrained to its container boundaries
2023-06-07 09:22:27 +02:00
rbarhtaoui
f28721b847 feat(lite/pool/VMs): ability to change the VMs power state (#6782) 2023-06-06 15:46:24 +02:00
Julien Fontanet
2dc174fd9d test(task/combineEvents): use variable to ease test maintenance 2023-06-06 10:29:47 +02:00
Julien Fontanet
07142d0410 test(task/combineEvents): test id, start and end properties 2023-06-05 15:29:12 +02:00
Julien Fontanet
41bb16ca30 feat: release 5.83.2 2023-06-01 15:36:48 +02:00
Julien Fontanet
d8f1034858 feat: technical release 2023-06-01 14:25:08 +02:00
Julien Fontanet
52b3c49cdb feat(xo-server): 5.116.3 2023-06-01 14:24:58 +02:00
Julien Fontanet
c5cb1a5e96 feat(@xen-orchestra/proxy): 0.26.27 2023-06-01 14:24:07 +02:00
Julien Fontanet
92d9d3232c feat(@xen-orchestra/backups): 0.38.2 2023-06-01 14:23:49 +02:00
Florent BEAUCHAMP
9c4e0464f0 fix(backups): fix vm is undefined error (#6873) 2023-06-01 14:21:43 +02:00
Julien Fontanet
72d25754fd feat: release 5.83.1 2023-06-01 12:00:06 +02:00
Julien Fontanet
1465a0ba59 feat(xo-server): 5.116.2 2023-06-01 11:30:47 +02:00
Julien Fontanet
ac8ce28286 fix(xo-server): don't require start for Redis collections (2)
Missing changed from fba86bf65
2023-06-01 11:08:52 +02:00
Julien Fontanet
c4b06e1915 feat(xo-server): 5.116.1 2023-06-01 10:48:20 +02:00
Julien Fontanet
f77675a8a3 feat(@xen-orchestra/proxy): 0.26.26 2023-06-01 10:46:31 +02:00
Julien Fontanet
b907c1fd03 feat(@xen-orchestra/backups): 0.38.1 2023-06-01 10:46:15 +02:00
Julien Fontanet
fba86bf653 fix(xo-server): don't require start for Redis collections (#6872)
Introduced by 9f3b02036

Redis connection is usable right after starting the core, therefore collections can be created
on the `core started` event and does not require for the (much heavier) `start` hook to run.

This change fixes `xo-server-recover-account`.
2023-06-01 10:36:02 +02:00
Florent BEAUCHAMP
b18ebcc38d fix(backups): fix CR not deleting older VM (#6871)
scheduleId was not passed to the writers constructor. It leads to missing scheduleId in metadata (I think there is no consequence), and a bad filter to detect VM to delete after a successfull replication

Users may need to delete manually the VM created that way
2023-06-01 10:33:33 +02:00
Mathieu
4f7f18458e fix(lite/console): fix console not updating when changing VM (#6850)
Introduced by 5237fdd387

`WatchEffect` is called before `Watch` so the connection was "created" then
"cleaned"
2023-05-31 16:26:19 +02:00
Julien Fontanet
d412196052 fix(CHANGELOG): badges
Introduced by 1d140d8fd
2023-05-31 16:06:28 +02:00
Julien Fontanet
1d140d8fd2 feat: release 5.83.0 2023-05-31 16:05:18 +02:00
Thierry Goettelmann
6948a25b09 fix(lite/markdown): vue code fence are no longer detected (#6845)
The `vue-template`, `vue-script`, and `vue-style` code fences were no longer
detected, and thus were no longer highlighted.
2023-05-31 15:25:59 +02:00
193 changed files with 6999 additions and 4257 deletions

View File

@@ -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 }
}
}

View File

@@ -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)
})

View File

@@ -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"
}
}

View 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-----

View 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)
})

View 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-----

View 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-----

View File

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

View 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.

View File

@@ -0,0 +1,127 @@
forked from https://github.com/reedog117/node-vsphere-soap
# node-vsphere-soap
[![Join the chat at https://gitter.im/reedog117/node-vsphere-soap](https://badges.gitter.im/Join%20Chat.svg)](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

View 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

View 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"
}
}

View 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

View 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)
})
})
})
})

View File

@@ -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.

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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')
})
})

View File

@@ -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 })

View File

@@ -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')
})
})

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.2",
"version": "0.2.0",
"engines": {
"node": ">=14"
},

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.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",

View File

@@ -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')))
}
}
}

View File

@@ -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,

View File

@@ -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,
})

View File

@@ -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,
})
)
})
}

View File

@@ -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,
})
)
}
})
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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))
}
}
)

View File

@@ -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

View File

@@ -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": {

View File

@@ -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"

View File

@@ -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",

View File

@@ -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

View File

@@ -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**

View File

@@ -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/).

View 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 });
```

View File

@@ -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",

View File

@@ -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(

View File

@@ -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>

View File

@@ -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)

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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,

View File

@@ -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();

View File

@@ -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();

View File

@@ -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>

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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>

View File

@@ -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);

View 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>

View 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>

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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());

View File

@@ -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]);

View File

@@ -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}`;
}

View File

@@ -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) =>

View File

@@ -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]])
)
);
},
};
}
}

View File

@@ -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",

View File

@@ -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",

View 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,
};
});

View 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,
};
}
);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
}
});
});

View File

@@ -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>

View File

@@ -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>

View 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>;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>({

View File

@@ -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),
},
};

View File

@@ -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];

View File

@@ -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>

View File

@@ -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)
}
}
}

View File

@@ -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
//

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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))

View File

@@ -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

View File

@@ -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.

View File

@@ -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.
![](https://xen-orchestra.com/blog/content/images/2016/01/replication.png)
:::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:
![](./assets/cr-seed-1.png)
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:
![](./assets/cr-seed-2.png)
### 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:
![](./assets/cr-seed-3.png)
:::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`:
![](./assets/cr-seed-4.png)
:::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.
:::
![](./assets/force-start.jpg)

View File

@@ -0,0 +1 @@
incremental_replication.md

View File

@@ -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:
![](./assets/nodelta.png)
It means huge files for each backup. Delta backups will only export the difference between the previous backup:
![](./assets/delta_final.png)
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:
![](./assets/deltamerge1.png)
This way we can go "forward" and remove this oldest VHD after the merge:
![](./assets/deltamerge2.png)
## 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
View File

@@ -0,0 +1 @@
incremental_backups.md

View File

@@ -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).**
![](https://xen-orchestra.com/blog/content/images/2015/10/newsolution.png)
## 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