Compare commits

...

105 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
f51e25ac28 fix(XenApi): cannot read properties of undefined (reading 'statusCode') 2023-08-29 08:48:40 +02:00
Thierry Goettelmann
3baa37846e feat(lite/stories): allow to organize stories in subdirectories (#6992) 2023-08-21 11:12:16 +02:00
Mathieu
999fba2030 feat(xo-web/pool/advanced): ability to set a crash dump SR (#6973)
Fixes #5060
2023-08-18 15:34:05 +02:00
Mathieu
785a5857ef fix(xapi/host_smartReboot): resume VMs after enabling host (#6980)
Found when investigating https://xcp-ng.org/forum/post/60372
2023-08-17 16:22:35 +02:00
Thierry Goettelmann
067f4ac882 feat(lite): new XenApi records collection system (#6975) 2023-08-17 15:22:33 +02:00
Julien Fontanet
8a26e08102 feat(xo-server/rest-api): filter/limit support for {backups/restore}/logs
Fixes https://xcp-ng.org/forum/post/64789
2023-08-17 13:59:32 +02:00
Julien Fontanet
42aa202f7a fix(xo-server/job.set): accept userId
Fixes https://xcp-ng.org/forum/post/64668
2023-08-16 15:13:00 +02:00
Julien Fontanet
403d2c8e7b fix(mixins/Tasks): behave when no user connected to API
Introduced by 1ddbe87d0
2023-08-11 11:27:08 +02:00
Julien Fontanet
ad46bde302 feat(backups/XO metadata): transfer binary config in base64 2023-08-10 15:39:34 +02:00
Julien Fontanet
1b6ec2c545 fix(xo-web/home): don't search in linked objects (#6881)
Introduced by 5928984069

For instance, searching the UUID of a running VM was showing all other VMs on the same host due to the UUID being present in their `container.residentVms`.
2023-08-10 14:42:07 +02:00
Julien Fontanet
56388557cb fix(xo-server): increase timeout when file restore via XO Proxy
Related to zammad#13396
2023-08-10 11:37:08 +02:00
Julien Fontanet
1ddbe87d0f feat(mixins/Tasks): inject userId in tasks 2023-08-09 16:18:29 +02:00
Pierre Donias
3081810450 feat(xo-server-netbox): synchronize VM tags
Fixes #5899
See Zammad#12478
See https://xcp-ng.org/forum/topic/6902
2023-08-08 15:23:57 +02:00
Pierre Donias
155be7fd95 fix(netbox): add missing trailing / in URL 2023-08-08 15:23:57 +02:00
Pierre Donias
ef960e94d3 chore(netbox): namespace all XO objects as xo* 2023-08-08 15:23:57 +02:00
Pierre Donias
bfd99a48fe chore(netbox): namespace all Netbox objects as nb* 2023-08-08 15:23:57 +02:00
Florent BEAUCHAMP
a13fda5fe9 fix(backups/_MixinXapiWriter): typo _heathCheckSr → _healthCheckSr (#6969)
Fix `TypeError: Cannot read properties of undefined (reading 'uuid') at #isAlreadyOnHealthCheckSr`
2023-08-08 09:48:53 +02:00
Florent BEAUCHAMP
66bee59774 fix(xen-api/getResource): don't fail silently when HTTP request fails without response (#6970)
Seen while investigating zammad#16309
2023-08-08 09:39:18 +02:00
Julien Fontanet
685400bbf8 fix(xo-server): fix get-stream@3 usage
Fixes #6971

Introduced by 3dca7f2a7
2023-08-08 08:05:38 +02:00
Julien Fontanet
5bef8fc411 fix(lite): disable linting because it's broken
Introduced by 3dca7f2a7
2023-08-05 17:05:02 +02:00
Julien Fontanet
aa7ff1449a fix(lite): adapt ESLint config to prettier@3
Introduced by 3dca7f2a7
2023-08-04 22:09:55 +02:00
Julien Fontanet
3dca7f2a71 chore: update deps 2023-08-03 17:56:24 +02:00
Julien Fontanet
3dc2f649f6 chore: format with Prettier 2023-08-03 17:56:24 +02:00
Julien Fontanet
9eb537c2f9 chore: update dev deps 2023-08-03 17:56:24 +02:00
Thierry Goettelmann
dfd5f6882f feat(lite): enhance typings for improved type safety (#6949) 2023-08-03 11:33:29 +02:00
Julien Fontanet
7214016338 fix(xo-server/_authenticateUser): don't use registerUser()
Introduced by 99605bf18
2023-08-03 10:25:15 +02:00
Julien Fontanet
606e3c4ce5 docs(xo-server-test-plugin): explain configurationPresets 2023-08-03 10:21:15 +02:00
Julien Fontanet
fb04d3d25d docs(xo-server-test-plugin): show title/description for settings 2023-08-03 10:20:51 +02:00
Julien Fontanet
db8c042131 fix(xo-web/plugins): merge preset with existing config
Instead of replacing it.
2023-08-03 10:14:07 +02:00
Julien Fontanet
fd9005fba8 fix(xo-web/plugins): don't disable presets when config not edited 2023-08-03 10:12:52 +02:00
Julien Fontanet
2d25413b8d fix(xo-server-auth-ldap): mark userIdAttribute as required
It can no longer be ommited since 99605bf18
2023-08-03 09:56:33 +02:00
Julien Fontanet
035679800a chore(xo-server-auth-ldap): defaults are merged automatically by xo-server
Related to 8c7d25424
2023-08-03 09:53:01 +02:00
Thierry Goettelmann
abd0a3035a feat(lite/component): created UiResources + UiResource (#6932) 2023-08-01 11:18:10 +02:00
Julien Fontanet
d307730c68 feat: release 5.85.0 2023-07-31 17:06:25 +02:00
Julien Fontanet
1b44de4958 feat(xo-server): 5.120.2 2023-07-31 16:52:03 +02:00
Julien Fontanet
ec78a1ce8b feat(xo-web): 5.122.2 2023-07-31 16:32:42 +02:00
Julien Fontanet
19c82ab30d feat(xo-server): 5.120.1 2023-07-31 16:32:41 +02:00
Julien Fontanet
9986f3fb18 fix(xo-web/removeUserAuthProvider): notify on error
Introduced by 52cf2d151
2023-07-31 16:29:09 +02:00
Julien Fontanet
d24e9c093d fix(xo-server/updaterUser): fix current user auth protection
Introduced by 2d52aee95
2023-07-31 16:28:16 +02:00
Julien Fontanet
70c8b24fac feat(xo-web): 5.122.1 2023-07-31 15:58:15 +02:00
Julien Fontanet
9c9c11104b feat(xo-server-auth-google): 0.3.0 2023-07-31 15:58:05 +02:00
Julien Fontanet
cba90b27f4 feat(xo-server-auth-github): 0.3.0 2023-07-31 15:57:44 +02:00
Julien Fontanet
46cbced570 feat(xo-server): 5.120.0 2023-07-31 15:56:48 +02:00
Julien Fontanet
52cf2d1514 feat(xo-web/settings/users): auth providers can be removed 2023-07-31 15:48:49 +02:00
Julien Fontanet
e51351be8d feat(xo-server/api): user.removeAuthProvider 2023-07-31 15:48:49 +02:00
Julien Fontanet
2a42e0ff94 feat(xo-web/users): display users auth providers
Related to zammad#16318
2023-07-31 15:48:49 +02:00
Julien Fontanet
3a824a2bfc fix(xo-server/updateUser): check password xor auth providers 2023-07-31 15:48:49 +02:00
Julien Fontanet
fc1c809a18 fix(xo-server): remove password when sign in with provider
Password can no longer be used/edited when an auth provider is registered.

For security concerns, this useless password should be removed from the database.
2023-07-31 15:48:49 +02:00
Julien Fontanet
221cd40199 fix(xo-server/updateUser): can remove password 2023-07-31 15:48:49 +02:00
Julien Fontanet
aca19d9a81 fix(xo-server): user pass disabled when associated auth providers 2023-07-31 15:48:49 +02:00
Julien Fontanet
0601bbe18d fix(xo-server/recover-account): remove all auth providers 2023-07-31 15:48:49 +02:00
Julien Fontanet
2d52aee952 fix(xo-server/updateUser): can remove all auth providers with null 2023-07-31 15:48:49 +02:00
Julien Fontanet
99605bf185 feat(xo-server/registerUser): completely disable 2023-07-31 15:48:49 +02:00
Julien Fontanet
91b19d9bc4 feat(xo-server-auth-google): use registerUser2 2023-07-31 15:48:49 +02:00
Julien Fontanet
562401ebe4 feat(xo-server-auth-github): use registerUser2 2023-07-31 15:48:49 +02:00
Julien Fontanet
6fd2f2610d fix(xo-web/new-vm): don't send device in VIFs
Introduced by 6ae19b064

Fixes #6960
2023-07-31 09:34:30 +02:00
Gabriel Gunullu
6ae19b0640 fix(xo-web/new-vm): list VIFs ordered by device (#6944)
Fixes zammad#15920
2023-07-28 18:51:48 +02:00
Pierre Donias
6b936d8a8c feat(lite): 0.1.2 (#6958) 2023-07-28 17:37:07 +02:00
Thierry Goettelmann
8f2cfaae00 feat(lite): open console in new window (#6868)
Add a link to open the console in a new window.
2023-07-28 14:04:06 +02:00
Thierry Goettelmann
5c215e1a8a feat(lite/console): rework VM console page (#6863)
Rework the VM Console page to be better aligned with Figma mockup.
- Spinner while loading the console
- Added the "monitor" image with correct message when VM is powered off
- Better screen space usage
2023-07-28 11:39:33 +02:00
Pierre Donias
e3cb98124f feat: technical release (#6956) 2023-07-28 10:05:26 +02:00
Julien Fontanet
90c3319880 feat(xo-web/backup/file-restore): add export format selection 2023-07-27 17:22:58 +02:00
Julien Fontanet
348db876d2 feat(xo-server/backupNg.fetchFiles): add format param 2023-07-27 17:22:58 +02:00
Julien Fontanet
408fd7ec03 feat(proxy/backup.fetchPartitionFiles): add format param 2023-07-27 17:22:58 +02:00
Julien Fontanet
1fd84836b1 feat(backups/fetchPartitionFiles): add tgz (tar+gzip) support
Around 6 times faster than ZIP export.
2023-07-27 17:22:58 +02:00
Julien Fontanet
522204795f fix(backups/fetchPartitionFiles): rewrite ZIP creation
It's now sequential which leads to better performance and less memory consumption.

Empty directories are now included and all entries have correct mode and modification time.
2023-07-27 17:22:58 +02:00
Julien Fontanet
e29c422ac9 fix(xo-server/_handleHttpRequest): use pipeline between result and response
Properly closes one stream if the other is destroyed.
2023-07-27 17:22:58 +02:00
Florent BEAUCHAMP
152cf09b7e feat(vmware-explorer): handle sesparse files (#6909) 2023-07-27 17:15:29 +02:00
Pierre Donias
ff728099dc docs(netbox): update screenshot (#6955) 2023-07-27 17:13:57 +02:00
Mathieu
706d94221d feat(xo-server/pool/rpu): avoid unnecessary VMs migration (#6943) 2023-07-27 17:12:31 +02:00
Gabriel Gunullu
340e9af7f4 fix(backups): handle incremental replication to multiple SRs (#6811)
Fix matching previous replications when multiple SRs.

Fixes #6582
2023-07-27 17:09:15 +02:00
Pierre Donias
40e536ba61 feat(xo-server-netbox): synchronize VM platform (#6954)
See Zammad#12478
See https://xcp-ng.org/forum/topic/6902
2023-07-27 16:59:50 +02:00
Thierry Goettelmann
fd4c56c8c2 feat(lite/pool): add tasks to Pool Dashboard (#6713)
Other updates:
- Move pending/finished tasks logic to store subscription
- Add `count` prop to `UiCardTitle`
- Add "No tasks" message on Task table if empty
- Make the `finishedTasks` prop optional
- Add ability to have full width dashboard cards
2023-07-27 16:23:52 +02:00
Thierry Goettelmann
20d04ba956 feat(lite): dynamic page title (#6853)
See #6793

ℹ️ This PR adds a `pageTitleStore` which allows defining the current page title
according to 3 parts: an object, a string, and a count. Each part is optional.

 The page title is **reactive** when function argument is a `Ref`, a `Computed`
or a getter. For example, when updating a VM name, the page title will be
updated in every tabs.

🪄 Each title part is automatically unset when the component that set it is
unmounted.
2023-07-27 11:41:33 +02:00
Pierre Donias
3b1bcc67ae feat(xo-server-netbox): rewrite (#6950)
Fixes #6038, Fixes #6135, Fixes #6024, Fixes #6036
See https://xcp-ng.org/forum/topic/6070
See zammad#5695
See https://xcp-ng.org/forum/topic/6149
See https://xcp-ng.org/forum/topic/6332

Complete rewrite of the plugin. Main functional changes:
- Synchronize VM description
- Fix duplicated VMs in Netbox after disconnecting one pool
- Migrating a VM from one pool to another keeps VM data added manually
- Fix largest IP prefix being picked instead of smallest
- Fix synchronization not working if some pools are unavailable
- Better error messages
2023-07-27 10:07:26 +02:00
Julien Fontanet
1add3fbf9d fix(yarn.lock): refresh
Introduced by 1c23bd5ff
2023-07-26 13:36:28 +02:00
Julien Fontanet
97f0759de0 feat(mixins/Hooks): warning every 5s if listener still running
This helps diagnosticate issues when a hook is stuck.'
2023-07-25 16:41:29 +02:00
Julien Fontanet
005ab47d9b fix(xo-web): clear token on authentication failure (#6937)
This prevents infinite refreshes when the token is deemed valid by the server
but the authentication failed for any reasons.
2023-07-25 09:49:11 +02:00
Julien Fontanet
14a0caa4c6 fix(xo-web/xoa/licenses): fix message *go TO* 2023-07-25 09:43:11 +02:00
Florent BEAUCHAMP
1c23bd5ff7 feat(read-chunk/readChunkStrict): attach read chunk to error if small text (#6940) 2023-07-20 17:01:26 +02:00
Julien Fontanet
49c161b17a fix(xo-server,xo-web): send version when probing NFS SR
Reported by @benjamreis
2023-07-20 16:46:18 +02:00
Gabriel Gunullu
18dce3fce6 test(fs): fix wrong encryption (#6945) 2023-07-20 16:32:09 +02:00
Julien Fontanet
d6fc86b6bc chore(xo-server-transport-xmpp): remove old dep node-xmpp-client
Fix possibly #6942
2023-07-20 10:54:52 +02:00
Florent BEAUCHAMP
61d960d4b1 fix(vmware-explorer): handle snapshot of 1TB+ disks 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
02d3465832 feat(vmware-explorer): don't transform stream for raw import in thick mode 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
4bbadc9515 feat(vmware-explorer): improve import
- use one stream instead of per block queries if possible
- retry block reading if failing
- handle unaligned end block
2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
78586291ca fix(vmware-explorer): better disk size computation 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
945dec94bf feat(vmware-explorer): retry connection to ESXi 2023-07-20 10:25:28 +02:00
Julien Fontanet
003140d96b test(nbd-client): fix issues introduced by conversion to ESM
Introduced by 7c80d0c1e
2023-07-19 23:09:48 +02:00
Julien Fontanet
363d7cf0d0 fix(node-vsphere-soap): add missing files
Introduced by f0c94496b
2023-07-19 23:02:34 +02:00
Julien Fontanet
f0c94496bf chore(node-vsphere-soap): convert to ESM
BREAKING CHANGE
2023-07-19 11:03:56 +02:00
Julien Fontanet
de217eabd9 test(nbd-client): fix issues introduced by conversion to ESM
Introduced by 7c80d0c1e
2023-07-19 11:02:12 +02:00
Julien Fontanet
7c80d0c1e1 chore(nbd-client): convert to ESM
BREAKING CHANGE
2023-07-19 10:46:05 +02:00
Julien Fontanet
9fb749b1db chore(fuse-vhd): convert to ESM
BREAKING CHANGE
2023-07-19 10:13:35 +02:00
Julien Fontanet
ad9c59669a chore: update dev deps 2023-07-19 10:11:30 +02:00
Julien Fontanet
76a038e403 fix(xo-web): fix doc link to incremental/key backup interval 2023-07-19 09:48:23 +02:00
Julien Fontanet
0e12072922 fix(xo-server/pool.mergeInto): fix IPv6 handling 2023-07-18 17:28:39 +02:00
Julien Fontanet
158a8e14a2 chore(xen-api): expose bracketless IPv6 as hostnameRaw 2023-07-18 17:28:38 +02:00
Julien Fontanet
0c97910349 chore(xo-server): remove unused _mounts property
Introduced by 5c9a47b6b
2023-07-18 11:14:24 +02:00
Florent BEAUCHAMP
8347ac6ed8 fix(xo-server/xapi-stats): simplify caching (#6920)
Following #6903

- change cache system per object => per host
- update cache at the beginning of the query to handle race conditions leading to duplicate requests
- remove concurrency limit (was leading to a huge backlog of queries, and response handling is quite fast)
2023-07-18 09:47:39 +02:00
Mathieu
996abd6e7e fix(xo-web/settings/config): wording fix for XO Config Cloud Backup (#6938)
See zammad#15904
2023-07-13 10:29:36 +02:00
rbarhtaoui
de8abd5b63 feat(lite/pool/vms): ability to export selected VMs as JSON file (#6911) 2023-07-13 09:30:10 +02:00
Julien Fontanet
3de928c488 fix(xo-server-audit): ignore mirrorBackup.getAllJobs 2023-07-12 21:52:51 +02:00
Mathieu
a2a514e483 feat(lite/stats): cache stats from rrd_update (#6781) 2023-07-12 15:30:05 +02:00
rbarhtaoui
ff432e04b0 feat(lite/pool/vms): export selected VMs as CSV file (#6915) 2023-07-12 14:34:16 +02:00
228 changed files with 6290 additions and 4354 deletions

View File

@@ -1,8 +1,11 @@
'use strict'
module.exports = {
arrowParens: 'avoid',
jsxSingleQuote: true,
semi: false,
singleQuote: true,
trailingComma: 'es5',
// 2020-11-24: Requested by nraynaud and approved by the rest of the team
//

View File

@@ -1,9 +1,7 @@
'use strict'
const LRU = require('lru-cache')
const Fuse = require('fuse-native')
const { VhdSynthetic } = require('vhd-lib')
const { Disposable, fromCallback } = require('promise-toolbox')
import LRU from 'lru-cache'
import Fuse from 'fuse-native'
import { VhdSynthetic } from 'vhd-lib'
import { Disposable, fromCallback } from 'promise-toolbox'
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
@@ -16,7 +14,7 @@ const stat = st => ({
gid: st.gid !== undefined ? st.gid : process.getgid(),
})
exports.mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
const cache = new LRU({

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/fuse-vhd",
"version": "1.0.0",
"version": "2.0.0",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
@@ -15,8 +15,9 @@
"url": "https://vates.fr"
},
"engines": {
"node": ">=10.0"
"node": ">=14"
},
"main": "./index.mjs",
"dependencies": {
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",

View File

@@ -1,42 +0,0 @@
'use strict'
exports.INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
exports.OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
exports.NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
exports.NBD_OPT_EXPORT_NAME = 1
exports.NBD_OPT_ABORT = 2
exports.NBD_OPT_LIST = 3
exports.NBD_OPT_STARTTLS = 5
exports.NBD_OPT_INFO = 6
exports.NBD_OPT_GO = 7
exports.NBD_FLAG_HAS_FLAGS = 1 << 0
exports.NBD_FLAG_READ_ONLY = 1 << 1
exports.NBD_FLAG_SEND_FLUSH = 1 << 2
exports.NBD_FLAG_SEND_FUA = 1 << 3
exports.NBD_FLAG_ROTATIONAL = 1 << 4
exports.NBD_FLAG_SEND_TRIM = 1 << 5
exports.NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
exports.NBD_CMD_FLAG_FUA = 1 << 0
exports.NBD_CMD_FLAG_NO_HOLE = 1 << 1
exports.NBD_CMD_FLAG_DF = 1 << 2
exports.NBD_CMD_FLAG_REQ_ONE = 1 << 3
exports.NBD_CMD_FLAG_FAST_ZERO = 1 << 4
exports.NBD_CMD_READ = 0
exports.NBD_CMD_WRITE = 1
exports.NBD_CMD_DISC = 2
exports.NBD_CMD_FLUSH = 3
exports.NBD_CMD_TRIM = 4
exports.NBD_CMD_CACHE = 5
exports.NBD_CMD_WRITE_ZEROES = 6
exports.NBD_CMD_BLOCK_STATUS = 7
exports.NBD_CMD_RESIZE = 8
exports.NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
exports.NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
exports.NBD_REPLY_ACK = 1
exports.NBD_DEFAULT_PORT = 10809
exports.NBD_DEFAULT_BLOCK_SIZE = 64 * 1024

View File

@@ -0,0 +1,41 @@
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
export const NBD_OPT_EXPORT_NAME = 1
export const NBD_OPT_ABORT = 2
export const NBD_OPT_LIST = 3
export const NBD_OPT_STARTTLS = 5
export const NBD_OPT_INFO = 6
export const NBD_OPT_GO = 7
export const NBD_FLAG_HAS_FLAGS = 1 << 0
export const NBD_FLAG_READ_ONLY = 1 << 1
export const NBD_FLAG_SEND_FLUSH = 1 << 2
export const NBD_FLAG_SEND_FUA = 1 << 3
export const NBD_FLAG_ROTATIONAL = 1 << 4
export const NBD_FLAG_SEND_TRIM = 1 << 5
export const NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
export const NBD_CMD_FLAG_FUA = 1 << 0
export const NBD_CMD_FLAG_NO_HOLE = 1 << 1
export const NBD_CMD_FLAG_DF = 1 << 2
export const NBD_CMD_FLAG_REQ_ONE = 1 << 3
export const NBD_CMD_FLAG_FAST_ZERO = 1 << 4
export const NBD_CMD_READ = 0
export const NBD_CMD_WRITE = 1
export const NBD_CMD_DISC = 2
export const NBD_CMD_FLUSH = 3
export const NBD_CMD_TRIM = 4
export const NBD_CMD_CACHE = 5
export const NBD_CMD_WRITE_ZEROES = 6
export const NBD_CMD_BLOCK_STATUS = 7
export const NBD_CMD_RESIZE = 8
export const NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
export const NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
export const NBD_REPLY_ACK = 1
export const NBD_DEFAULT_PORT = 10809
export const NBD_DEFAULT_BLOCK_SIZE = 64 * 1024

View File

@@ -1,8 +1,11 @@
'use strict'
const assert = require('node:assert')
const { Socket } = require('node:net')
const { connect } = require('node:tls')
const {
import assert from 'node:assert'
import { Socket } from 'node:net'
import { connect } from 'node:tls'
import { fromCallback, pRetry, pDelay, pTimeout } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
INIT_PASSWD,
NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE,
@@ -17,16 +20,13 @@ const {
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
NBD_CMD_DISC,
} = require('./constants.js')
const { fromCallback, pRetry, pDelay, pTimeout } = require('promise-toolbox')
const { readChunkStrict } = require('@vates/read-chunk')
const { createLogger } = require('@xen-orchestra/log')
} from './constants.mjs'
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
module.exports = class NbdClient {
export default class NbdClient {
#serverAddress
#serverCert
#serverPort

View File

@@ -13,17 +13,18 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.2.1",
"version": "2.0.0",
"engines": {
"node": ">=14.0"
},
"main": "./index.mjs",
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.1.1",
"@vates/read-chunk": "^1.2.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.3.3"
"xen-api": "^1.3.4"
},
"devDependencies": {
"tap": "^16.3.0",
@@ -31,6 +32,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.js"
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.mjs"
}
}

View File

@@ -1,13 +1,12 @@
'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')
import NbdClient from '../index.mjs'
import { spawn, exec } from 'node:child_process'
import fs from 'node:fs/promises'
import { test } from 'tap'
import tmp from 'tmp'
import { pFromCallback } from 'promise-toolbox'
import { Socket } from 'node:net'
import { NBD_DEFAULT_PORT } from '../constants.mjs'
import assert from 'node:assert'
const FILE_SIZE = 10 * 1024 * 1024

View File

@@ -1,4 +1,3 @@
'use strict'
/*
node-vsphere-soap
@@ -12,17 +11,18 @@
*/
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
import { EventEmitter } from 'events'
import axios from 'axios'
import https from 'node:https'
import util from 'util'
import soap from 'soap'
import Cookie from 'soap-cookie' // required for session persistence
// Client class
// inherits from EventEmitter
// possible events: connect, error, ready
function Client(vCenterHostname, username, password, sslVerify) {
export function Client(vCenterHostname, username, password, sslVerify) {
this.status = 'disconnected'
this.reconnectCount = 0
@@ -228,4 +228,3 @@ function _soapErrorHandler(self, emitter, command, args, err) {
}
// end
exports.Client = Client

View File

@@ -1,8 +1,8 @@
{
"name": "@vates/node-vsphere-soap",
"version": "1.0.0",
"version": "2.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",
"main": "lib/client.mjs",
"author": "reedog117",
"repository": {
"directory": "@vates/node-vsphere-soap",
@@ -30,7 +30,7 @@
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/node-vsphere-soap",
"engines": {
"node": ">=8.10"
"node": ">=14"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -1,15 +1,11 @@
'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 = {
export const vCenterTestCreds = {
vCenterIP: 'vcsa',
vCenterUser: 'vcuser',
vCenterPassword: 'vcpw',
vCenter: true,
}
exports.vCenterTestCreds = vCenterTestCreds

View File

@@ -1,18 +1,16 @@
'use strict'
/*
vsphere-soap.test.js
tests for the vCenterConnectionInstance class
*/
const assert = require('assert')
const { describe, it } = require('test')
import assert from 'assert'
import { describe, it } from 'test'
const vc = require('../lib/client')
import * as vc from '../lib/client.mjs'
// eslint-disable-next-line n/no-missing-require
const TestCreds = require('../config-test.js').vCenterTestCreds
// eslint-disable-next-line n/no-missing-import
import { vCenterTestCreds as TestCreds } from '../config-test.mjs'
const VItest = new vc.Client(TestCreds.vCenterIP, TestCreds.vCenterUser, TestCreds.vCenterPassword, false)

View File

@@ -1,6 +1,7 @@
'use strict'
const assert = require('assert')
const isUtf8 = require('isutf8')
/**
* Read a chunk of data from a stream.
@@ -81,6 +82,13 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
if (size !== undefined && chunk.length !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
// Buffer.isUtf8 is too recent for now
// @todo : replace external package by Buffer.isUtf8 when the supported version of node reach 18
if (chunk.length < 1024 && isUtf8(chunk)) {
error.text = chunk.toString('utf8')
}
Object.defineProperties(error, {
chunk: {
value: chunk,

View File

@@ -102,12 +102,37 @@ describe('readChunkStrict', function () {
assert.strictEqual(error.chunk, undefined)
})
it('throws if stream ends with not enough data', async () => {
it('throws if stream ends with not enough data, utf8', async () => {
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.strictEqual(error.text, 'foobar')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
it('throws if stream ends with not enough data, non utf8 ', async () => {
const source = [Buffer.alloc(10, 128), Buffer.alloc(10, 128)]
const error = await rejectionOf(readChunkStrict(makeStream(source), 30))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 20, expected: 30)')
assert.strictEqual(error.text, undefined)
assert.deepEqual(error.chunk, Buffer.concat(source))
})
it('throws if stream ends with not enough data, utf8 , long data', async () => {
const source = Buffer.from('a'.repeat(1500))
const error = await rejectionOf(readChunkStrict(makeStream([source]), 2000))
assert(error instanceof Error)
assert.strictEqual(error.message, `stream has ended with not enough data (actual: 1500, expected: 2000)`)
assert.strictEqual(error.text, undefined)
assert.deepEqual(error.chunk, source)
})
it('succeed', async () => {
const source = Buffer.from('a'.repeat(20))
const chunk = await readChunkStrict(makeStream([source]), 10)
assert.deepEqual(source.subarray(10), chunk)
})
})
describe('skip', function () {
@@ -134,6 +159,16 @@ describe('skip', function () {
it('returns less size if stream ends', async () => {
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
})
it('put back if it read too much', async () => {
let source = makeStream(['foo', 'bar'])
await skip(source, 1) // read part of data chunk
const chunk = (await readChunkStrict(source, 2)).toString('utf-8')
assert.strictEqual(chunk, 'oo')
source = makeStream(['foo', 'bar'])
assert.strictEqual(await skip(source, 3), 3) // read aligned with data chunk
})
})
describe('skipStrict', function () {
@@ -144,4 +179,9 @@ describe('skipStrict', function () {
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
it('succeed', async () => {
const source = makeStream(['foo', 'bar', 'baz'])
const res = await skipStrict(source, 4)
assert.strictEqual(res, undefined)
})
})

View File

@@ -19,7 +19,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "1.1.1",
"version": "1.2.0",
"engines": {
"node": ">=8.10"
},
@@ -33,5 +33,8 @@
},
"devDependencies": {
"test": "^3.2.1"
},
"dependencies": {
"isutf8": "^4.0.0"
}
}

View File

@@ -7,9 +7,9 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.39.0",
"@xen-orchestra/backups": "^0.40.0",
"@xen-orchestra/fs": "^4.0.1",
"filenamify": "^4.1.0",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
"promise-toolbox": "^0.21.0"
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.9",
"version": "1.0.10",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -5,7 +5,7 @@ import { createLogger } from '@xen-orchestra/log'
import { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } from 'vhd-lib'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { dirname, join, normalize, resolve } from 'node:path'
import { dirname, join, resolve } from 'node:path'
import { execFile } from 'child_process'
import { mount } from '@vates/fuse-vhd'
import { readdir, lstat } from 'node:fs/promises'
@@ -18,6 +18,7 @@ import fromEvent from 'promise-toolbox/fromEvent'
import groupBy from 'lodash/groupBy.js'
import pDefer from 'promise-toolbox/defer'
import pickBy from 'lodash/pickBy.js'
import tar from 'tar'
import zlib from 'zlib'
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
@@ -41,20 +42,23 @@ const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const noop = Function.prototype
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
const makeRelative = path => resolve('/', path).slice(1)
const resolveSubpath = (root, path) => resolve(root, makeRelative(path))
const resolveSubpath = (root, path) => resolve(root, `.${resolve('/', path)}`)
async function addZipEntries(zip, realBasePath, virtualBasePath, relativePaths) {
for (const relativePath of relativePaths) {
const realPath = join(realBasePath, relativePath)
const virtualPath = join(virtualBasePath, relativePath)
async function addDirectory(files, realPath, metadataPath) {
const stats = await lstat(realPath)
if (stats.isDirectory()) {
await asyncMap(await readdir(realPath), file =>
addDirectory(files, realPath + '/' + file, metadataPath + '/' + file)
)
} else if (stats.isFile()) {
files.push({
realPath,
metadataPath,
})
const stats = await lstat(realPath)
const { mode, mtime } = stats
const opts = { mode, mtime }
if (stats.isDirectory()) {
zip.addEmptyDirectory(virtualPath, opts)
await addZipEntries(zip, realPath, virtualPath, await readdir(realPath))
} else if (stats.isFile()) {
zip.addFile(realPath, virtualPath, opts)
}
}
}
@@ -182,17 +186,6 @@ export class RemoteAdapter {
})
}
async *_usePartitionFiles(diskId, partitionId, paths) {
const path = yield this.getPartition(diskId, partitionId)
const files = []
await asyncMap(paths, file =>
addDirectory(files, resolveSubpath(path, file), normalize('./' + file).replace(/\/+$/, ''))
)
return files
}
// check if we will be allowed to merge a a vhd created in this adapter
// with the vhd at path `path`
async isMergeableParent(packedParentUid, path) {
@@ -209,15 +202,24 @@ export class RemoteAdapter {
})
}
fetchPartitionFiles(diskId, partitionId, paths) {
fetchPartitionFiles(diskId, partitionId, paths, format) {
const { promise, reject, resolve } = pDefer()
Disposable.use(
async function* () {
const files = yield this._usePartitionFiles(diskId, partitionId, paths)
const zip = new ZipFile()
files.forEach(({ realPath, metadataPath }) => zip.addFile(realPath, metadataPath))
zip.end()
const { outputStream } = zip
const path = yield this.getPartition(diskId, partitionId)
let outputStream
if (format === 'tgz') {
outputStream = tar.c({ cwd: path, gzip: true }, paths.map(makeRelative))
} else if (format === 'zip') {
const zip = new ZipFile()
await addZipEntries(zip, path, '', paths.map(makeRelative))
zip.end()
;({ outputStream } = zip)
} else {
throw new Error('unsupported format ' + format)
}
resolve(outputStream)
await fromEvent(outputStream, 'end')
}.bind(this)
@@ -824,8 +826,6 @@ decorateMethodsWith(RemoteAdapter, {
debounceResourceFactory,
]),
_usePartitionFiles: Disposable.factory,
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
getPartition: Disposable.factory,

View File

@@ -21,7 +21,12 @@ export class RestoreMetadataBackup {
})
} else {
const metadata = JSON.parse(await handler.readFile(join(backupId, 'metadata.json')))
return String(await handler.readFile(resolve(backupId, metadata.data ?? 'data.json')))
const dataFileName = resolve(backupId, metadata.data ?? 'data.json')
const data = await handler.readFile(dataFileName)
// if data is JSON, sent it as a plain string, otherwise, consider the data as binary and encode it
const isJson = dataFileName.endsWith('.json')
return isJson ? data.toString() : { encoding: 'base64', data: data.toString('base64') }
}
}
}

View File

@@ -16,6 +16,8 @@ export const TAG_BASE_DELTA = 'xo:base_delta'
export const TAG_COPY_SRC = 'xo:copy_of'
const TAG_BACKUP_SR = 'xo:backup:sr'
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
const resolveUuid = async (xapi, cache, uuid, type) => {
if (uuid == null) {
@@ -157,7 +159,10 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
if (detectBase) {
const remoteBaseVmUuid = vmRecord.other_config[TAG_BASE_DELTA]
if (remoteBaseVmUuid) {
baseVm = find(xapi.objects.all, obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid)
baseVm = find(
xapi.objects.all,
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
)
if (!baseVm) {
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)

View File

@@ -22,7 +22,13 @@ export class XoMetadataBackup {
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
const data = job.xoMetadata
const dataBaseName = './data.json'
let dataBaseName = './data'
// JSON data is sent as plain string, binary data is sent as an object with `data` and `encoding properties
const isJson = typeof data === 'string'
if (isJson) {
dataBaseName += '.json'
}
const metadata = JSON.stringify(
{
@@ -54,7 +60,7 @@ export class XoMetadataBackup {
async () => {
const handler = adapter.handler
const dirMode = this._config.dirMode
await handler.outputFile(dataFileName, data, { dirMode })
await handler.outputFile(dataFileName, isJson ? data : Buffer.from(data.data, data.encoding), { dirMode })
await handler.outputFile(metaDataFileName, metadata, {
dirMode,
})

View File

@@ -18,7 +18,7 @@ export const MixinXapiWriter = (BaseClass = Object) =>
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) {
if (vdi.$SR.uuid !== this._healthCheckSr.uuid) {
return false
}
}

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.39.0",
"version": "0.40.0",
"engines": {
"node": ">=14.18"
},
@@ -23,15 +23,15 @@
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "^1.2.1",
"@vates/fuse-vhd": "^2.0.0",
"@vates/nbd-client": "^2.0.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.0.1",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1",
"d3-time-format": "^3.0.0",
"compare-versions": "^6.0.0",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"golike-defer": "^0.5.1",
"limit-concurrency-decorator": "^0.5.0",
@@ -40,9 +40,10 @@
"parse-pairs": "^2.0.0",
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"tar": "^6.1.15",
"uuid": "^9.0.0",
"vhd-lib": "^4.5.0",
"xen-api": "^1.3.3",
"xen-api": "^1.3.4",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -53,7 +54,7 @@
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^2.2.1"
"@xen-orchestra/xapi": "^3.0.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

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

View File

@@ -29,7 +29,7 @@
"@vates/async-each": "^1.0.0",
"@vates/coalesce-calls": "^0.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.1.1",
"@vates/read-chunk": "^1.2.0",
"@xen-orchestra/log": "^0.6.0",
"bind-property-descriptor": "^2.0.0",
"decorator-synchronized": "^0.6.0",

View File

@@ -209,7 +209,7 @@ describe('encryption', () => {
// encrypt with a non default algorithm
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gmc"}`)
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gcm"}`)
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
// remote is now non empty : can't modify key anymore

View File

@@ -1,2 +1,4 @@
// Keeping this file to prevent applying the global monorepo config for now
module.exports = {};
module.exports = {
trailingComma: "es5",
};

View File

@@ -2,6 +2,12 @@
## **next**
## **0.1.2** (2023-07-28)
- Ability to export selected VMs as CSV file (PR [#6915](https://github.com/vatesfr/xen-orchestra/pull/6915))
- [Pool/VMs] Ability to export selected VMs as JSON file (PR [#6911](https://github.com/vatesfr/xen-orchestra/pull/6911))
- Add Tasks to Pool Dashboard (PR [#6713](https://github.com/vatesfr/xen-orchestra/pull/6713))
## **0.1.1** (2023-07-03)
- Invalidate sessionId token after logout (PR [#6480](https://github.com/vatesfr/xen-orchestra/pull/6480))

View File

@@ -0,0 +1,144 @@
<!-- TOC -->
- [XenApiCollection](#xenapicollection)
- [Get the collection](#get-the-collection)
- [Defer the subscription](#defer-the-subscription)
- [Create a dedicated collection](#create-a-dedicated-collection)
- [Alter the collection](#alter-the-collection)
_ [Example 1: Adding props to records](#example-1-adding-props-to-records)
_ [Example 2: Adding props to the collection](#example-2-adding-props-to-the-collection) \* [Example 3, filtering and sorting the collection](#example-3-filtering-and-sorting-the-collection)
<!-- TOC -->
# XenApiCollection
## Get the collection
To retrieve a collection, invoke `useXenApiCollection("VM")`.
By doing this, the current component will be automatically subscribed to the collection and will be updated when the
collection changes.
When the component is unmounted, the subscription will be automatically stopped.
## Defer the subscription
If you don't want to fetch the data of the collection when the component is mounted, you can pass `{ immediate: false }`
as options: `const { start, isStarted } = useXenApiCollection("VM", { immediate: false })`.
Then you subscribe to the collection by calling `start()`.
## Create a dedicated collection
It is recommended to create a dedicated collection composable for each type of record you want to use.
They are stored in `src/composables/xen-api-collection/*-collection.composable.ts`.
```typescript
// src/composables/xen-api-collection/console-collection.composable.ts
export const useConsoleCollection = () => useXenApiCollection("console");
```
If you want to allow the user to defer the subscription, you can propagate the options to `useXenApiCollection`.
```typescript
// console-collection.composable.ts
export const useConsoleCollection = <
Immediate extends boolean = true,
>(options?: {
immediate?: Immediate;
}) => useXenApiCollection("console", options);
```
```typescript
// MyComponent.vue
const collection = useConsoleCollection({ immediate: false });
setTimeout(() => collection.start(), 10000);
```
## Alter the collection
You can alter the collection by overriding parts of it.
### Example 1: Adding props to records
```typescript
// xen-api.ts
export interface XenApiConsole extends XenApiRecord<"console"> {
// ... existing props
someProp: string;
someOtherProp: number;
}
```
```typescript
// console-collection.composable.ts
export const useConsoleCollection = () => {
const collection = useXenApiCollection("console");
const records = computed(() => {
return collection.records.value.map((console) => ({
...console,
someProp: "Some value",
someOtherProp: 42,
}));
});
return {
...collection,
records,
};
};
```
```typescript
const consoleCollection = useConsoleCollection();
consoleCollection.getByUuid("...").someProp; // "Some value"
```
### Example 2: Adding props to the collection
```typescript
// vm-collection.composable.ts
export const useVmCollection = () => {
const collection = useXenApiCollection("VM");
return {
...collection,
runningVms: computed(() =>
collection.records.value.filter(
(vm) => vm.power_state === POWER_STATE.RUNNING
)
),
};
};
```
### Example 3, filtering and sorting the collection
```typescript
// vm-collection.composable.ts
export const useVmCollection = () => {
const collection = useXenApiCollection("VM");
return {
...collection,
records: computed(() =>
collection.records.value
.filter(
(vm) =>
!vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain
)
.sort((vm1, vm2) => vm1.name_label.localeCompare(vm2.name_label))
),
};
};
```

View File

@@ -1,144 +0,0 @@
# 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

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<title>XO Lite</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.1.1",
"version": "0.1.2",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
@@ -17,14 +17,16 @@
"@fortawesome/vue-fontawesome": "^3.0.1",
"@novnc/novnc": "^1.3.0",
"@types/d3-time-format": "^4.0.0",
"@types/file-saver": "^2.0.5",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.8",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"complex-matcher": "^0.7.0",
"complex-matcher": "^0.7.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"file-saver": "^2.0.5",
"highlight.js": "^11.6.0",
"human-format": "^1.1.0",
"iterable-backoff": "^0.1.0",
@@ -47,7 +49,7 @@
"@rushstack/eslint-patch": "^1.1.0",
"@types/node": "^16.11.41",
"@vitejs/plugin-vue": "^4.2.3",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/tsconfig": "^0.1.3",
"eslint-plugin-vue": "^9.0.0",

View File

@@ -4,10 +4,10 @@
<AppLogin />
</div>
<div v-else>
<AppHeader />
<AppHeader v-if="uiStore.hasUi" />
<div style="display: flex">
<AppNavigation />
<main class="main">
<AppNavigation v-if="uiStore.hasUi" />
<main class="main" :class="{ 'no-ui': !uiStore.hasUi }">
<RouterView />
</main>
</div>
@@ -23,7 +23,7 @@ import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { usePoolStore } from "@/stores/pool.store";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { useUiStore } from "@/stores/ui.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
@@ -41,10 +41,10 @@ if (link == null) {
}
link.href = favicon;
document.title = "XO Lite";
const xenApiStore = useXenApiStore();
const { pool } = usePoolStore().subscribe();
const { pool } = usePoolCollection();
useChartTheme();
const uiStore = useUiStore();
@@ -92,5 +92,9 @@ whenever(
flex: 1;
height: calc(100vh - 8rem);
background-color: var(--background-color-secondary);
&.no-ui {
height: 100vh;
}
}
</style>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -24,6 +24,7 @@
</template>
<script lang="ts" setup>
import { usePageTitleStore } from "@/stores/page-title.store";
import { storeToRefs } from "pinia";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
@@ -33,6 +34,7 @@ import UiButton from "@/components/ui/UiButton.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const { t } = useI18n();
usePageTitleStore().setTitle(t("login"));
const xenApiStore = useXenApiStore();
const { isConnecting } = storeToRefs(xenApiStore);
const login = ref("root");
@@ -62,7 +64,7 @@ async function handleSubmit() {
isInvalidPassword.value = true;
error.value = t("password-invalid");
} else {
error.value = t("error-occured");
error.value = t("error-occurred");
console.error(err);
}
}

View File

@@ -7,12 +7,12 @@
</template>
<script
generic="T extends XenApiRecord<string>, I extends T['uuid']"
generic="T extends XenApiRecord<RawObjectType>, I extends T['uuid']"
lang="ts"
setup
>
import UiSpinner from "@/components/ui/UiSpinner.vue";
import type { XenApiRecord } from "@/libs/xen-api";
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import { computed } from "vue";
import { useRouter } from "vue-router";

View File

@@ -3,11 +3,11 @@
</template>
<script lang="ts" setup>
import { useXenApiStore } from "@/stores/xen-api.store";
import VncClient from "@novnc/novnc/core/rfb";
import { promiseTimeout } from "@vueuse/shared";
import { fibonacci } from "iterable-backoff";
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";
const N_TOTAL_TRIES = 8;
const FIBONACCI_MS_ARRAY: number[] = Array.from(

View File

@@ -27,14 +27,14 @@
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import UiModal from "@/components/ui/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { computed, ref, watch } from "vue";
import { difference } from "lodash-es";
import { useHostStore } from "@/stores/host.store";
const { records: hosts } = useHostStore().subscribe();
const { records: hosts } = useHostCollection();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);

View File

@@ -33,7 +33,7 @@
</AppMenu>
</UiTabBar>
<div class="tabs">
<div :class="{ 'full-width': fullWidthComponent }" class="tabs">
<UiCard v-if="selectedTab === TAB.NONE" class="tab-content">
<i>No configuration defined</i>
</UiCard>
@@ -102,11 +102,11 @@ import StorySettingParams from "@/components/component-story/StorySettingParams.
import StorySlotParams from "@/components/component-story/StorySlotParams.vue";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiTab from "@/components/ui/UiTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import {
@@ -140,6 +140,7 @@ const props = defineProps<{
settings?: Record<string, any>;
}
>;
fullWidthComponent?: boolean;
}>();
enum TAB {
@@ -329,6 +330,10 @@ const applyPreset = (preset: {
padding: 1rem;
gap: 1rem;
&.full-width {
flex-direction: column;
}
.tab-content {
flex: 1;
height: auto;

View File

@@ -2,32 +2,80 @@
<RouterLink :to="{ name: 'story' }">
<UiTitle type="h4">Stories</UiTitle>
</RouterLink>
<ul class="links">
<li v-for="route in routes" :key="route.name">
<RouterLink class="link" :to="route">
{{ route.meta.storyTitle }}
</RouterLink>
</li>
</ul>
<StoryMenuTree
:tree="tree"
@toggle-directory="toggleDirectory"
:opened-directories="openedDirectories"
/>
</template>
<script lang="ts" setup>
import { useRouter } from "vue-router";
import StoryMenuTree from "@/components/component-story/StoryMenuTree.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { type RouteRecordNormalized, useRoute, useRouter } from "vue-router";
import { ref } from "vue";
const { getRoutes } = useRouter();
const routes = getRoutes().filter((route) => route.meta.isStory);
</script>
<style lang="postcss" scoped>
.links {
padding: 1rem;
export type StoryTree = Map<
string,
{ path: string; directory: string; children: StoryTree }
>;
function createTree(routes: RouteRecordNormalized[]) {
const tree: StoryTree = new Map();
for (const route of routes) {
const parts = route.path.slice(7).split("/");
let currentNode = tree;
let currentPath = "";
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!currentNode.has(part)) {
currentNode.set(part, {
children: new Map(),
path: route.path,
directory: currentPath,
});
}
currentNode = currentNode.get(part)!.children;
}
}
return tree;
}
.link {
display: inline-block;
padding: 0.5rem 1rem;
text-decoration: none;
font-size: 1.6rem;
}
</style>
const tree = createTree(routes);
const currentRoute = useRoute();
const getDefaultOpenedDirectories = (): Set<string> => {
if (!currentRoute.meta.isStory) {
return new Set<string>();
}
const openedDirectories = new Set<string>();
const parts = currentRoute.path.split("/");
let currentPath = "";
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
openedDirectories.add(currentPath);
}
return openedDirectories;
};
const openedDirectories = ref(getDefaultOpenedDirectories());
const toggleDirectory = (directory: string) => {
if (openedDirectories.value.has(directory)) {
openedDirectories.value.delete(directory);
} else {
openedDirectories.value.add(directory);
}
};
</script>

View File

@@ -0,0 +1,83 @@
<template>
<ul class="story-menu-tree">
<li v-for="[key, node] in tree" :key="key">
<span
v-if="node.children.size > 0"
class="directory"
@click="emit('toggle-directory', node.directory)"
>
<UiIcon
:icon="isOpen(node.directory) ? faFolderOpen : faFolderClosed"
/>
{{ formatName(key) }}
</span>
<RouterLink v-else :to="node.path" class="link">
<UiIcon :icon="faFile" />
{{ formatName(key) }}
</RouterLink>
<StoryMenuTree
v-if="isOpen(node.directory)"
:tree="node.children"
@toggle-directory="emit('toggle-directory', $event)"
:opened-directories="openedDirectories"
/>
</li>
</ul>
</template>
<script lang="ts" setup>
import type { StoryTree } from "@/components/component-story/StoryMenu.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import {
faFile,
faFolderClosed,
faFolderOpen,
} from "@fortawesome/free-regular-svg-icons";
const props = defineProps<{
tree: StoryTree;
openedDirectories: Set<string>;
}>();
const emit = defineEmits<{
(event: "toggle-directory", directory: string): void;
}>();
const isOpen = (directory: string) => props.openedDirectories.has(directory);
const formatName = (name: string) => {
const parts = name.split("-");
return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
};
</script>
<style lang="postcss" scoped>
.story-menu-tree {
padding-left: 1rem;
.story-menu-tree {
padding-left: 2.2rem;
}
}
.directory {
font-weight: 500;
}
.link {
padding: 0.5rem 0;
}
.directory {
padding: 0.5rem 0;
}
.link,
.directory {
cursor: pointer;
text-decoration: none;
font-size: 1.6rem;
display: inline-block;
}
</style>

View File

@@ -28,10 +28,10 @@
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
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";
import {
faAngleDown,
@@ -46,11 +46,10 @@ const props = defineProps<{
hostOpaqueRef: XenApiHost["$ref"];
}>();
const { getByOpaqueRef } = useHostStore().subscribe();
const { getByOpaqueRef } = useHostCollection();
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
const { pool } = usePoolStore().subscribe();
const { pool } = usePoolCollection();
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
const uiStore = useUiStore();

View File

@@ -16,9 +16,9 @@
<script lang="ts" setup>
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
import { useHostStore } from "@/stores/host.store";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
const { records: hosts, isReady, hasError } = useHostCollection();
</script>
<style lang="postcss" scoped>

View File

@@ -28,10 +28,10 @@ import InfraHostList from "@/components/infra/InfraHostList.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { usePoolStore } from "@/stores/pool.store";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
const { isReady, hasError, pool } = usePoolStore().subscribe();
const { isReady, hasError, pool } = usePoolCollection();
</script>
<style lang="postcss" scoped>

View File

@@ -19,8 +19,8 @@
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
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";
@@ -29,7 +29,7 @@ const props = defineProps<{
vmOpaqueRef: XenApiVm["$ref"];
}>();
const { getByOpaqueRef } = useVmStore().subscribe();
const { getByOpaqueRef } = useVmCollection();
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
const rootElement = ref();
const isVisible = ref(false);

View File

@@ -11,8 +11,8 @@
<script lang="ts" setup>
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
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";
@@ -20,7 +20,7 @@ const props = defineProps<{
hostOpaqueRef?: XenApiHost["$ref"];
}>();
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
const { isReady, recordsByHostRef, hasError } = useVmCollection();
const vms = computed(() =>
recordsByHostRef.value.get(

View File

@@ -5,12 +5,12 @@
</template>
<script lang="ts" setup>
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { computed } from "vue";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import TitleBar from "@/components/TitleBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const { pool } = usePoolStore().subscribe();
const { pool } = usePoolCollection();
const name = computed(() => pool.value?.name_label ?? "...");
</script>

View File

@@ -33,7 +33,7 @@
<script lang="ts" setup>
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { usePoolStore } from "@/stores/pool.store";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
const { pool, isReady } = usePoolStore().subscribe();
const { pool, isReady } = usePoolCollection();
</script>

View File

@@ -37,12 +37,11 @@ import UiCard from "@/components/ui/UiCard.vue";
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { useVmMetricsCollection } from "@/composables/xen-api-collection/vm-metrics-collection.composable";
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";
import { useVmStore } from "@/stores/vm.store";
import { logicAnd } from "@vueuse/math";
import { computed } from "vue";
@@ -52,18 +51,16 @@ const {
hasError: hostStoreHasError,
isReady: isHostStoreReady,
runningHosts,
} = useHostStore().subscribe({
hostMetricsSubscription: useHostMetricsStore().subscribe(),
});
} = useHostCollection();
const {
hasError: vmStoreHasError,
isReady: isVmStoreReady,
records: vms,
} = useVmStore().subscribe();
} = useVmCollection();
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
useVmMetricsStore().subscribe();
useVmMetricsCollection();
const nPCpu = computed(() =>
runningHosts.value.reduce(

View File

@@ -1,21 +1,48 @@
<template>
<UiCard :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("cpu-usage") }}</UiCardTitle>
<UiCardTitle>
{{ $t("cpu-usage") }}
<template v-if="vmStatsCanBeExpired || hostStatsCanBeExpired" #right>
<UiSpinner v-tooltip="$t('fetching-fresh-data')" />
</template>
</UiCardTitle>
<HostsCpuUsage />
<VmsCpuUsage />
</UiCard>
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { computed, inject, type ComputedRef } from "vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import UiSpinner from "@/components/ui/UiSpinner.vue";
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const { hasError: hasVmError } = useVmCollection();
const { hasError: hasHostError } = useHostCollection();
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",
computed(() => [])
);
const hostStats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",
computed(() => [])
);
const vmStatsCanBeExpired = computed(() =>
vmStats.value.some((stat) => stat.canBeExpired)
);
const hostStatsCanBeExpired = computed(() =>
hostStats.value.some((stat) => stat.canBeExpired)
);
const hasError = computed(() => hasVmError.value || hasHostError.value);
</script>

View File

@@ -1,5 +1,6 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoData component in case of a data recovery error -->
<LinearChart
:data="data"

View File

@@ -1,22 +1,50 @@
<template>
<UiCard :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("ram-usage") }}</UiCardTitle>
<UiCardTitle>
{{ $t("ram-usage") }}
<template v-if="vmStatsCanBeExpired || hostStatsCanBeExpired" #right>
<UiSpinner v-tooltip="$t('fetching-fresh-data')" />
</template>
</UiCardTitle>
<HostsRamUsage />
<VmsRamUsage />
</UiCard>
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { computed, inject } from "vue";
import type { ComputedRef } from "vue";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import type { Stat } from "@/composables/fetch-stats.composable";
import UiSpinner from "@/components/ui/UiSpinner.vue";
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const { hasError: hasVmError } = useVmCollection();
const { hasError: hasHostError } = useHostCollection();
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",
computed(() => [])
);
const hostStats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",
computed(() => [])
);
const vmStatsCanBeExpired = computed(() =>
vmStats.value.some((stat) => stat.canBeExpired)
);
const hostStatsCanBeExpired = computed(() =>
hostStats.value.some((stat) => stat.canBeExpired)
);
const hasError = computed(() => hasVmError.value || hasHostError.value);
</script>

View File

@@ -26,22 +26,21 @@ import UiCard from "@/components/ui/UiCard.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiSeparator from "@/components/ui/UiSeparator.vue";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { computed } from "vue";
const {
isReady: isVmReady,
records: vms,
hasError: hasVmError,
runningVms,
} = useVmStore().subscribe();
} = useVmCollection();
const {
isReady: isHostMetricsReady,
records: hostMetrics,
hasError: hasHostMetricsError,
} = useHostMetricsStore().subscribe();
} = useHostMetricsCollection();
const hasError = computed(() => hasVmError.value || hasHostMetricsError.value);
@@ -55,5 +54,7 @@ const activeHostsCount = computed(
const totalVmsCount = computed(() => vms.value.length);
const activeVmsCount = computed(() => runningVms.value.length);
const activeVmsCount = computed(
() => vms.value.filter((vm) => vm.power_state === "Running").length
);
</script>

View File

@@ -23,11 +23,11 @@ import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useSrStore } from "@/stores/storage.store";
import { useSrCollection } from "@/composables/xen-api-collection/sr-collection.composable";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed } from "vue";
const { records: srs, isReady, hasError } = useSrStore().subscribe();
const { records: srs, isReady, hasError } = useSrCollection();
const data = computed<{
result: { id: string; label: string; value: number }[];

View File

@@ -0,0 +1,17 @@
<template>
<UiCard>
<UiCardTitle :count="pendingTasks.length">{{ $t("tasks") }}</UiCardTitle>
<TasksTable :pending-tasks="pendingTasks" />
</UiCard>
</template>
<script lang="ts" setup>
import TasksTable from "@/components/tasks/TasksTable.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
const { pendingTasks } = useTaskCollection();
</script>
<style lang="postcss" scoped></style>

View File

@@ -12,13 +12,13 @@
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { getAvgCpuUsage } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const { hasError } = useHostStore().subscribe();
const { hasError } = useHostCollection();
const stats = inject(
IK_HOST_STATS,

View File

@@ -1,5 +1,6 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: Display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"
@@ -11,9 +12,9 @@
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
@@ -28,7 +29,7 @@ const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const { records: hosts } = useHostStore().subscribe();
const { records: hosts } = useHostCollection();
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)

View File

@@ -12,13 +12,13 @@
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { getAvgCpuUsage } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const { hasError } = useVmStore().subscribe();
const { hasError } = useVmCollection();
const stats = inject(
IK_VM_STATS,

View File

@@ -10,15 +10,15 @@
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useHostStore } from "@/stores/host.store";
const { hasError } = useHostStore().subscribe();
const { hasError } = useHostCollection();
const stats = inject(
IK_HOST_STATS,

View File

@@ -1,5 +1,6 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"
@@ -16,10 +17,10 @@
<script lang="ts" setup>
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import { formatSize, getHostMemory } from "@/libs/utils";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import { formatSize } from "@/libs/utils";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
@@ -30,27 +31,22 @@ const LinearChart = defineAsyncComponent(
() => import("@/components/charts/LinearChart.vue")
);
const hostMetricsSubscription = useHostMetricsStore().subscribe();
const hostStore = useHostStore();
const { runningHosts } = hostStore.subscribe({ hostMetricsSubscription });
const { runningHosts } = useHostCollection();
const { getHostMemory } = useHostMetricsCollection();
const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const customMaxValue = computed(() =>
sumBy(
runningHosts.value,
(host) => getHostMemory(host, hostMetricsSubscription)?.size ?? 0
)
sumBy(runningHosts.value, (host) => getHostMemory(host)?.size ?? 0)
);
const currentData = computed(() => {
let size = 0,
usage = 0;
runningHosts.value.forEach((host) => {
const hostMemory = getHostMemory(host, hostMetricsSubscription);
const hostMemory = getHostMemory(host);
size += hostMemory?.size ?? 0;
usage += hostMemory?.usage ?? 0;
});

View File

@@ -12,13 +12,13 @@
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const { hasError } = useVmStore().subscribe();
const { hasError } = useVmCollection();
const stats = inject(
IK_VM_STATS,

View File

@@ -34,9 +34,9 @@
<script lang="ts" setup>
import RelativeTime from "@/components/RelativeTime.vue";
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { parseDateTime } from "@/libs/utils";
import type { XenApiTask } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { computed } from "vue";
const props = defineProps<{
@@ -44,7 +44,7 @@ const props = defineProps<{
task: XenApiTask;
}>();
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
const { getByOpaqueRef: getHost } = useHostCollection();
const createdAt = computed(() => parseDateTime(props.task.created));

View File

@@ -1,5 +1,5 @@
<template>
<UiTable class="tasks-table" :color="hasError ? 'error' : undefined">
<UiTable :color="hasError ? 'error' : undefined" class="tasks-table">
<thead>
<tr>
<th>{{ $t("name") }}</th>
@@ -20,6 +20,9 @@
<UiSpinner class="loader" />
</td>
</tr>
<tr v-else-if="!hasTasks">
<td class="no-tasks" colspan="5">{{ $t("no-tasks") }}</td>
</tr>
<template v-else>
<TaskRow
v-for="task in pendingTasks"
@@ -35,20 +38,35 @@
<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 UiTable from "@/components/ui/UiTable.vue";
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
import type { XenApiTask } from "@/libs/xen-api";
import { computed } from "vue";
defineProps<{
const props = defineProps<{
pendingTasks: XenApiTask[];
finishedTasks: XenApiTask[];
finishedTasks?: XenApiTask[];
}>();
const { hasError, isFetching } = useTaskStore().subscribe();
const { hasError, isFetching } = useTaskCollection();
const hasTasks = computed(
() => props.pendingTasks.length > 0 || (props.finishedTasks?.length ?? 0) > 0
);
</script>
<style lang="postcss" scoped>
.tasks-table {
width: 100%;
}
.no-tasks {
text-align: center;
color: var(--color-blue-scale-300);
font-style: italic;
}
td[colspan="5"] {
text-align: center;
}

View File

@@ -6,6 +6,7 @@
class="left"
>
<slot>{{ left }}</slot>
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
</component>
<component
:is="subtitle ? 'h6' : 'h5'"
@@ -18,11 +19,17 @@
</template>
<script lang="ts" setup>
defineProps<{
subtitle?: boolean;
left?: string;
right?: string;
}>();
import UiCounter from "@/components/ui/UiCounter.vue";
withDefaults(
defineProps<{
subtitle?: boolean;
left?: string;
right?: string;
count?: number;
}>(),
{ count: 0 }
);
</script>
<style lang="postcss" scoped>
@@ -55,6 +62,9 @@ defineProps<{
font-size: var(--section-title-left-size);
font-weight: var(--section-title-left-weight);
color: var(--section-title-left-color);
display: flex;
align-items: center;
gap: 2rem;
}
.right {
@@ -62,4 +72,8 @@ defineProps<{
font-weight: var(--section-title-right-weight);
color: var(--section-title-right-color);
}
.count {
font-size: 1.6rem;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<li class="ui-resource">
<UiIcon :icon="icon" class="icon" />
<div class="separator" />
<div class="label">{{ label }}</div>
<div class="count">{{ count }}</div>
</li>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineProps<{
icon: IconDefinition;
label: string;
count: string | number;
}>();
</script>
<style lang="postcss" scoped>
.ui-resource {
display: flex;
align-items: center;
}
.icon {
color: var(--color-extra-blue-base);
font-size: 3.2rem;
}
.separator {
height: 4.5rem;
width: 0;
border-left: 0.1rem solid var(--color-extra-blue-base);
background-color: var(--color-extra-blue-base);
margin: 0 1.5rem;
}
.label {
font-size: 1.6rem;
font-weight: 700;
}
.count {
font-size: 1.4rem;
font-weight: 400;
margin-left: 2rem;
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<ul class="ui-resources">
<slot />
</ul>
</template>
<style lang="postcss" scoped>
.ui-resources {
display: flex;
gap: 1rem 5.4rem;
flex-wrap: wrap;
}
</style>

View File

@@ -12,10 +12,9 @@
<script lang="ts" setup>
import MenuItem from "@/components/menu/MenuItem.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
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";
@@ -24,7 +23,7 @@ const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef } = useVmStore().subscribe();
const { getByOpaqueRef, isOperationPending } = useVmCollection();
const selectedVms = computed(() =>
props.selectedRefs
@@ -39,7 +38,7 @@ const areAllSelectedVmsHalted = computed(() =>
);
const areSomeSelectedVmsCloning = computed(() =>
selectedVms.value.some((vm) => isOperationsPending(vm, VM_OPERATION.CLONE))
selectedVms.value.some((vm) => isOperationPending(vm, VM_OPERATION.CLONE))
);
const handleCopy = async () => {

View File

@@ -35,11 +35,11 @@
<script lang="ts" setup>
import MenuItem from "@/components/menu/MenuItem.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
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";
@@ -51,7 +51,7 @@ const props = defineProps<{
}>();
const xenApi = useXenApiStore().getXapi();
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
const { getByOpaqueRef: getVm } = useVmCollection();
const {
open: openDeleteModal,
close: closeDeleteModal,

View File

@@ -0,0 +1,51 @@
<template>
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faDisplay"
>
{{ $t("export-vms") }}
</MenuItem>
<MenuItem
:icon="faCode"
@click="
exportVmsAsJsonFile(vms, `vms_${new Date().toISOString()}.json`)
"
>
{{ $t("export-table-to", { type: ".json" }) }}
</MenuItem>
<MenuItem
:icon="faFileCsv"
@click="exportVmsAsCsvFile(vms, `vms_${new Date().toISOString()}.csv`)"
>
{{ $t("export-table-to", { type: ".csv" }) }}
</MenuItem>
</template>
</MenuItem>
</template>
<script lang="ts" setup>
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { computed } from "vue";
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
import MenuItem from "@/components/menu/MenuItem.vue";
import {
faCode,
faDisplay,
faFileCsv,
faFileExport,
} from "@fortawesome/free-solid-svg-icons";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef: getVm } = useVmCollection();
const vms = computed(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
);
</script>

View File

@@ -95,13 +95,12 @@
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 { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
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,
@@ -121,12 +120,12 @@ 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 { getByOpaqueRef: getVm, isOperationPending } = useVmCollection();
const { records: hosts } = useHostCollection();
const { pool } = usePoolCollection();
const { isHostRunning } = useHostMetricsCollection();
const vms = computed<XenApiVm[]>(() =>
const vms = computed(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
);
@@ -150,7 +149,7 @@ const areVmsPaused = computed(() =>
);
const areOperationsPending = (operation: VM_OPERATION | VM_OPERATION[]) =>
vms.value.some((vm) => isOperationsPending(vm, operation));
vms.value.some((vm) => isOperationPending(vm, operation));
const areVmsBusyToStart = computed(() =>
areOperationsPending(VM_OPERATION.START)
@@ -180,9 +179,7 @@ const areVmsBusyToForceShutdown = computed(() =>
areOperationsPending(VM_OPERATION.HARD_SHUTDOWN)
);
const getHostState = (host: XenApiHost) =>
isHostRunning(host, hostMetricsSubscription)
? POWER_STATE.RUNNING
: POWER_STATE.HALTED;
isHostRunning(host) ? POWER_STATE.RUNNING : POWER_STATE.HALTED;
</script>
<style lang="postcss" scoped>

View File

@@ -20,8 +20,8 @@ import AppMenu from "@/components/menu/AppMenu.vue";
import TitleBar from "@/components/TitleBar.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useVmStore } from "@/stores/vm.store";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import type { XenApiVm } from "@/libs/xen-api";
import {
faAngleDown,
@@ -31,7 +31,7 @@ import {
import { computed } from "vue";
import { useRouter } from "vue-router";
const { getByUuid: getVmByUuid } = useVmStore().subscribe();
const { getByUuid: getVmByUuid } = useVmCollection();
const { currentRoute } = useRouter();
const vm = computed(() =>

View File

@@ -25,30 +25,8 @@
<MenuItem v-tooltip="$t('coming-soon')" :icon="faCamera">
{{ $t("snapshot") }}
</MenuItem>
<VmActionExportItem :vm-refs="selectedRefs" />
<VmActionDeleteItem :vm-refs="selectedRefs" />
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faDisplay"
>
{{ $t("export-vms") }}
</MenuItem>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faCode"
>
{{ $t("export-table-to", { type: ".json" }) }}
</MenuItem>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faFileCsv"
>
{{ $t("export-table-to", { type: ".csv" }) }}
</MenuItem>
</template>
</MenuItem>
</AppMenu>
</template>
@@ -57,6 +35,7 @@ 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 VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import { vTooltip } from "@/directives/tooltip.directive";
@@ -64,12 +43,8 @@ import type { XenApiVm } from "@/libs/xen-api";
import { useUiStore } from "@/stores/ui.store";
import {
faCamera,
faCode,
faDisplay,
faEdit,
faEllipsis,
faFileCsv,
faFileExport,
faPowerOff,
faRoute,
} from "@fortawesome/free-solid-svg-icons";

View File

@@ -10,23 +10,26 @@ import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
import { computed, type ComputedRef, onUnmounted, ref } from "vue";
export type Stat<T> = {
canBeExpired: boolean;
id: string;
name: string;
stats: T | undefined;
pausable: Pausable;
};
type GetStats<
export type GetStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
> = (
uuid: T["uuid"],
granularity: GRANULARITY
) => Promise<XapiStatsResponse<S>> | undefined;
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<S> | undefined> | undefined;
export type FetchedStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
> = {
register: (object: T) => void;
unregister: (object: T) => void;
@@ -37,10 +40,11 @@ export type FetchedStats<
export default function useFetchStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
>(getStats: GetStats<T, S>, granularity: GRANULARITY): FetchedStats<T, S> {
const stats = ref<Map<string, Stat<S>>>(new Map());
const timestamp = ref<number[]>([0, 0]);
const abortController = new AbortController();
const register = (object: T) => {
const mapKey = `${object.uuid}-${granularity}`;
@@ -49,13 +53,18 @@ export default function useFetchStats<
return;
}
const ignoreExpired = computed(() => !stats.value.has(mapKey));
const pausable = useTimeoutPoll(
async () => {
if (!stats.value.has(mapKey)) {
return;
}
const newStats = await getStats(object.uuid, granularity);
const newStats = (await getStats(
object.uuid,
granularity,
ignoreExpired.value,
{
abortSignal: abortController.signal,
}
)) as XapiStatsResponse<S>;
if (newStats === undefined) {
return;
@@ -69,6 +78,7 @@ export default function useFetchStats<
];
stats.value.get(mapKey)!.stats = newStats.stats;
stats.value.get(mapKey)!.canBeExpired = newStats.canBeExpired;
await promiseTimeout(newStats.interval * 1000);
},
0,
@@ -76,6 +86,7 @@ export default function useFetchStats<
);
stats.value.set(mapKey, {
canBeExpired: false,
id: object.uuid,
name: object.name_label,
stats: undefined,
@@ -90,13 +101,14 @@ export default function useFetchStats<
};
onUnmounted(() => {
abortController.abort();
stats.value.forEach((stat) => stat.pausable.pause());
});
return {
register,
unregister,
stats: computed<Stat<S>[]>(() => Array.from(stats.value.values())),
stats: computed(() => Array.from(stats.value.values()) as Stat<S>[]),
timestampStart: computed(() => timestamp.value[0]),
timestampEnd: computed(() => timestamp.value[1]),
};

View File

@@ -0,0 +1,63 @@
import type { RawObjectType } from "@/libs/xen-api";
import { getXenApiCollection } from "@/libs/xen-api-collection";
import type {
RawTypeToRecord,
XenApiBaseCollection,
XenApiCollectionManager,
} from "@/types/xen-api-collection";
import { tryOnUnmounted } from "@vueuse/core";
import { computed } from "vue";
export const useXenApiCollection = <
ObjectType extends RawObjectType,
Record extends RawTypeToRecord<ObjectType>,
Immediate extends boolean,
>(
type: ObjectType,
options?: { immediate?: Immediate }
): XenApiBaseCollection<Record, Immediate> => {
const baseCollection = getXenApiCollection(type);
const isDeferred = options?.immediate === false;
const id = Symbol();
const collection = {
records: baseCollection.records,
isFetching: baseCollection.isFetching,
isReloading: baseCollection.isReloading,
isReady: baseCollection.isReady,
hasError: baseCollection.hasError,
hasUuid: baseCollection.hasUuid.bind(baseCollection),
getByUuid: baseCollection.getByUuid.bind(baseCollection),
getByOpaqueRef: baseCollection.getByOpaqueRef.bind(baseCollection),
};
tryOnUnmounted(() => baseCollection.unsubscribe(id));
if (isDeferred) {
return {
...collection,
start: () => baseCollection.subscribe(id),
isStarted: computed(() => baseCollection.hasSubscriptions.value),
} as XenApiBaseCollection<Record, false>;
}
baseCollection.subscribe(id);
return collection as XenApiBaseCollection<Record, Immediate>;
};
export const useXenApiCollectionManager = <
ObjectType extends RawObjectType,
Record extends RawTypeToRecord<ObjectType>,
>(
type: ObjectType
): XenApiCollectionManager<Record> => {
const collection = getXenApiCollection(type);
return {
hasSubscriptions: collection.hasSubscriptions,
add: collection.add.bind(collection),
remove: collection.remove.bind(collection),
update: collection.update.bind(collection),
};
};

View File

@@ -0,0 +1,3 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
export const useConsoleCollection = () => useXenApiCollection("console");

View File

@@ -0,0 +1,45 @@
import type { GetStats } from "@/composables/fetch-stats.composable";
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import type { XenApiHost } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import { computed } from "vue";
export const useHostCollection = () => {
const collection = useXenApiCollection("host");
const hostMetricsCollection = useHostMetricsCollection();
return {
...collection,
runningHosts: computed(() =>
collection.records.value.filter((host) =>
hostMetricsCollection.isHostRunning(host)
)
),
getStats: ((
hostUuid,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const xenApiStore = useXenApiStore();
const host = collection.getByUuid(hostUuid);
if (host === undefined) {
throw new Error(`Host ${hostUuid} could not be found.`);
}
const xapiStats = xenApiStore.isConnected
? xenApiStore.getXapiStats()
: undefined;
return xapiStats?._getAndUpdateStats({
abortSignal,
host,
ignoreExpired,
uuid: host.uuid,
granularity,
});
}) as GetStats<XenApiHost>,
};
};

View File

@@ -0,0 +1,24 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import type { XenApiHost } from "@/libs/xen-api";
export const useHostMetricsCollection = () => {
const collection = useXenApiCollection("host_metrics");
return {
...collection,
getHostMemory: (host: XenApiHost) => {
const hostMetrics = collection.getByOpaqueRef(host.metrics);
if (hostMetrics !== undefined) {
const total = +hostMetrics.memory_total;
return {
usage: total - +hostMetrics.memory_free,
size: total,
};
}
},
isHostRunning: (host: XenApiHost) => {
return collection.getByOpaqueRef(host.metrics)?.live === true;
},
};
};

View File

@@ -0,0 +1,14 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import type { XenApiPool } from "@/libs/xen-api";
import { computed } from "vue";
export const usePoolCollection = () => {
const poolCollection = useXenApiCollection("pool");
return {
...poolCollection,
pool: computed<XenApiPool | undefined>(
() => poolCollection.records.value[0]
),
};
};

View File

@@ -0,0 +1,3 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
export const useSrCollection = () => useXenApiCollection("SR");

View File

@@ -0,0 +1,41 @@
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionSorter from "@/composables/collection-sorter.composable";
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import useFilteredCollection from "@/composables/filtered-collection.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import type { XenApiTask } from "@/libs/xen-api";
export const useTaskCollection = () => {
const collection = useXenApiCollection("task");
const { compareFn } = useCollectionSorter<XenApiTask>({
initialSorts: ["-created"],
});
const { predicate } = useCollectionFilter({
initialFilters: [
"!name_label:|(SR.scan host.call_plugin)",
"status:pending",
],
});
const sortedTasks = useSortedCollection(collection.records, compareFn);
return {
...collection,
pendingTasks: useFilteredCollection<XenApiTask>(sortedTasks, predicate),
finishedTasks: useArrayRemovedItemsHistory(
sortedTasks,
(task) => task.uuid,
{
limit: 50,
onRemove: (tasks) =>
tasks.map((task) => ({
...task,
finished: new Date().toISOString(),
})),
}
),
};
};

View File

@@ -0,0 +1,83 @@
import type { GetStats } from "@/composables/fetch-stats.composable";
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { sortRecordsByNameLabel } from "@/libs/utils";
import type { VmStats } from "@/libs/xapi-stats";
import {
POWER_STATE,
VM_OPERATION,
type XenApiHost,
type XenApiVm,
} from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import { castArray } from "lodash-es";
import { computed } from "vue";
export const useVmCollection = () => {
const collection = useXenApiCollection("VM");
const hostCollection = useHostCollection();
const xenApiStore = useXenApiStore();
const records = computed(() =>
collection.records.value
.filter(
(vm) => !vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain
)
.sort(sortRecordsByNameLabel)
);
return {
...collection,
records,
isOperationPending: (
vm: XenApiVm,
operations: VM_OPERATION[] | VM_OPERATION
) => {
const currentOperations = Object.values(vm.current_operations);
return castArray(operations).some((operation) =>
currentOperations.includes(operation)
);
},
runningVms: computed(() =>
records.value.filter((vm) => vm.power_state === POWER_STATE.RUNNING)
),
recordsByHostRef: computed(() => {
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
records.value.forEach((vm) => {
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
vmsByHostOpaqueRef.set(vm.resident_on, []);
}
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
});
return vmsByHostOpaqueRef;
}),
getStats: ((id, granularity, ignoreExpired = false, { abortSignal }) => {
if (!xenApiStore.isConnected) {
return undefined;
}
const vm = collection.getByUuid(id);
if (vm === undefined) {
throw new Error(`VM ${id} could not be found.`);
}
const host = hostCollection.getByOpaqueRef(vm.resident_on);
if (host === undefined) {
throw new Error(`VM ${id} is halted or host could not be found.`);
}
return xenApiStore.getXapiStats()._getAndUpdateStats<VmStats>({
abortSignal,
host,
ignoreExpired,
uuid: vm.uuid,
granularity,
});
}) as GetStats<XenApiVm>,
};
};

View File

@@ -0,0 +1,3 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
export const useVmMetricsCollection = () => useXenApiCollection("VM_metrics");

View File

@@ -1,18 +1,14 @@
import type {
RawXenApiRecord,
XenApiHost,
XenApiHostMetrics,
XenApiRecord,
XenApiVm,
VM_OPERATION,
RawObjectType,
} from "@/libs/xen-api";
import type { Filter } from "@/types/filter";
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";
import humanFormat from "human-format";
import { castArray, find, forEach, round, size, sum } from "lodash-es";
import { find, forEach, round, size, sum } from "lodash-es";
export function sortRecordsByNameLabel(
record1: { name_label: string },
@@ -21,14 +17,7 @@ export function sortRecordsByNameLabel(
const label1 = record1.name_label.toLocaleLowerCase();
const label2 = record2.name_label.toLocaleLowerCase();
switch (true) {
case label1 < label2:
return -1;
case label1 > label2:
return 1;
default:
return 0;
}
return label1.localeCompare(label2);
}
export function escapeRegExp(string: string) {
@@ -114,29 +103,7 @@ export function getStatsLength(stats?: object | any[]) {
return size(find(stats, (stat) => stat != null));
}
export function isHostRunning(
host: XenApiHost,
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
) {
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
}
export function getHostMemory(
host: XenApiHost,
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
) {
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
if (hostMetrics !== undefined) {
const total = +hostMetrics.memory_total;
return {
usage: total - +hostMetrics.memory_free,
size: total,
};
}
}
export const buildXoObject = <T extends XenApiRecord<string>>(
export const buildXoObject = <T extends XenApiRecord<RawObjectType>>(
record: RawXenApiRecord<T>,
params: { opaqueRef: T["$ref"] }
) => {
@@ -182,13 +149,3 @@ export function parseRamUsage(
export const getFirst = <T>(value: T | T[]): T | undefined =>
Array.isArray(value) ? value[0] : value;
export const isOperationsPending = (
obj: XenApiVm,
operations: VM_OPERATION[] | VM_OPERATION
) => {
const currentOperations = Object.values(obj.current_operations);
return castArray(operations).some((operation) =>
currentOperations.includes(operation)
);
};

View File

@@ -0,0 +1,41 @@
import { saveAs } from "file-saver";
import type { XenApiVm } from "@/libs/xen-api";
function stringifyCsvValue(value: any) {
let res = "";
if (Array.isArray(value)) {
res = value.join(";");
} else if (typeof value === "object") {
res = JSON.stringify(value);
} else {
res = String(value);
}
return `"${res.replace(/"/g, '""')}"`;
}
export function exportVmsAsCsvFile(vms: XenApiVm[], fileName: string) {
const csvHeaders = Object.keys(vms[0]);
const csvRows = vms.map((vm) =>
csvHeaders.map((header) => stringifyCsvValue(vm[header as keyof XenApiVm]))
);
saveAs(
new Blob(
[[csvHeaders, ...csvRows].map((row) => row.join(",")).join("\n")],
{
type: "text/csv;charset=utf-8",
}
),
fileName
);
}
export function exportVmsAsJsonFile(vms: XenApiVm[], fileName: string) {
saveAs(
new Blob([JSON.stringify(vms, null, 2)], {
type: "application/json",
}),
fileName
);
}

View File

@@ -295,18 +295,22 @@ export type HostStats = {
};
export type XapiStatsResponse<T> = {
canBeExpired: boolean;
endTimestamp: number;
interval: number;
stats: T;
};
type StatsByObject = {
[uuid: string]: {
[step: string]: XapiStatsResponse<HostStats | VmStats>;
};
};
export default class XapiStats {
#xapi;
#statsByObject: {
[uuid: string]: {
[step: string]: XapiStatsResponse<HostStats | any>;
};
} = {};
#statsByObject: StatsByObject = {};
#cachedStatsByObject: StatsByObject = {};
constructor(xapi: XenApi) {
this.#xapi = xapi;
}
@@ -314,7 +318,12 @@ export default class XapiStats {
// Execute one http request on a XenServer for get stats
// Return stats (Json format) or throws got exception
@limitConcurrency(3)
async _getJson(host: XenApiHost, timestamp: any, step: any) {
async _getJson(
host: XenApiHost,
timestamp: number,
step: RRD_STEP,
{ abortSignal }: { abortSignal?: AbortSignal } = {}
) {
const resp = await this.#xapi.getResource("/rrd_updates", {
host,
query: {
@@ -324,13 +333,23 @@ export default class XapiStats {
json: "true",
start: timestamp,
},
abortSignal,
});
return JSON5.parse(await resp.text());
}
// To avoid multiple requests, we keep a cache for the stats and
// only return it if we not exceed a step
#getCachedStats(uuid: any, step: any, currentTimeStamp: any) {
#getCachedStats(
uuid: string,
step: RRD_STEP,
currentTimeStamp: number,
ignoreExpired = false
) {
if (ignoreExpired) {
return this.#cachedStatsByObject[uuid]?.[step];
}
const statsByObject = this.#statsByObject;
const stats = statsByObject[uuid]?.[step];
@@ -347,12 +366,16 @@ export default class XapiStats {
}
@synchronized.withKey(({ host }: { host: XenApiHost }) => host.uuid)
async _getAndUpdateStats({
async _getAndUpdateStats<T extends VmStats | HostStats>({
abortSignal,
host,
ignoreExpired = false,
uuid,
granularity,
}: {
abortSignal?: AbortSignal;
host: XenApiHost;
ignoreExpired?: boolean;
uuid: any;
granularity: GRANULARITY;
}) {
@@ -367,7 +390,13 @@ export default class XapiStats {
}
const currentTimeStamp = Math.floor(new Date().getTime() / 1000);
const stats = this.#getCachedStats(uuid, step, currentTimeStamp);
const stats = this.#getCachedStats(
uuid,
step,
currentTimeStamp,
ignoreExpired
) as XapiStatsResponse<T>;
if (stats !== undefined) {
return stats;
}
@@ -376,75 +405,113 @@ export default class XapiStats {
// To avoid crossing over the boundary, we ask for one less step
const optimumTimestamp = currentTimeStamp - maxDuration + step;
const json = await this._getJson(host, optimumTimestamp, step);
const actualStep = json.meta.step as number;
try {
const json = await this._getJson(host, optimumTimestamp, step, {
abortSignal,
});
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
// but this implementation requires it in the other direction
json.data.reverse();
json.meta.legend.forEach((legend: any, index: number) => {
const [, type, uuid, metricType] = /^AVERAGE:([^:]+):(.+):(.+)$/.exec(
legend
) as any;
const actualStep = json.meta.step as number;
const metrics = STATS[type] as any;
if (metrics === undefined) {
return;
}
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
// but this implementation requires it in the other direction
json.data.reverse();
json.meta.legend.forEach((legend: any, index: number) => {
const [, type, uuid, metricType] = /^AVERAGE:([^:]+):(.+):(.+)$/.exec(
legend
) as any;
const { metric, testResult } = findMetric(metrics, metricType) as any;
if (metric === undefined) {
return;
}
const xoObjectStats = createGetProperty(this.#statsByObject, uuid, {});
let stepStats = xoObjectStats[actualStep];
if (
stepStats === undefined ||
stepStats.endTimestamp !== json.meta.end
) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
interval: actualStep,
};
}
const path =
metric.getPath !== undefined
? metric.getPath(testResult)
: [findKey(metrics, metric)];
const lastKey = path.length - 1;
let metricStats = createGetProperty(stepStats, "stats", {});
path.forEach((property: any, key: number) => {
if (key === lastKey) {
metricStats[property] = computeValues(
json.data,
index,
metric.transformValue
);
const metrics = STATS[type] as any;
if (metrics === undefined) {
return;
}
metricStats = createGetProperty(metricStats, property, {});
const { metric, testResult } = findMetric(metrics, metricType) as any;
if (metric === undefined) {
return;
}
const xoObjectStats = createGetProperty(
this.#statsByObject,
uuid,
{}
);
const cacheXoObjectStats = createGetProperty(
this.#cachedStatsByObject,
uuid,
{}
);
let stepStats = xoObjectStats[actualStep];
let cacheStepStats = cacheXoObjectStats[actualStep];
if (
stepStats === undefined ||
stepStats.endTimestamp !== json.meta.end
) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: false,
};
cacheStepStats = cacheXoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: true,
};
}
const path =
metric.getPath !== undefined
? metric.getPath(testResult)
: [findKey(metrics, metric)];
const lastKey = path.length - 1;
let metricStats = createGetProperty(stepStats, "stats", {});
let cacheMetricStats = createGetProperty(cacheStepStats, "stats", {});
path.forEach((property: any, key: number) => {
if (key === lastKey) {
metricStats[property] = computeValues(
json.data,
index,
metric.transformValue
);
cacheMetricStats[property] = computeValues(
json.data,
index,
metric.transformValue
);
return;
}
metricStats = createGetProperty(metricStats, property, {});
cacheMetricStats = createGetProperty(
cacheMetricStats,
property,
{}
);
});
});
});
}
if (actualStep !== step) {
throw new FaultyGranularity(
`Unable to get the true granularity: ${actualStep}`
);
}
return (
this.#statsByObject[uuid]?.[step] ?? {
endTimestamp: currentTimeStamp,
interval: step,
stats: {},
}
);
if (actualStep !== step) {
throw new FaultyGranularity(
`Unable to get the true granularity: ${actualStep}`
);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
throw error;
}
return (this.#statsByObject[uuid]?.[step] ?? {
endTimestamp: currentTimeStamp,
interval: step,
stats: {},
}) as XapiStatsResponse<T>;
}
}

View File

@@ -0,0 +1,112 @@
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import type { RawTypeToRecord } from "@/types/xen-api-collection";
import { whenever } from "@vueuse/core";
import { computed, reactive } from "vue";
const collections = new Map<RawObjectType, XenApiCollection<any>>();
export const getXenApiCollection = <
ObjectType extends RawObjectType,
Record extends RawTypeToRecord<ObjectType> = RawTypeToRecord<ObjectType>,
>(
type: ObjectType
) => {
if (!collections.has(type)) {
collections.set(type, new XenApiCollection(type));
}
return collections.get(type)! as XenApiCollection<Record>;
};
export class XenApiCollection<Record extends XenApiRecord<any>> {
private state = reactive({
isReady: false,
isFetching: false,
lastError: undefined as string | undefined,
subscriptions: new Set<symbol>(),
recordsByOpaqueRef: new Map<Record["$ref"], Record>(),
recordsByUuid: new Map<Record["uuid"], Record>(),
});
isReady = computed(() => this.state.isReady);
isFetching = computed(() => this.state.isFetching);
isReloading = computed(() => this.state.isReady && this.state.isFetching);
lastError = computed(() => this.state.lastError);
hasError = computed(() => this.state.lastError !== undefined);
hasSubscriptions = computed(() => this.state.subscriptions.size > 0);
records = computed(() => Array.from(this.state.recordsByOpaqueRef.values()));
subscribe(id: symbol) {
this.state.subscriptions.add(id);
}
unsubscribe(id: symbol) {
this.state.subscriptions.delete(id);
}
constructor(private type: RawObjectType) {
const xenApiStore = useXenApiStore();
whenever(
() => xenApiStore.isConnected && this.hasSubscriptions.value,
() => this.fetchAll(xenApiStore)
);
}
getByOpaqueRef(opaqueRef: Record["$ref"]) {
return this.state.recordsByOpaqueRef.get(opaqueRef);
}
getByUuid(uuid: Record["uuid"]) {
return this.state.recordsByUuid.get(uuid);
}
hasUuid(uuid: Record["uuid"]) {
return this.state.recordsByUuid.has(uuid);
}
add(record: Record) {
this.state.recordsByOpaqueRef.set(record.$ref, record);
this.state.recordsByUuid.set(record.uuid, record);
}
update(record: Record) {
this.state.recordsByOpaqueRef.set(record.$ref, record);
this.state.recordsByUuid.set(record.uuid, record);
}
remove(opaqueRef: Record["$ref"]) {
if (!this.state.recordsByOpaqueRef.has(opaqueRef)) {
return;
}
const record = this.state.recordsByOpaqueRef.get(opaqueRef)!;
this.state.recordsByOpaqueRef.delete(opaqueRef);
this.state.recordsByUuid.delete(record.uuid);
}
private async fetchAll(xenApiStore: ReturnType<typeof useXenApiStore>) {
try {
this.state.isFetching = true;
this.state.lastError = undefined;
const records = await xenApiStore
.getXapi()
.loadRecords<any, Record>(this.type);
this.state.recordsByOpaqueRef.clear();
this.state.recordsByUuid.clear();
records.forEach((record) => this.add(record));
this.state.isReady = true;
} catch {
this.state.lastError = `[${this.type}] Failed to fetch records`;
} finally {
this.state.isFetching = false;
}
}
}

View File

@@ -1,4 +1,5 @@
import { buildXoObject, parseDateTime } from "@/libs/utils";
import type { RawTypeToRecord } from "@/types/xen-api-collection";
import { JSONRPCClient } from "json-rpc-2.0";
import { castArray } from "lodash-es";
@@ -90,14 +91,17 @@ export enum VM_OPERATION {
declare const __brand: unique symbol;
export interface XenApiRecord<Name extends string> {
export interface XenApiRecord<Name extends RawObjectType> {
$ref: string & { [__brand]: `${Name}Ref` };
uuid: string & { [__brand]: `${Name}Uuid` };
}
export type RawXenApiRecord<T extends XenApiRecord<string>> = Omit<T, "$ref">;
export type RawXenApiRecord<T extends XenApiRecord<RawObjectType>> = Omit<
T,
"$ref"
>;
export interface XenApiPool extends XenApiRecord<"Pool"> {
export interface XenApiPool extends XenApiRecord<"pool"> {
cpu_info: {
cpu_count: string;
};
@@ -105,7 +109,7 @@ export interface XenApiPool extends XenApiRecord<"Pool"> {
name_label: string;
}
export interface XenApiHost extends XenApiRecord<"Host"> {
export interface XenApiHost extends XenApiRecord<"host"> {
address: string;
name_label: string;
metrics: XenApiHostMetrics["$ref"];
@@ -114,13 +118,13 @@ export interface XenApiHost extends XenApiRecord<"Host"> {
software_version: { product_version: string };
}
export interface XenApiSr extends XenApiRecord<"Sr"> {
export interface XenApiSr extends XenApiRecord<"SR"> {
name_label: string;
physical_size: number;
physical_utilisation: number;
}
export interface XenApiVm extends XenApiRecord<"Vm"> {
export interface XenApiVm extends XenApiRecord<"VM"> {
current_operations: Record<string, VM_OPERATION>;
guest_metrics: string;
metrics: XenApiVmMetrics["$ref"];
@@ -135,24 +139,24 @@ export interface XenApiVm extends XenApiRecord<"Vm"> {
VCPUs_at_startup: number;
}
export interface XenApiConsole extends XenApiRecord<"Console"> {
export interface XenApiConsole extends XenApiRecord<"console"> {
protocol: string;
location: string;
}
export interface XenApiHostMetrics extends XenApiRecord<"HostMetrics"> {
export interface XenApiHostMetrics extends XenApiRecord<"host_metrics"> {
live: boolean;
memory_free: number;
memory_total: number;
}
export interface XenApiVmMetrics extends XenApiRecord<"VmMetrics"> {
export interface XenApiVmMetrics extends XenApiRecord<"VM_metrics"> {
VCPUs_number: number;
}
export type XenApiVmGuestMetrics = XenApiRecord<"VmGuestMetrics">;
export type XenApiVmGuestMetrics = XenApiRecord<"VM_guest_metrics">;
export interface XenApiTask extends XenApiRecord<"Task"> {
export interface XenApiTask extends XenApiRecord<"task"> {
name_label: string;
resident_on: XenApiHost["$ref"];
created: string;
@@ -161,17 +165,22 @@ export interface XenApiTask extends XenApiRecord<"Task"> {
progress: number;
}
export interface XenApiMessage extends XenApiRecord<"Message"> {
export interface XenApiMessage<T extends RawObjectType = RawObjectType>
extends XenApiRecord<"message"> {
body: string;
cls: T;
name: string;
cls: RawObjectType;
obj_uuid: RawTypeToRecord<T>["uuid"];
priority: number;
timestamp: string;
}
type WatchCallbackResult = {
id: string;
class: ObjectType;
operation: "add" | "mod" | "del";
ref: XenApiRecord<string>["$ref"];
snapshot: RawXenApiRecord<XenApiRecord<string>>;
ref: XenApiRecord<RawObjectType>["$ref"];
snapshot: RawXenApiRecord<XenApiRecord<RawObjectType>>;
};
type WatchCallback = (results: WatchCallbackResult[]) => void;
@@ -266,7 +275,11 @@ export default class XenApi {
async getResource(
pathname: string,
{ host, query }: { host: XenApiHost; query: any }
{
abortSignal,
host,
query,
}: { abortSignal?: AbortSignal; host: XenApiHost; query: any }
) {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
@@ -277,19 +290,20 @@ export default class XenApi {
session_id: this.#sessionId,
}).toString();
return fetch(url);
return fetch(url, { signal: abortSignal });
}
async loadRecords<T extends XenApiRecord<string>>(
type: RawObjectType
): Promise<T[]> {
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
async loadRecords<
T extends RawObjectType,
R extends RawTypeToRecord<T> = RawTypeToRecord<T>,
>(type: T): Promise<R[]> {
const result = await this.#call<{ [key: string]: R }>(
`${type}.get_all_records`,
[this.sessionId]
);
return Object.entries(result).map(([opaqueRef, record]) =>
buildXoObject(record, { opaqueRef: opaqueRef as T["$ref"] })
buildXoObject(record, { opaqueRef: opaqueRef as R["$ref"] })
);
}

View File

@@ -18,6 +18,7 @@
"community": "Community",
"community-name": "{name} community",
"console": "Console",
"console-unavailable": "Console unavailable",
"copy": "Copy",
"cpu-provisioning": "CPU provisioning",
"cpu-usage": "CPU usage",
@@ -32,10 +33,11 @@
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"edit-config": "Edit config",
"error-no-data": "Error, can't collect data.",
"error-occured": "An error has occurred",
"error-occurred": "An error has occurred",
"export": "Export",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
"fetching-fresh-data": "Fetching fresh data",
"filter": {
"comparison": {
"contains": "Contains",
@@ -76,8 +78,11 @@
"news": "News",
"news-name": "{name} news",
"new-features-are-coming": "New features are coming soon!",
"no-tasks": "No tasks",
"not-found": "Not found",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"open-in-new-window": "Open in new window",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
@@ -86,6 +91,7 @@
"please-confirm": "Please confirm",
"pool-cpu-usage": "Pool CPU Usage",
"pool-ram-usage": "Pool RAM Usage",
"power-on-for-console": "Power on your VM to access its console",
"power-state": "Power state",
"property": "Property",
"ram-usage": "RAM usage",
@@ -109,7 +115,6 @@
"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",
@@ -126,7 +131,6 @@
"system": "System",
"task": {
"estimated-end": "Estimated end",
"page-title": "Tasks | (1) Tasks | ({n}) Tasks",
"progress": "Progress",
"started": "Started"
},

View File

@@ -18,6 +18,7 @@
"community": "Communauté",
"community-name": "Communauté {name}",
"console": "Console",
"console-unavailable": "Console indisponible",
"copy": "Copier",
"cpu-provisioning": "Provisionnement CPU",
"cpu-usage": "Utilisation CPU",
@@ -32,10 +33,11 @@
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
"edit-config": "Modifier config",
"error-no-data": "Erreur, impossible de collecter les données.",
"error-occured": "Une erreur est survenue",
"error-occurred": "Une erreur est survenue",
"export": "Exporter",
"export-table-to": "Exporter le tableau en {type}",
"export-vms": "Exporter les VMs",
"fetching-fresh-data": "Récupération de données à jour",
"filter": {
"comparison": {
"contains": "Contient",
@@ -76,8 +78,11 @@
"news": "Actualités",
"news-name": "Actualités {name}",
"new-features-are-coming": "De nouvelles fonctionnalités arrivent bientôt !",
"no-tasks": "Aucune tâche",
"not-found": "Non trouvé",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
"or": "Ou",
"page-not-found": "Cette page est introuvable…",
"password": "Mot de passe",
@@ -86,6 +91,7 @@
"please-confirm": "Veuillez confirmer",
"pool-cpu-usage": "Utilisation CPU du Pool",
"pool-ram-usage": "Utilisation RAM du Pool",
"power-on-for-console": "Allumez votre VM pour accéder à sa console",
"power-state": "État d'alimentation",
"property": "Propriété",
"ram-usage": "Utilisation de la RAM",
@@ -109,7 +115,6 @@
"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",
@@ -126,7 +131,6 @@
"system": "Système",
"task": {
"estimated-end": "Fin estimée",
"page-title": "Tâches | (1) Tâches | ({n}) Tâches",
"progress": "Progression",
"started": "Démarré"
},

View File

@@ -33,7 +33,7 @@ const router = createRouter({
},
{
path: "/:pathMatch(.*)*",
name: "notFound",
name: "not-found",
component: () => import("@/views/PageNotFoundView.vue"),
},
],

View File

@@ -1,21 +1,21 @@
import type { RouteRecordRaw } from "vue-router";
const componentLoaders = import.meta.glob("@/stories/*.story.vue");
const docLoaders = import.meta.glob("@/stories/*.story.md", { as: "raw" });
const componentLoaders = import.meta.glob("@/stories/**/*.story.vue");
const docLoaders = import.meta.glob("@/stories/**/*.story.md", { as: "raw" });
const children: RouteRecordRaw[] = Object.entries(componentLoaders).map(
([path, componentLoader]) => {
const basename = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
const basePath = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
const docPath = path.replace(/\.vue$/, ".md");
const routeName = `story-${basename}`;
const routeName = `story-${basePath}`;
return {
name: routeName,
path: basename,
path: basePath,
component: componentLoader,
meta: {
isStory: true,
storyTitle: basenameToStoryTitle(basename),
storyTitle: basePathToStoryTitle(basePath),
storyMdLoader: docLoaders[docPath],
},
};
@@ -46,8 +46,10 @@ export default {
* Basename: `my-component`
* Page title: `My Component`
*/
function basenameToStoryTitle(basename: string) {
return basename
function basePathToStoryTitle(basePath: string) {
return basePath
.split("/")
.pop()!
.split("-")
.map((s) => `${s.charAt(0).toUpperCase()}${s.substring(1)}`)
.join(" ");

View File

@@ -1,31 +0,0 @@
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

@@ -1,6 +0,0 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useConsoleStore = defineStore("console", () =>
useXapiCollectionStore().get("console")
);

View File

@@ -1,6 +0,0 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useHostMetricsStore = defineStore("host-metrics", () =>
useXapiCollectionStore().get("host_metrics")
);

View File

@@ -1,79 +0,0 @@
import { isHostRunning, sortRecordsByNameLabel } from "@/libs/utils";
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 { Subscription } from "@/types/xapi-collection";
import { createSubscribe } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type GetStatsExtension = {
getStats: (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY
) => Promise<XapiStatsResponse<any>> | undefined;
};
type RunningHostsExtension = [
{ runningHosts: ComputedRef<XenApiHost[]> },
{ hostMetricsSubscription: Subscription<XenApiHostMetrics, any> }
];
type Extensions = [GetStatsExtension, RunningHostsExtension];
export const useHostStore = defineStore("host", () => {
const xenApiStore = useXenApiStore();
const hostCollection = useXapiCollectionStore().get("host");
hostCollection.setSort(sortRecordsByNameLabel);
const subscribe = createSubscribe<XenApiHost, Extensions>((options) => {
const originalSubscription = hostCollection.subscribe(options);
const getStats = (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY
) => {
const host = originalSubscription.getByUuid(hostUuid);
if (host === undefined) {
throw new Error(`Host ${hostUuid} could not be found.`);
}
const xapiStats = xenApiStore.isConnected
? xenApiStore.getXapiStats()
: undefined;
return xapiStats?._getAndUpdateStats({
host,
uuid: host.uuid,
granularity,
});
};
const extendedSubscription = {
getStats,
};
const hostMetricsSubscription = options?.hostMetricsSubscription;
const runningHostsSubscription = hostMetricsSubscription !== undefined && {
runningHosts: computed(() =>
originalSubscription.records.value.filter((host) =>
isHostRunning(host, hostMetricsSubscription)
)
),
};
return {
...originalSubscription,
...extendedSubscription,
...runningHostsSubscription,
};
});
return {
...hostCollection,
subscribe,
};
});

View File

@@ -0,0 +1,92 @@
import { useTitle } from "@vueuse/core";
import { defineStore } from "pinia";
import {
computed,
type MaybeRefOrGetter,
onBeforeUnmount,
reactive,
toRef,
watch,
} from "vue";
const PAGE_TITLE_SUFFIX = "XO Lite";
interface PageTitleConfig {
object: { name_label: string } | undefined;
title: string | undefined;
count: number | undefined;
}
export const usePageTitleStore = defineStore("page-title", () => {
const pageTitleConfig = reactive<PageTitleConfig>({
count: undefined,
title: undefined,
object: undefined,
});
const generatedPageTitle = computed(() => {
const { object, title, count } = pageTitleConfig;
const parts = [];
if (count !== undefined && count > 0) {
parts.push(`(${count})`);
}
if (title !== undefined && object !== undefined) {
parts.push(`${title} - ${object.name_label}`);
} else if (title !== undefined) {
parts.push(title);
} else if (object !== undefined) {
parts.push(object.name_label);
}
if (parts.length === 0) {
return undefined;
}
return parts.join(" ");
});
useTitle(generatedPageTitle, {
titleTemplate: computed(() =>
generatedPageTitle.value === undefined
? PAGE_TITLE_SUFFIX
: `%s - ${PAGE_TITLE_SUFFIX}`
),
});
const setPageTitleConfig = <T extends keyof PageTitleConfig>(
configKey: T,
value: MaybeRefOrGetter<PageTitleConfig[T]>
) => {
const stop = watch(
toRef(value),
(newValue) =>
(pageTitleConfig[configKey] = newValue as PageTitleConfig[T]),
{
immediate: true,
}
);
onBeforeUnmount(() => {
stop();
pageTitleConfig[configKey] = undefined;
});
};
const setObject = (
object: MaybeRefOrGetter<{ name_label: string } | undefined>
) => setPageTitleConfig("object", object);
const setTitle = (title: MaybeRefOrGetter<string | undefined>) =>
setPageTitleConfig("title", title);
const setCount = (count: MaybeRefOrGetter<number | undefined>) =>
setPageTitleConfig("count", count);
return {
setObject,
setTitle,
setCount,
};
});

View File

@@ -1,34 +0,0 @@
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, 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 = createSubscribe<XenApiPool, Extensions>((options) => {
const originalSubscription = poolCollection.subscribe(options);
const extendedSubscription = {
pool: computed(() => getFirst(originalSubscription.records.value)),
};
return {
...originalSubscription,
...extendedSubscription,
};
});
return {
...poolCollection,
subscribe,
};
});

View File

@@ -1,6 +0,0 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useSrStore = defineStore("SR", () =>
useXapiCollectionStore().get("SR")
);

View File

@@ -1,6 +0,0 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useTaskStore = defineStore("task", () =>
useXapiCollectionStore().get("task")
);

View File

@@ -1,6 +1,7 @@
import { useBreakpoints, useColorMode } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
@@ -13,10 +14,14 @@ export const useUiStore = defineStore("ui", () => {
const isMobile = computed(() => !isDesktop.value);
const route = useRoute();
const hasUi = computed(() => route.query.ui !== "0");
return {
colorMode,
currentHostOpaqueRef,
isDesktop,
isMobile,
hasUi,
};
});

View File

@@ -1,6 +0,0 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useVmGuestMetricsStore = defineStore("vm-guest-metrics", () =>
useXapiCollectionStore().get("VM_guest_metrics")
);

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