Compare commits

..

112 Commits

Author SHA1 Message Date
Manon Mercier
50afcdab3b Add files via upload
Screenshots that will be used in an article about NBD-enabled backups.
2023-12-15 10:28:43 +01:00
Julien Fontanet
59a9a63971 feat(xo-server/store): ensure leveldb only accessible to current user 2023-12-13 11:36:31 +01:00
Julien Fontanet
a2e8b999da feat(xo-server-auth-saml): forceAuthn setting (#7232)
Fixes https://xcp-ng.org/forum/post/67764
2023-12-13 11:25:16 +01:00
OlivierFL
489ad51b4d feat(lite): add new UiStatusPanel component (#7227) 2023-12-12 11:44:22 +01:00
Julien Fontanet
7db2516a38 chore: update dev deps 2023-12-12 10:30:11 +01:00
Julien Fontanet
1141ef524f fix(xapi/host_smartReboot): retries when HOST_STILL_BOOTING (#7231)
Fixes #7194
2023-12-11 16:04:43 +01:00
OlivierFL
f449258ed3 feat(lite): add indeterminate state on FormToggle component (#7230) 2023-12-11 14:48:24 +01:00
Julien Fontanet
bb3b83c690 fix(xo-server/rest-api): proper 404 in case of missing backup job 2023-12-08 15:19:48 +01:00
Julien Fontanet
2b973275c0 feat(xo-server/rest-api): expose metadata & mirror backup jobs 2023-12-08 15:17:51 +01:00
Julien Fontanet
037e1c1dfa feat(xo-server/rest-api): /backups → /backup 2023-12-08 15:14:06 +01:00
Julien Fontanet
f0da94081b feat(gen-deps-list): detect duplicate packages
Prevents a bug where a second entry would override the previous one and possibly
decrease the release type (e.g. `major + patch → patch`).
2023-12-07 17:15:09 +01:00
Julien Fontanet
cd44a6e28c feat(eslint): enable require-atomic-updates rule 2023-12-07 17:05:21 +01:00
Julien Fontanet
70b09839c7 chore(xo-server): use @xen-orchestra/xapi/VM_import when possible 2023-12-07 16:50:45 +01:00
OlivierFL
12140143d2 feat(lite): added tooltip on CPU provisioning warning icon (#7223) 2023-12-07 09:07:15 +01:00
b-Nollet
e68236c9f2 docs(installation): update Debian & Fedora packages (#7207)
Fixes #7095
2023-12-06 15:39:50 +01:00
Julien Fontanet
8a1a0d76f7 chore: update dev deps 2023-12-06 11:09:54 +01:00
Mathieu
4a5bc5dccc feat(lite): override host address with 'master' query param (#7187) 2023-12-04 11:31:35 +01:00
MlssFrncJrg
0ccdfbd6f4 feat(xo-web/SR): improve forget SR modal message (#7155) 2023-12-04 09:33:50 +01:00
Mathieu
75af7668b5 fix(lite/changelog): fix xolite changelog (#7215) 2023-12-01 10:48:22 +01:00
Thierry Goettelmann
0b454fa670 feat(lite/VM): ability to migrate a VM (#7164) 2023-12-01 10:38:55 +01:00
Pierre Donias
2dcb5cb7cd feat(lite): 0.1.6 (#7213) 2023-11-30 16:01:06 +01:00
Thierry Goettelmann
a5aeeceb7f chore(lite): upgrade dependencies (#7170)
1. Since the project is built-only, all deps have been moved to `devDependencies`
2. TypeScript has been upgraded from 4.9 to 5.2
3. `engines.node` requirement was set to `>=8.10`. It has been updated to `>=18` to be aligned with deps requirements
2023-11-30 15:13:36 +01:00
Florent BEAUCHAMP
b2f2c3cbc4 feat: release 5.89.0 (#7212) 2023-11-30 13:57:49 +01:00
Florent BEAUCHAMP
0f7ac004ad feat: technical release (#7211) 2023-11-30 10:42:54 +01:00
Florent Beauchamp
7faa82a9c8 feat(xo-web): add UX for differential backup 2023-11-30 10:12:20 +01:00
Florent Beauchamp
4b3f60b280 feat(backups): implement differential restore
When restoring a backup, try to reuse data from an existing snapshot.
We use this snasphot and apply a reverse differential of the changes
between the backup and the snapshot

Prerequisite : a uninterrupted delta chain from the backup being
restored to a backup that still have its snapshot on the host
2023-11-30 10:12:20 +01:00
Florent Beauchamp
b29d5ba95c feat(vhd-lib): implement a limit in VhdSynthetic.fromVhdChain 2023-11-30 10:12:20 +01:00
Florent Beauchamp
408fc5af84 feat(vhd-lib): implement VhdNegative
it's a virtual Vhd that contains all the changes to be applied
to reset a child to its parent value
2023-11-30 10:12:20 +01:00
Florent BEAUCHAMP
2748aea4e9 feat: technical release (#7210) 2023-11-29 15:39:25 +01:00
Florent Beauchamp
a5acc7d267 fix(backups,xo-server): don't backup VMs created by Health Check 2023-11-29 14:46:05 +01:00
Florent Beauchamp
87a9fbe237 feat(xo-server,xo-web): don't backup VMs with xo:no-bak tag 2023-11-29 14:46:05 +01:00
Julien Fontanet
9d0b7242f0 fix(xapi-explore-sr): use xen-api@2.0.0 2023-11-29 14:42:50 +01:00
Julien Fontanet
20ec44c3b3 fix(xo-server/registerHttpRequestHandler): match on path
Instead of full URL, so that handlers can manage query string.
2023-11-29 14:42:50 +01:00
Julien Fontanet
6f68456bae feat(xo-server/registerHttpRequestHandler): returns teardown function
`unregisterHttpRequestHandler` is no longer useful and has been removed.
2023-11-29 14:42:50 +01:00
Florent BEAUCHAMP
b856c1a6b4 feat(xo-server,xo-web): show link to the SR for the garbage collector (coalesce) task (#7189)
See https://github.com/vatesfr/xen-orchestra/issues/5379#issuecomment-1765170973
2023-11-29 09:07:05 +01:00
Julien Fontanet
61e1f83a9f feat(xo-server/rest-api): possibility to import a VM 2023-11-28 17:54:10 +01:00
Mathieu
5820e19731 feat(xo-web/VM): display task information on VDI import (#7197) 2023-11-28 15:41:10 +01:00
Pierre Donias
cdb51f8fe3 chore(lite/settings): use FormSelect instead of select (#7206) 2023-11-28 14:46:25 +01:00
Florent BEAUCHAMP
57940e0a52 fix(backups): import on non default SR (#7209) 2023-11-28 14:35:08 +01:00
Florent BEAUCHAMP
6cc95efe51 feat: technical release (#7208) 2023-11-28 09:30:32 +01:00
Pierre Donias
b0ff2342ab chore(netbox): remove null-indexed entries from keyed-by collections (#7156) 2023-11-27 16:26:53 +01:00
Mathieu
0f67692be4 feat(xo-server/xostor): add XO tasks (#7201) 2023-11-27 16:11:53 +01:00
Julien Fontanet
865461bfb9 feat(xo-server/api): backupNg.{,un}mountPartition (#7176)
Manual method to mount a backup partition on the XOA.
2023-11-24 09:47:23 +01:00
Julien Fontanet
e108cb0990 feat(xo-server/rest-api): possibility to import in an existing VDI (#7199) 2023-11-23 17:07:40 +01:00
Florent BEAUCHAMP
c4535c6bae fix(fs/s3): enable md5 if object lock status is unknown (#7195)
From https://xcp-ng.org/forum/topic/7939/unable-to-connect-to-backblaze-b2/7?_=1700572613725
Following 796e2ab674 

User report it fixes the issue https://xcp-ng.org/forum/post/67633
2023-11-23 16:43:25 +01:00
Julien Fontanet
ad8eaaa771 feat(xo-cli): support REST PUT method 2023-11-23 16:30:03 +01:00
Julien Fontanet
9419cade3d feat(xo-server/rest-api): tags property can be updated 2023-11-23 16:30:03 +01:00
Julien Fontanet
272e6422bd chore(xapi/VM_import): typo snapshots → snapshot 2023-11-23 16:28:30 +01:00
Julien Fontanet
547908a8f9 chore(xo-server/proxy.checkHealth): call checkProxyHealth 2023-11-23 16:28:29 +01:00
Mathieu
8abfaa0bd5 feat(lite/VM): ability to export a VM (#7190) 2023-11-23 11:00:38 +01:00
MlssFrncJrg
a9fbcf3962 feat(xo-web/new VM): always show ISO selector (#7166)
Fixes #3464
2023-11-22 11:04:30 +01:00
Michael Bennett
887b49ebbf docs(installation): Fedora & CentOS wrong package libvhd-utils (#7200)
Under Packages the installation of package `libvhdi-utils` is incorrect for Fedora/CentOS. This should be replaced by `libvhdi-tools` instead.
2023-11-21 17:46:17 +01:00
Florent BEAUCHAMP
858ecbc217 fix(xapi/VDI_importContent): other_config entries must be strings (#7198)
Introduced byffd523679de80b36b2eacd30cc98de3c588a2b77
2023-11-21 16:55:53 +01:00
Florent BEAUCHAMP
ffd523679d feat(backups): update VDI importing status its name_label 2023-11-21 14:38:49 +01:00
Florent BEAUCHAMP
bd9db437f1 feat(xapi/VDI_importContent): store task UUID and stream length into other_config 2023-11-21 14:38:49 +01:00
Florent BEAUCHAMP
0365bacfbb feat(backups): show more detail on restored VM (#7186) 2023-11-21 12:28:53 +01:00
MlssFrncJrg
f3e0227c55 feat(xo-web/console): add disabled console message (#7161)
Fixes #6319
2023-11-21 10:39:35 +01:00
Florent BEAUCHAMP
4504141cbf refactor(backups/importIncrementalVm): move base detection to callers (#7165) 2023-11-20 14:52:59 +01:00
b-Nollet
ecbbf878d0 chore(xen-api): convert to ESM (#7181) 2023-11-20 14:32:44 +01:00
MlssFrncJrg
c1faaa3107 fix(xo-server/resource-set): fix error when changing VM resource set (#7144) 2023-11-20 14:19:27 +01:00
Julien Fontanet
59f04b4a6b chore: format with Prettier 2023-11-20 12:34:30 +01:00
Julien Fontanet
781b070e74 fix(xen-api/examples/import-vdi): params handling 2023-11-20 11:45:45 +01:00
Julien Fontanet
1911386aba chore: refresh yarn.lock 2023-11-20 09:55:07 +01:00
MlssFrncJrg
5b0339315f docs(Support): remove Partner Program (#7099) 2023-11-20 09:42:21 +01:00
b-Nollet
5fe53dfa99 refactor(xapi-explore-sr): convert to EM (#7191) 2023-11-17 16:55:11 +01:00
b-Nollet
06068cdcc6 refactor(cr-seed-cli): convert to ESM (#7192) 2023-11-17 16:46:36 +01:00
Julien Fontanet
c88cc2b020 chore(xo-server/token.create): allow 60s for expiresIn
It makes more sense for the min accepted value to be 60s than 60,001ms.
2023-11-17 10:57:48 +01:00
Pierre Donias
03de8ad481 docs(netbox): update steps and screenshots with latest version (#7182) 2023-11-16 16:06:01 +01:00
Julien Fontanet
08ba7e7253 chore: refresh yarn.lock 2023-11-16 10:21:17 +01:00
Florent BEAUCHAMP
9ca3f3df26 fix(xo-vmdk-to-vhd): improve compatibilty of ova with disk bigger than 8.2GB (#7183)
following #7047, from https://xcp-ng.org/forum/topic/7946/ova-export-not-functional?_=1700051758755

ova exported from xo with more than 8.2G data per disk can't be imported in virtual box 

tar-stream@3 pack and entry are now streams
2023-11-15 16:23:33 +01:00
Mathieu
511908bb7d feat(lite/pool/VMs): ability to export selected VMs (#7174) 2023-11-15 15:29:25 +01:00
Thierry Goettelmann
4351aad312 feat(lite): new FormByteSize component (#6741) 2023-11-15 15:28:46 +01:00
Florent BEAUCHAMP
af7aa29c91 feat(nbd-client): various fixes (#6964) 2023-11-15 10:04:09 +01:00
Thierry Goettelmann
315d626055 fix(lite/story): code highlight modal path (#7180) 2023-11-15 09:58:15 +01:00
Pierre Donias
7af0899800 feat(netbox): sync XO users as Netbox tenants (#7158)
See Zammad#11356
See Zammad#17364
See Zammad#18409
2023-11-14 15:25:56 +01:00
Florent BEAUCHAMP
46ec2dfd56 fix(vmware-explorer): better handling of VM import without any storage (#7168) 2023-11-14 15:14:13 +01:00
Thierry Goettelmann
b2348474c3 fix(lite/pool): host patches list is broken if changelog property is empty (#7169) 2023-11-14 15:08:56 +01:00
Julien Fontanet
836300755a feat: release 5.88.2 2023-11-13 15:07:43 +01:00
Julien Fontanet
55c8c8a6e9 feat(xo-server): 5.126.0 2023-11-13 11:41:08 +01:00
Julien Fontanet
38e32cd24c chore: update dev deps 2023-11-13 09:48:54 +01:00
Julien Fontanet
5ceacfaf5a fix(xo-server/redis): fix searching with multiple indexes
Introduced by 36b94f745
2023-11-12 22:18:19 +01:00
Thierry Goettelmann
1ee6b106b9 feat(lite/ui): compact layout (#7159) 2023-11-10 16:26:20 +01:00
Julien Fontanet
eaef4f22d2 fix(xo-web/settings/logs): use template when reporting
Related to #7142
2023-11-10 11:33:01 +01:00
Julien Fontanet
96025df12f feat(xo-server): only create a single token per web client (and user)
Related to e07e2d3cc

Similar to 581b42fa9
2023-11-09 17:13:10 +01:00
Mathieu
a8aac295eb fix(lite/login): correctly handle login from slave (#7110) 2023-11-09 15:00:29 +01:00
Mathieu
83141989f0 feat(xolite/modals): add onClose event (#7167) 2023-11-09 10:57:24 +01:00
Julien Fontanet
9dea52281d docs(installation): explicit Redis should be started 2023-11-07 17:10:35 +01:00
Julien Fontanet
2164c72034 fix(xo-server): log redis errors
Avoid unhandled error events.
2023-11-07 16:08:41 +01:00
Julien Fontanet
0d0c38f3b5 fix(backups): create suspend VDI on correct SR
Introduced by a958fe86d
2023-11-07 15:55:14 +01:00
Pierre Donias
e5be21a590 feat(lite): 0.1.5 (#7162) 2023-11-07 15:49:12 +01:00
Julien Fontanet
bc1a8be862 chore: fix formatting 2023-11-07 14:33:16 +01:00
Mathieu
3df4dbaae7 feat: release 5.88.1 (#7163) 2023-11-07 14:30:53 +01:00
Julien Fontanet
8f2cfebda6 feat(xo-server/rest-api): add users collection 2023-11-07 12:51:12 +01:00
Julien Fontanet
0d00c1c45f chore: update dev deps 2023-11-07 12:32:38 +01:00
Mathieu
9886e06d6a feat: technical release (#7160) 2023-11-07 10:18:09 +01:00
Thierry Goettelmann
478dbdfe41 feat(lite): new modal management (#7134) 2023-11-07 09:49:02 +01:00
Florent BEAUCHAMP
2bfdb60dda fix(fs): handle object storage server not implementing Object lock (#7157) 2023-11-06 17:18:00 +01:00
Pierre Donias
cabd04470d feat(xo-server-netbox): use Netbox version instead of Netbox API version (#7138)
Netbox version is more precise (X.Y.Z instead of X.Y)
2023-11-06 17:10:10 +01:00
Florent BEAUCHAMP
f6819b23f9 fix(xo-web/dashboard): empty VDIs shouldn't be flagged as orphan (#7102)
Fixes zammad#15524
2023-11-06 13:57:44 +01:00
Julien Fontanet
c9dbcf1384 fix(proxy/backup.importVmBackup): only dispose resources at the end (#7152)
Fixes #7052

Fixes zammad#17383

When a stream is returned, the handler immediately returned a stream which disposed the resource.

Due to the disposable having a 5 mins debounce delay, the problem was only apparent after 5 mins.
2023-11-06 10:30:34 +01:00
Pierre Donias
457fec0bc8 fix(xo-server-netbox): properly delete all interfaces that don't have a UUID (#7153)
Fixes zammad#18812

Introduced by 3b1bcc67ae

The first step of synchronizing VIFs with Netbox interfaces is to clean up any interface attached to the Netbox VM that isn't found on the XO VM, *based on their UUID*, including the interfaces that don't have a UUID at all (`uuid` is `null`).

But by looping over the keyed-by-UUID collection, we could only ever delete at most *one* null-UUID interface, the other ones being dropped by `keyBy`.

Using the flat-array collection instead makes sure all the interfaces are handled.
2023-11-06 10:28:43 +01:00
Pierre Donias
db99a22244 fix(xo-web/New network): only hide bond-PIFs when creating a bonded network (#7151)
Fixes #7150
See https://xcp-ng.org/forum/topic/7918
Introduced by dbdc5f3e3b
2023-11-03 11:22:10 +01:00
Pierre Donias
89d8adc6c6 feat(netbox): expose raw HTTP body if it cannot be JSON-parsed (#7146) 2023-11-02 14:28:06 +01:00
Pierre Donias
a3ea70c61c fix(xo-server-netbox): fix site property null/undefined cases (#7145)
Introduced by 1d7559ded2
2023-10-31 16:16:38 +01:00
Mathieu
ae0f3b4fe0 feat: release 5.88.0 (#7143) 2023-10-31 14:58:50 +01:00
Mathieu
2552ef37d2 feat: technical release (#7141) 2023-10-31 10:09:35 +01:00
Pierre Donias
9803e8c6cb feat(xo-web/patches): warning about updating pool master first (#7140) 2023-10-31 09:51:17 +01:00
Florent BEAUCHAMP
3410cbc3b9 fix(backups): use VDI virtual_size instead of physical_size
`physical_size` appears to be broken
2023-10-30 15:55:30 +01:00
Florent BEAUCHAMP
93fce0d4bf fix(backups): pass type to Xapi#getRecord
Fixes #7131

Introduced by 37b211376
2023-10-30 15:55:30 +01:00
MlssFrncJrg
dbdc5f3e3b feat(xo-web/New network): don't show PIFs that belong to a bond (#7136) 2023-10-30 15:47:38 +01:00
Julien Fontanet
581b42fa9d feat(xo-cli): only create a single token per instance (and user) 2023-10-30 15:47:17 +01:00
Julien Fontanet
e07e2d3ccd feat(xo-server/token): client info support 2023-10-30 15:47:17 +01:00
260 changed files with 9152 additions and 4505 deletions

View File

@@ -68,6 +68,11 @@ module.exports = {
'no-console': ['error', { allow: ['warn', 'error'] }],
// this rule can prevent race condition bugs like parallel `a += await foo()`
//
// as it has a lots of false positive, it is only enabled as a warning for now
'require-atomic-updates': 'warn',
strict: 'error',
},
}

View File

@@ -33,7 +33,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^16.0.0",
"sinon": "^17.0.1",
"tap": "^16.3.0",
"test": "^3.2.1"
}

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.4",
"version": "0.1.5",
"engines": {
"node": ">=8.10"
},
@@ -23,13 +23,13 @@
"test": "node--test"
},
"dependencies": {
"@vates/multi-key-map": "^0.1.0",
"@vates/multi-key-map": "^0.2.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"ensure-array": "^1.0.0"
},
"devDependencies": {
"sinon": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.2.1"
}
}

View File

@@ -22,7 +22,7 @@
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.6.1"
"vhd-lib": "^4.7.0"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -17,4 +17,14 @@ map.get(['foo', 'bar']) // 2
map.get(['bar', 'foo']) // 3
map.get([OBJ]) // 4
map.get([{}]) // undefined
map.delete([])
for (const [key, value] of map.entries() {
console.log(key, value)
}
for (const value of map.values()) {
console.log(value)
}
```

View File

@@ -35,6 +35,16 @@ map.get(['foo', 'bar']) // 2
map.get(['bar', 'foo']) // 3
map.get([OBJ]) // 4
map.get([{}]) // undefined
map.delete([])
for (const [key, value] of map.entries() {
console.log(key, value)
}
for (const value of map.values()) {
console.log(value)
}
```
## Contributions

View File

@@ -36,14 +36,31 @@ function del(node, i, keys) {
return node
}
function* entries(node, key) {
if (node !== undefined) {
if (node instanceof Node) {
const { value } = node
if (value !== undefined) {
yield [key, node.value]
}
for (const [childKey, child] of node.children.entries()) {
yield* entries(child, key.concat(childKey))
}
} else {
yield [key, node]
}
}
}
function get(node, i, keys) {
return i === keys.length
? node instanceof Node
? node.value
: node
: node instanceof Node
? get(node.children.get(keys[i]), i + 1, keys)
: undefined
? get(node.children.get(keys[i]), i + 1, keys)
: undefined
}
function set(node, i, keys, value) {
@@ -69,6 +86,22 @@ function set(node, i, keys, value) {
return node
}
function* values(node) {
if (node !== undefined) {
if (node instanceof Node) {
const { value } = node
if (value !== undefined) {
yield node.value
}
for (const child of node.children.values()) {
yield* values(child)
}
} else {
yield node
}
}
}
exports.MultiKeyMap = class MultiKeyMap {
constructor() {
// each node is either a value or a Node if it contains children
@@ -79,6 +112,10 @@ exports.MultiKeyMap = class MultiKeyMap {
this._root = del(this._root, 0, keys)
}
entries() {
return entries(this._root, [])
}
get(keys) {
return get(this._root, 0, keys)
}
@@ -86,4 +123,8 @@ exports.MultiKeyMap = class MultiKeyMap {
set(keys, value) {
this._root = set(this._root, 0, keys, value)
}
values() {
return values(this._root)
}
}

View File

@@ -19,7 +19,7 @@ describe('MultiKeyMap', () => {
// reverse composite key
['bar', 'foo'],
]
const values = keys.map(() => ({}))
const values = keys.map(() => Math.random())
// set all values first to make sure they are all stored and not only the
// last one
@@ -27,6 +27,12 @@ describe('MultiKeyMap', () => {
map.set(key, values[i])
})
assert.deepEqual(
Array.from(map.entries()),
keys.map((key, i) => [key, values[i]])
)
assert.deepEqual(Array.from(map.values()), values)
keys.forEach((key, i) => {
// copy the key to make sure the array itself is not the key
assert.strictEqual(map.get(key.slice()), values[i])

View File

@@ -18,7 +18,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"version": "0.2.0",
"engines": {
"node": ">=8.10"
},

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert'
import { Socket } from 'node:net'
import { connect } from 'node:tls'
import { fromCallback, pRetry, pDelay, pTimeout } from 'promise-toolbox'
import { fromCallback, pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
@@ -21,6 +21,7 @@ import {
OPTS_MAGIC,
NBD_CMD_DISC,
} from './constants.mjs'
import { Readable } from 'node:stream'
const { warn } = createLogger('vates:nbd-client')
@@ -40,6 +41,7 @@ export default class NbdClient {
#readBlockRetries
#reconnectRetry
#connectTimeout
#messageTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
@@ -52,7 +54,14 @@ export default class NbdClient {
#reconnectingPromise
constructor(
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
{ connectTimeout = 6e4, waitBeforeReconnect = 1e3, readAhead = 10, readBlockRetries = 5, reconnectRetry = 5 } = {}
{
connectTimeout = 6e4,
messageTimeout = 6e4,
waitBeforeReconnect = 1e3,
readAhead = 10,
readBlockRetries = 5,
reconnectRetry = 5,
} = {}
) {
this.#serverAddress = address
this.#serverPort = port
@@ -63,6 +72,7 @@ export default class NbdClient {
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
this.#messageTimeout = messageTimeout
}
get exportSize() {
@@ -116,12 +126,24 @@ export default class NbdClient {
return
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
await this.#write(buffer)
await this.#serverSocket.destroy()
buffer.writeBigUInt64BE(queryId, 8)
buffer.writeBigUInt64BE(0n, 16)
buffer.writeInt32BE(0, 24)
const promise = pFromCallback(cb => {
this.#serverSocket.end(buffer, 'utf8', cb)
})
try {
await pTimeout.call(promise, this.#messageTimeout)
} catch (error) {
this.#serverSocket.destroy()
}
this.#serverSocket = undefined
this.#connected = false
}
@@ -195,11 +217,13 @@ export default class NbdClient {
}
#read(length) {
return readChunkStrict(this.#serverSocket, length)
const promise = readChunkStrict(this.#serverSocket, length)
return pTimeout.call(promise, this.#messageTimeout)
}
#write(buffer) {
return fromCallback.call(this.#serverSocket, 'write', buffer)
const promise = fromCallback.call(this.#serverSocket, 'write', buffer)
return pTimeout.call(promise, this.#messageTimeout)
}
async #readInt32() {
@@ -232,19 +256,20 @@ export default class NbdClient {
}
try {
this.#waitingForResponse = true
const magic = await this.#readInt32()
const buffer = await this.#read(16)
const magic = buffer.readInt32BE(0)
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = await this.#readInt32()
const error = buffer.readInt32BE(4)
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = await this.#readInt64()
const blockQueryId = buffer.readBigUInt64BE(8)
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
@@ -281,7 +306,13 @@ export default class NbdClient {
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
buffer.writeBigUInt64BE(queryId, 8)
// byte offset in the raw disk
buffer.writeBigUInt64BE(BigInt(index) * BigInt(size), 16)
const offset = BigInt(index) * BigInt(size)
const remaining = this.#exportSize - offset
if (remaining < BigInt(size)) {
size = Number(remaining)
}
buffer.writeBigUInt64BE(offset, 16)
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
@@ -307,14 +338,15 @@ export default class NbdClient {
})
}
async *readBlocks(indexGenerator) {
async *readBlocks(indexGenerator = 2 * 1024 * 1024) {
// default : read all blocks
if (indexGenerator === undefined) {
const exportSize = this.#exportSize
const chunkSize = 2 * 1024 * 1024
if (typeof indexGenerator === 'number') {
const exportSize = Number(this.#exportSize)
const chunkSize = indexGenerator
indexGenerator = function* () {
const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
for (let index = 0; BigInt(index) < nbBlocks; index++) {
const nbBlocks = Math.ceil(exportSize / chunkSize)
for (let index = 0; index < nbBlocks; index++) {
yield { index, size: chunkSize }
}
}
@@ -348,4 +380,15 @@ export default class NbdClient {
yield readAhead.shift()
}
}
stream(chunkSize) {
async function* iterator() {
for await (const chunk of this.readBlocks(chunkSize)) {
yield chunk
}
}
// create a readable stream instead of returning the iterator
// since iterators don't like unshift and partial reading
return Readable.from(iterator())
}
}

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "2.0.0",
"version": "2.0.1",
"engines": {
"node": ">=14.0"
},
@@ -24,7 +24,7 @@
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.3.6"
"xen-api": "^2.0.0"
},
"devDependencies": {
"tap": "^16.3.0",

View File

@@ -22,41 +22,41 @@ const readChunk = (stream, size) =>
stream.errored != null
? Promise.reject(stream.errored)
: stream.closed || stream.readableEnded
? Promise.resolve(null)
: new Promise((resolve, reject) => {
if (size !== undefined) {
assert(size > 0)
? Promise.resolve(null)
: new Promise((resolve, reject) => {
if (size !== undefined) {
assert(size > 0)
// per Node documentation:
// > The size argument must be less than or equal to 1 GiB.
assert(size < 1073741824)
}
// per Node documentation:
// > The size argument must be less than or equal to 1 GiB.
assert(size < 1073741824)
}
function onEnd() {
resolve(null)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read(size)
if (data !== null) {
resolve(data)
function onEnd() {
resolve(null)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read(size)
if (data !== null) {
resolve(data)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
exports.readChunk = readChunk
/**
@@ -111,42 +111,42 @@ async function skip(stream, size) {
return stream.errored != null
? Promise.reject(stream.errored)
: size === 0 || stream.closed || stream.readableEnded
? Promise.resolve(0)
: new Promise((resolve, reject) => {
let left = size
function onEnd() {
resolve(size - left)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read()
left -= data === null ? 0 : data.length
if (left > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (left < 0) {
stream.unshift(data.slice(left))
}
resolve(size)
? Promise.resolve(0)
: new Promise((resolve, reject) => {
let left = size
function onEnd() {
resolve(size - left)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read()
left -= data === null ? 0 : data.length
if (left > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (left < 0) {
stream.unshift(data.slice(left))
}
resolve(size)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
}
exports.skip = skip

View File

@@ -111,7 +111,7 @@ const onProgress = makeOnProgress({
// current status of the task as described in the previous section
taskLog.status
// undefined or a dictionnary of properties attached to the task
// undefined or a dictionary of properties attached to the task
taskLog.properties
// timestamp at which the abortion was requested, undefined otherwise

View File

@@ -35,7 +35,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.2.1"
}
}

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.1",
"@xen-orchestra/fs": "^4.1.1",
"@xen-orchestra/backups": "^0.44.2",
"@xen-orchestra/fs": "^4.1.3",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.13",
"version": "1.0.14",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -4,23 +4,229 @@ import { formatFilenameDate } from './_filenameDate.mjs'
import { importIncrementalVm } from './_incrementalVm.mjs'
import { Task } from './Task.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { VhdNegative, VhdSynthetic } from 'vhd-lib'
import { decorateClass } from '@vates/decorate-with'
import { createLogger } from '@xen-orchestra/log'
import { dirname, join } from 'node:path'
import pickBy from 'lodash/pickBy.js'
import { defer } from 'golike-defer'
const { debug, info, warn } = createLogger('xo:backups:importVmBackup')
async function resolveUuid(xapi, cache, uuid, type) {
if (uuid == null) {
return uuid
}
const ref = cache.get(uuid)
if (ref === undefined) {
cache.set(uuid, xapi.call(`${type}.get_by_uuid`, uuid))
}
return cache.get(uuid)
}
export class ImportVmBackup {
constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses, mapVdisSrs = {} } = {} }) {
constructor({
adapter,
metadata,
srUuid,
xapi,
settings: { additionnalVmTag, newMacAddresses, mapVdisSrs = {}, useDifferentialRestore = false } = {},
}) {
this._adapter = adapter
this._importIncrementalVmSettings = { newMacAddresses, mapVdisSrs }
this._importIncrementalVmSettings = { additionnalVmTag, newMacAddresses, mapVdisSrs, useDifferentialRestore }
this._metadata = metadata
this._srUuid = srUuid
this._xapi = xapi
}
async #getPathOfVdiSnapshot(snapshotUuid) {
const metadata = this._metadata
if (this._pathToVdis === undefined) {
const backups = await this._adapter.listVmBackups(
this._metadata.vm.uuid,
({ mode, timestamp }) => mode === 'delta' && timestamp >= metadata.timestamp
)
const map = new Map()
for (const backup of backups) {
for (const [vdiRef, vdi] of Object.entries(backup.vdis)) {
map.set(vdi.uuid, backup.vhds[vdiRef])
}
}
this._pathToVdis = map
}
return this._pathToVdis.get(snapshotUuid)
}
async _reuseNearestSnapshot($defer, ignoredVdis) {
const metadata = this._metadata
const { mapVdisSrs } = this._importIncrementalVmSettings
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
const streams = {}
const metdataDir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
for (const [vdiRef, vdi] of Object.entries(vdis)) {
const vhdPath = join(metdataDir, vhds[vdiRef])
let xapiDisk
try {
xapiDisk = await this._xapi.getRecordByUuid('VDI', vdi.$snapshot_of$uuid)
} catch (err) {
// if this disk is not present anymore, fall back to default restore
warn(err)
}
let snapshotCandidate, backupCandidate
if (xapiDisk !== undefined) {
debug('found disks, wlll search its snapshots', { snapshots: xapiDisk.snapshots })
for (const snapshotRef of xapiDisk.snapshots) {
const snapshot = await this._xapi.getRecord('VDI', snapshotRef)
debug('handling snapshot', { snapshot })
// take only the first snapshot
if (snapshotCandidate && snapshotCandidate.snapshot_time < snapshot.snapshot_time) {
debug('already got a better candidate')
continue
}
// have a corresponding backup more recent than metadata ?
const pathToSnapshotData = await this.#getPathOfVdiSnapshot(snapshot.uuid)
if (pathToSnapshotData === undefined) {
debug('no backup linked to this snaphot')
continue
}
if (snapshot.$SR.uuid !== (mapVdisSrs[vdi.$snapshot_of$uuid] ?? this._srUuid)) {
debug('not restored on the same SR', { snapshotSr: snapshot.$SR.uuid, mapVdisSrs, srUuid: this._srUuid })
continue
}
debug('got a candidate', pathToSnapshotData)
snapshotCandidate = snapshot
backupCandidate = pathToSnapshotData
}
}
let stream
const backupWithSnapshotPath = join(metdataDir, backupCandidate ?? '')
if (vhdPath === backupWithSnapshotPath) {
// all the data are already on the host
debug('direct reuse of a snapshot')
stream = null
vdis[vdiRef].baseVdi = snapshotCandidate
// go next disk , we won't use this stream
continue
}
let disposableDescendants
const disposableSynthetic = await VhdSynthetic.fromVhdChain(this._adapter._handler, vhdPath)
// this will also clean if another disk of this VM backup fails
// if user really only need to restore non failing disks he can retry with ignoredVdis
let disposed = false
const disposeOnce = async () => {
if (!disposed) {
disposed = true
try {
await disposableDescendants?.dispose()
await disposableSynthetic?.dispose()
} catch (error) {
warn('openVhd: failed to dispose VHDs', { error })
}
}
}
$defer.onFailure(() => disposeOnce())
const parentVhd = disposableSynthetic.value
await parentVhd.readBlockAllocationTable()
debug('got vhd synthetic of parents', parentVhd.length)
if (snapshotCandidate !== undefined) {
try {
debug('will try to use differential restore', {
backupWithSnapshotPath,
vhdPath,
vdiRef,
})
disposableDescendants = await VhdSynthetic.fromVhdChain(this._adapter._handler, backupWithSnapshotPath, {
until: vhdPath,
})
const descendantsVhd = disposableDescendants.value
await descendantsVhd.readBlockAllocationTable()
debug('got vhd synthetic of descendants')
const negativeVhd = new VhdNegative(parentVhd, descendantsVhd)
debug('got vhd negative')
// update the stream with the negative vhd stream
stream = await negativeVhd.stream()
vdis[vdiRef].baseVdi = snapshotCandidate
} catch (err) {
// can be a broken VHD chain, a vhd chain with a key backup, ....
// not an irrecuperable error, don't dispose parentVhd, and fallback to full restore
warn(`can't use differential restore`, err)
disposableDescendants?.dispose()
}
}
// didn't make a negative stream : fallback to classic stream
if (stream === undefined) {
debug('use legacy restore')
stream = await parentVhd.stream()
}
stream.on('end', disposeOnce)
stream.on('close', disposeOnce)
stream.on('error', disposeOnce)
info('everything is ready, will transfer', stream.length)
streams[`${vdiRef}.vhd`] = stream
}
return {
streams,
vbds,
vdis,
version: '1.0.0',
vifs,
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
}
}
async #decorateIncrementalVmMetadata() {
const { additionnalVmTag, mapVdisSrs, useDifferentialRestore } = this._importIncrementalVmSettings
const ignoredVdis = new Set(
Object.entries(mapVdisSrs)
.filter(([_, srUuid]) => srUuid === null)
.map(([vdiUuid]) => vdiUuid)
)
let backup
if (useDifferentialRestore) {
backup = await this._reuseNearestSnapshot(ignoredVdis)
} else {
backup = await this._adapter.readIncrementalVmBackup(this._metadata, ignoredVdis)
}
const xapi = this._xapi
const cache = new Map()
const mapVdisSrRefs = {}
if (additionnalVmTag !== undefined) {
backup.vm.tags.push(additionnalVmTag)
}
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
}
const srRef = await resolveUuid(xapi, cache, this._srUuid, 'SR')
Object.values(backup.vdis).forEach(vdi => {
vdi.SR = mapVdisSrRefs[vdi.uuid] ?? srRef
})
return backup
}
async run() {
const adapter = this._adapter
const metadata = this._metadata
const isFull = metadata.mode === 'full'
const sizeContainer = { size: 0 }
const { newMacAddresses } = this._importIncrementalVmSettings
let backup
if (isFull) {
backup = await adapter.readFullVmBackup(metadata)
@@ -28,12 +234,7 @@ export class ImportVmBackup {
} else {
assert.strictEqual(metadata.mode, 'delta')
const ignoredVdis = new Set(
Object.entries(this._importIncrementalVmSettings.mapVdisSrs)
.filter(([_, srUuid]) => srUuid === null)
.map(([vdiUuid]) => vdiUuid)
)
backup = await adapter.readIncrementalVmBackup(metadata, ignoredVdis)
backup = await this.#decorateIncrementalVmMetadata()
Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer))
}
@@ -48,8 +249,7 @@ export class ImportVmBackup {
const vmRef = isFull
? await xapi.VM_import(backup, srRef)
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
...this._importIncrementalVmSettings,
detectBase: false,
newMacAddresses,
})
await Promise.all([
@@ -59,6 +259,13 @@ export class ImportVmBackup {
vmRef,
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
),
xapi.call(
'VM.set_name_description',
vmRef,
`Restored on ${formatFilenameDate(+new Date())} from ${adapter._handler._remote.name} -
${metadata.vm.name_description}
`
),
])
return {
@@ -69,3 +276,5 @@ export class ImportVmBackup {
)
}
}
decorateClass(ImportVmBackup, { _reuseNearestSnapshot: defer })

View File

@@ -1,4 +1,3 @@
import find from 'lodash/find.js'
import groupBy from 'lodash/groupBy.js'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import omit from 'lodash/omit.js'
@@ -12,24 +11,18 @@ import { cancelableMap } from './_cancelableMap.mjs'
import { Task } from './Task.mjs'
import pick from 'lodash/pick.js'
// in `other_config` of an incrementally replicated VM, contains the UUID of the source VM
export const TAG_BASE_DELTA = 'xo:base_delta'
// in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
//
// added after the complete replication
export const TAG_BACKUP_SR = 'xo:backup:sr'
// in other_config of VDIs of an incrementally replicated VM, contains the UUID of the source VDI
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) {
return uuid
}
let ref = cache.get(uuid)
if (ref === undefined) {
ref = await xapi.call(`${type}.get_by_uuid`, uuid)
cache.set(uuid, ref)
}
return ref
}
export async function exportIncrementalVm(
vm,
@@ -147,7 +140,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
$defer,
incrementalVm,
sr,
{ cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {}
{ cancelToken = CancelToken.none, newMacAddresses = false } = {}
) {
const { version } = incrementalVm
if (compareVersions(version, '1.0.0') < 0) {
@@ -157,35 +150,6 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
const vmRecord = incrementalVm.vm
const xapi = sr.$xapi
let baseVm
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 && obj[TAG_BACKUP_SR] === sr.$id
)
if (!baseVm) {
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
}
}
}
const cache = new Map()
const mapVdisSrRefs = {}
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
}
const baseVdis = {}
baseVm &&
baseVm.$VBDs.forEach(vbd => {
const vdi = vbd.$VDI
if (vdi !== undefined) {
baseVdis[vbd.VDI] = vbd.$VDI
}
})
const vdiRecords = incrementalVm.vdis
// 0. Create suspend_VDI
@@ -197,18 +161,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
})
} else {
suspendVdi = await xapi.getRecord(
'VDI',
await xapi.VDI_create({
...vdi,
other_config: {
...vdi.other_config,
[TAG_BASE_DELTA]: undefined,
[TAG_COPY_SRC]: vdi.uuid,
},
sr: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
})
)
suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
$defer.onFailure(() => suspendVdi.$destroy())
}
}
@@ -226,10 +179,6 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
ha_always_run: false,
is_a_template: false,
name_label: '[Importing…] ' + vmRecord.name_label,
other_config: {
...vmRecord.other_config,
[TAG_COPY_SRC]: vmRecord.uuid,
},
},
{
bios_strings: vmRecord.bios_strings,
@@ -250,14 +199,8 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
const vdi = vdiRecords[vdiRef]
let newVdi
const remoteBaseVdiUuid = detectBase && vdi.other_config[TAG_BASE_DELTA]
if (remoteBaseVdiUuid) {
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
if (!baseVdi) {
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
}
newVdi = await xapi.getRecord('VDI', await baseVdi.$clone())
if (vdi.baseVdi !== undefined) {
newVdi = await xapi.getRecord('VDI', await vdi.baseVdi.$clone())
$defer.onFailure(() => newVdi.$destroy())
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
@@ -268,18 +211,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
// suspendVDI has already created
newVdi = suspendVdi
} else {
newVdi = await xapi.getRecord(
'VDI',
await xapi.VDI_create({
...vdi,
other_config: {
...vdi.other_config,
[TAG_BASE_DELTA]: undefined,
[TAG_COPY_SRC]: vdi.uuid,
},
SR: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
})
)
newVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
$defer.onFailure(() => newVdi.$destroy())
}
@@ -318,13 +250,19 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
// Import VDI contents.
cancelableMap(cancelToken, Object.entries(newVdis), async (cancelToken, [id, vdi]) => {
for (let stream of ensureArray(streams[`${id}.vhd`])) {
if (stream === null) {
// we restore a backup and reuse completly a local snapshot
continue
}
if (typeof stream === 'function') {
stream = await stream()
}
if (stream.length === undefined) {
stream = await createVhdStreamWithLength(stream)
}
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
await vdi.$importContent(stream, { cancelToken, format: 'vhd' })
await xapi.setField('VDI', vdi.$ref, 'name_label', vdiRecords[id].name_label)
}
}),

View File

@@ -39,10 +39,12 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
const vdis = await exportedVm.$getDisks()
let maxStreamLength = 1024 * 1024 // Ovf file and tar headers are a few KB, let's stay safe
for (const vdiRef of vdis) {
const vdi = await this._xapi.getRecord(vdiRef)
// at most the xva will take the physical usage of the disk
// the resulting stream can be smaller due to the smaller block size for xva than vhd, and compression of xcp-ng
maxStreamLength += vdi.physical_utilisation
const vdi = await this._xapi.getRecord('VDI', vdiRef)
// the size a of fully allocated vdi will be virtual_size exaclty, it's a gross over evaluation
// of the real stream size in general, since a disk is never completly full
// vdi.physical_size seems to underevaluate a lot the real disk usage of a VDI, as of 2023-10-30
maxStreamLength += vdi.virtual_size
}
const sizeContainer = watchStreamSize(stream)

View File

@@ -1,11 +1,11 @@
import cloneDeep from 'lodash/cloneDeep.js'
import mapValues from 'lodash/mapValues.js'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
export function forkDeltaExport(deltaExport) {
return Object.create(deltaExport, {
streams: {
value: mapValues(deltaExport.streams, forkStreamUnpipe),
},
})
const { streams, ...rest } = deltaExport
const newMetadata = cloneDeep(rest)
newMetadata.streams = mapValues(streams, forkStreamUnpipe)
return newMetadata
}

View File

@@ -11,6 +11,7 @@ import { dirname } from 'node:path'
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { TAG_BASE_DELTA } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
@@ -195,7 +196,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
assert.notStrictEqual(
parentPath,
undefined,
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config['xo:base_delta']}`
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config[TAG_BASE_DELTA]}`
)
parentPath = parentPath.slice(1) // remove leading slash

View File

@@ -4,12 +4,13 @@ import { formatDateTime } from '@xen-orchestra/xapi'
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { importIncrementalVm, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
import { importIncrementalVm, TAG_BACKUP_SR, TAG_BASE_DELTA, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
import { listReplicatedVms } from './_listReplicatedVms.mjs'
import find from 'lodash/find.js'
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
@@ -81,6 +82,54 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
return asyncMapSettled(this._oldEntries, vm => vm.$destroy())
}
#decorateVmMetadata(backup) {
const { _warmMigration } = this._settings
const sr = this._sr
const xapi = sr.$xapi
const vm = backup.vm
vm.other_config[TAG_COPY_SRC] = vm.uuid
const remoteBaseVmUuid = vm.other_config[TAG_BASE_DELTA]
let baseVm
if (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})`)
}
}
const baseVdis = {}
baseVm?.$VBDs.forEach(vbd => {
const vdi = vbd.$VDI
if (vdi !== undefined) {
baseVdis[vbd.VDI] = vbd.$VDI
}
})
vm.other_config[TAG_COPY_SRC] = vm.uuid
if (!_warmMigration) {
vm.tags.push('Continuous Replication')
}
Object.values(backup.vdis).forEach(vdi => {
vdi.other_config[TAG_COPY_SRC] = vdi.uuid
vdi.SR = sr.$ref
// vdi.other_config[TAG_BASE_DELTA] is never defined on a suspend vdi
if (vdi.other_config[TAG_BASE_DELTA]) {
const remoteBaseVdiUuid = vdi.other_config[TAG_BASE_DELTA]
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
if (!baseVdi) {
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
}
vdi.baseVdi = baseVdi
}
})
return backup
}
async _transfer({ timestamp, deltaExport, sizeContainers, vm }) {
const { _warmMigration } = this._settings
const sr = this._sr
@@ -91,16 +140,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
let targetVmRef
await Task.run({ name: 'transfer' }, async () => {
targetVmRef = await importIncrementalVm(
{
__proto__: deltaExport,
vm: {
...deltaExport.vm,
tags: _warmMigration ? deltaExport.vm.tags : [...deltaExport.vm.tags, 'Continuous Replication'],
},
},
sr
)
targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport), sr)
return {
size: Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0),
}
@@ -121,13 +161,13 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
)
),
targetVm.update_other_config({
'xo:backup:sr': srUuid,
[TAG_BACKUP_SR]: srUuid,
// these entries need to be added in case of offline backup
'xo:backup:datetime': formatDateTime(timestamp),
'xo:backup:job': job.id,
'xo:backup:schedule': scheduleId,
'xo:backup:vm': vm.uuid,
[TAG_BASE_DELTA]: vm.uuid,
}),
])
}

View File

@@ -96,6 +96,9 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
metadata,
srUuid,
xapi,
settings: {
additionnalVmTag: 'xo:no-bak=Health Check',
},
}).run()
const restoredVm = xapi.getObject(restoredId)
try {

View File

@@ -58,7 +58,7 @@ export const MixinXapiWriter = (BaseClass = Object) =>
)
}
const healthCheckVm = xapi.getObject(healthCheckVmRef) ?? (await xapi.waitObject(healthCheckVmRef))
await healthCheckVm.add_tag('xo:no-bak=Health Check')
await new HealthCheckVmBackup({
restoredVm: healthCheckVm,
xapi,

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.43.1",
"version": "0.44.2",
"engines": {
"node": ">=14.18"
},
@@ -23,12 +23,12 @@
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/disposable": "^0.1.5",
"@vates/fuse-vhd": "^2.0.0",
"@vates/nbd-client": "^2.0.0",
"@vates/nbd-client": "^2.0.1",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.1",
"@xen-orchestra/fs": "^4.1.3",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"app-conf": "^2.3.0",
@@ -44,19 +44,19 @@
"proper-lockfile": "^4.1.2",
"tar": "^6.1.15",
"uuid": "^9.0.0",
"vhd-lib": "^4.6.1",
"xen-api": "^1.3.6",
"vhd-lib": "^4.7.0",
"xen-api": "^2.0.0",
"yazl": "^2.5.1"
},
"devDependencies": {
"fs-extra": "^11.1.0",
"rimraf": "^5.0.1",
"sinon": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^3.3.0"
"@xen-orchestra/xapi": "^4.0.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -1,11 +1,10 @@
#!/usr/bin/env node
'use strict'
import { defer } from 'golike-defer'
import { readFileSync } from 'fs'
import { Ref, Xapi } from 'xen-api'
const { Ref, Xapi } = require('xen-api')
const { defer } = require('golike-defer')
const pkg = require('./package.json')
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)))
Xapi.prototype.getVmDisks = async function (vm) {
const disks = { __proto__: null }

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/cr-seed-cli",
"version": "0.2.0",
"version": "1.0.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/cr-seed-cli",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
@@ -10,15 +10,15 @@
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"engines": {
"node": ">=8"
"node": ">=10"
},
"bin": {
"xo-cr-seed": "./index.js"
"xo-cr-seed": "./index.mjs"
},
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^1.3.6"
"xen-api": "^2.0.0"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -42,7 +42,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.2.1"
}
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.1.1",
"version": "4.1.3",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -53,7 +53,7 @@
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^5.0.1",
"sinon": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.3.0",
"tmp": "^0.2.1"
},

View File

@@ -33,7 +33,7 @@ import { pRetry } from 'promise-toolbox'
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PART_NUMBER = 10000
const MIN_PART_SIZE = 5 * 1024 * 1024
const { warn } = createLogger('xo:fs:s3')
const { debug, info, warn } = createLogger('xo:fs:s3')
export default class S3Handler extends RemoteHandlerAbstract {
#bucket
@@ -453,10 +453,18 @@ export default class S3Handler extends RemoteHandlerAbstract {
if (res.ObjectLockConfiguration?.ObjectLockEnabled === 'Enabled') {
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
// will automatically add the contentMD5 header to any upload to S3
debug(`Object Lock is enable, enable content md5 header`)
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
}
} catch (error) {
if (error.Code !== 'ObjectLockConfigurationNotFoundError') {
// maybe the account doesn't have enought privilege to query the object lock configuration
// be defensive and apply the md5 just in case
if (error.$metadata.httpStatusCode === 403) {
info(`s3 user doesnt have enough privilege to check for Object Lock, enable content MD5 header`)
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
} else if (error.Code === 'ObjectLockConfigurationNotFoundError' || error.$metadata.httpStatusCode === 501) {
info(`Object lock is not available or not configured, don't add the content MD5 header`)
} else {
throw error
}
}

View File

@@ -2,9 +2,26 @@
## **next**
- [VM/Action] Ability to migrate a VM from its view (PR [#7164](https://github.com/vatesfr/xen-orchestra/pull/7164))
- Ability to override host address with `master` URL query param (PR [#7187](https://github.com/vatesfr/xen-orchestra/pull/7187))
- Added tooltip on CPU provisioning warning icon (PR [#7223](https://github.com/vatesfr/xen-orchestra/pull/7223))
- Add indeterminate state on FormToggle component (PR [#7230](https://github.com/vatesfr/xen-orchestra/pull/7230))
- Add new UiStatusPanel component (PR [#7227](https://github.com/vatesfr/xen-orchestra/pull/7227))
## **0.1.6** (2023-11-30)
- Explicit error if users attempt to connect from a slave host (PR [#7110](https://github.com/vatesfr/xen-orchestra/pull/7110))
- More compact UI (PR [#7159](https://github.com/vatesfr/xen-orchestra/pull/7159))
- Fix dashboard host patches list (PR [#7169](https://github.com/vatesfr/xen-orchestra/pull/7169))
- Ability to export selected VMs (PR [#7174](https://github.com/vatesfr/xen-orchestra/pull/7174))
- [VM/Action] Ability to export a VM from its view (PR [#7190](https://github.com/vatesfr/xen-orchestra/pull/7190))
## **0.1.5** (2023-11-07)
- Ability to snapshot/copy a VM from its view (PR [#7087](https://github.com/vatesfr/xen-orchestra/pull/7087))
- [Header] Replace logo with "XO LITE" (PR [#7118](https://github.com/vatesfr/xen-orchestra/pull/7118))
- New VM console toolbar + Ability to send Ctrl+Alt+Del (PR [#7088](https://github.com/vatesfr/xen-orchestra/pull/7088))
- Total overhaul of the modal system (PR [#7134](https://github.com/vatesfr/xen-orchestra/pull/7134))
## **0.1.4** (2023-10-03)

View File

@@ -0,0 +1,111 @@
# Modal System Documentation
## Opening a modal
To open a modal, call `useModal(loader, props?)`.
- `loader`: The modal component loader (e.g. `() => import("path/to/MyModal.vue")`)
- `props`: The optional props to pass to the modal component
This will return an object with the following methods:
- `onApprove(cb)`:
- A function to register a callback to be called when the modal is approved.
- The callback will receive the modal payload as first argument, if any.
- The callback can return a Promise, in which case the modal will wait for it to resolve before closing.
- `onDecline(cb)`:
- A function to register a callback to be called when the modal is declined.
- The callback can return a Promise, in which case the modal will wait for it to resolve before closing.
### Static modal
```ts
useModal(MyModal);
```
### Modal with props
```ts
useModal(MyModal, { message: "Hello world!" });
```
### Handle modal approval
```ts
const { onApprove } = useModal(MyModal, { message: "Hello world!" });
onApprove(() => console.log("Modal approved"));
```
### Handle modal approval with payload
```ts
const { onApprove } = useModal(MyModal, { message: "Hello world!" });
onApprove((payload) => console.log("Modal approved with payload", payload));
```
### Handle modal decline
```ts
const { onDecline } = useModal(MyModal, { message: "Hello world!" });
onDecline(() => console.log("Modal declined"));
```
### Handle modal close
```ts
const { onClose } = useModal(MyModal, { message: "Hello world!" });
onClose(() => console.log("Modal closed"));
```
## Modal controller
Inside the modal component, you can inject the modal controller with `inject(IK_MODAL)!`.
```ts
const modal = inject(IK_MODAL)!;
```
You can then use the following properties and methods on the `modal` object:
- `isBusy`: Whether the modal is currently doing something (e.g. waiting for a promise to resolve).
- `approve(payload?: any | Promise<any>)`: Approve the modal with an optional payload.
- Set `isBusy` to `true`.
- Wait for the `payload` to resolve (if any).
- Wait for all callbacks registered with `onApprove` to resolve (if any).
- Close the modal in case of success.
- `decline()`: Decline the modal.
- Set `isBusy` to `true`.
- Wait for all callbacks registered with `onDecline` to resolve (if any).
- Close the modal in case of success.
## Components
Some components are available for quick modal creation.
### `UiModal`
The root component of the modal which will display the backdrop.
A click on the backdrop will execute `modal.decline()`.
It accepts `color` and `disabled` props which will update the `ColorContext` and `DisabledContext`.
`DisabledContext` will also be set to `true` when `modal.isBusy` is `true`.
The component itself is a `form` and is meant to be used with `<UiModal @submit.prevent="modal.approve()">`.
### `ModalApproveButton`
A wrapper around `UiButton` with `type="submit"` and with the `busy` prop set to `modal.isBusy`.
### `ModalDeclineButton`
A wrapper around `UiButton` with an `outline` prop and with the `busy` prop set to `modal.isBusy`.
This button will call `modal.decline()` on click.
Default text is `$t("cancel")`.

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.1.4",
"version": "0.1.6",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
@@ -10,57 +10,55 @@
"test": "yarn run type-check",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"devDependencies": {
"@fontsource/poppins": "^5.0.8",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@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",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-regular-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@intlify/unplugin-vue-i18n": "^1.5.0",
"@limegrass/eslint-plugin-import-alias": "^1.1.0",
"@novnc/novnc": "^1.4.0",
"@rushstack/eslint-patch": "^1.5.1",
"@tsconfig/node18": "^18.2.2",
"@types/d3-time-format": "^4.0.3",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.11",
"@types/node": "^18.18.9",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.4.0",
"@vueuse/core": "^10.5.0",
"@vueuse/math": "^10.5.0",
"complex-matcher": "^0.7.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"echarts": "^5.4.3",
"eslint-plugin-vue": "^9.18.1",
"file-saver": "^2.0.5",
"highlight.js": "^11.6.0",
"human-format": "^1.1.0",
"highlight.js": "^11.9.0",
"human-format": "^1.2.0",
"iterable-backoff": "^0.1.0",
"json-rpc-2.0": "^1.3.0",
"json5": "^2.2.1",
"json-rpc-2.0": "^1.7.0",
"json5": "^2.2.3",
"limit-concurrency-decorator": "^0.5.0",
"lodash-es": "^4.17.21",
"make-error": "^1.3.6",
"marked": "^4.2.12",
"pinia": "^2.1.2",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.3.4",
"vue-echarts": "^6.2.3",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.1"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^0.10.0",
"@limegrass/eslint-plugin-import-alias": "^1.0.5",
"@rushstack/eslint-patch": "^1.1.0",
"@types/node": "^16.11.41",
"@vitejs/plugin-vue": "^4.2.3",
"@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",
"marked": "^9.1.5",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.19",
"postcss-custom-media": "^9.0.1",
"postcss-nested": "^6.0.0",
"typescript": "^4.9.3",
"vite": "^4.3.8",
"vue-tsc": "^1.6.5"
"pinia": "^2.1.7",
"placement.js": "^1.0.0-beta.5",
"postcss": "^8.4.31",
"postcss-custom-media": "^10.0.2",
"postcss-nested": "^6.0.1",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vue": "^3.3.8",
"vue-echarts": "^6.6.1",
"vue-i18n": "^9.6.5",
"vue-router": "^4.2.5",
"vue-tsc": "^1.8.22"
},
"private": true,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/lite",
@@ -76,6 +74,6 @@
},
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=8.10"
"node": ">=18"
}
}

View File

@@ -1,5 +1,4 @@
<template>
<UnreachableHostsModal />
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin />
</div>
@@ -13,6 +12,7 @@
</div>
<AppTooltips />
</div>
<ModalList />
</template>
<script lang="ts" setup>
@@ -21,8 +21,9 @@ import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import ModalList from "@/components/ui/modals/ModalList.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useUnreachableHosts } from "@/composables/unreachable-hosts.composable";
import { useUiStore } from "@/stores/ui.store";
import { usePoolCollection } from "@/stores/xen-api/pool.store";
import { useXenApiStore } from "@/stores/xen-api.store";
@@ -78,6 +79,8 @@ whenever(
xenApiStore.getXapi().startWatching(poolRef);
}
);
useUnreachableHosts();
</script>
<style lang="postcss">
@@ -88,7 +91,7 @@ whenever(
.main {
overflow: auto;
flex: 1;
height: calc(100vh - 8rem);
height: calc(100vh - 5.5rem);
background-color: var(--background-color-secondary);
&.no-ui {

View File

@@ -12,6 +12,7 @@
</RouterLink>
<slot />
<div class="right">
<PoolOverrideWarning as-tooltip />
<AccountButton />
</div>
</header>
@@ -19,6 +20,7 @@
<script lang="ts" setup>
import AccountButton from "@/components/AccountButton.vue";
import PoolOverrideWarning from "@/components/PoolOverrideWarning.vue";
import TextLogo from "@/components/TextLogo.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
@@ -38,7 +40,7 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
display: flex;
align-items: center;
justify-content: space-between;
min-height: 8rem;
height: 5.5rem;
padding: 1rem;
border-bottom: 0.1rem solid var(--color-blue-scale-400);
background-color: var(--background-color-secondary);
@@ -48,7 +50,12 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
}
.text-logo {
margin: 1rem;
margin-left: 1rem;
vertical-align: middle;
}
.warning-not-current-pool {
font-size: 2.4rem;
}
}

View File

@@ -2,27 +2,35 @@
<div class="app-login form-container">
<form @submit.prevent="handleSubmit">
<img alt="XO Lite" src="../assets/logo-title.svg" />
<FormInputWrapper>
<FormInput v-model="login" name="login" readonly type="text" />
</FormInputWrapper>
<FormInputWrapper :error="error">
<PoolOverrideWarning />
<p v-if="isHostIsSlaveErr(error)" class="error">
<UiIcon :icon="faExclamationCircle" />
{{ $t("login-only-on-master") }}
<a :href="masterUrl.href">{{ masterUrl.hostname }}</a>
</p>
<template v-else>
<FormInputWrapper>
<FormInput v-model="login" name="login" readonly type="text" />
</FormInputWrapper>
<FormInput
name="password"
ref="passwordRef"
type="password"
v-model="password"
:class="{ error: isInvalidPassword }"
:placeholder="$t('password')"
:readonly="isConnecting"
required
/>
</FormInputWrapper>
<label class="remember-me-label">
<FormCheckbox v-model="rememberMe" />
<p>{{ $t("keep-me-logged") }}</p>
</label>
<UiButton type="submit" :busy="isConnecting">
{{ $t("login") }}
</UiButton>
<LoginError :error="error" />
<label class="remember-me-label">
<FormCheckbox v-model="rememberMe" />
{{ $t("keep-me-logged") }}
</label>
<UiButton type="submit" :busy="isConnecting">
{{ $t("login") }}
</UiButton>
</template>
</form>
</div>
</template>
@@ -32,12 +40,17 @@ import { usePageTitleStore } from "@/stores/page-title.store";
import { storeToRefs } from "pinia";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useLocalStorage } from "@vueuse/core";
import { useLocalStorage, whenever } from "@vueuse/core";
import FormCheckbox from "@/components/form/FormCheckbox.vue";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import LoginError from "@/components/LoginError.vue";
import PoolOverrideWarning from "@/components/PoolOverrideWarning.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { XenApiError } from "@/libs/xen-api/xen-api.types";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { useXenApiStore } from "@/stores/xen-api.store";
const { t } = useI18n();
@@ -46,12 +59,15 @@ const xenApiStore = useXenApiStore();
const { isConnecting } = storeToRefs(xenApiStore);
const login = ref("root");
const password = ref("");
const error = ref<string>();
const error = ref<XenApiError>();
const passwordRef = ref<InstanceType<typeof FormInput>>();
const isInvalidPassword = ref(false);
const masterUrl = ref(new URL(window.origin));
const rememberMe = useLocalStorage("rememberMe", false);
const focusPasswordInput = () => passwordRef.value?.focus();
const isHostIsSlaveErr = (err: XenApiError | undefined) =>
err?.message === "HOST_IS_SLAVE";
onMounted(() => {
if (rememberMe.value) {
@@ -66,18 +82,23 @@ watch(password, () => {
error.value = undefined;
});
whenever(
() => isHostIsSlaveErr(error.value),
() => (masterUrl.value.hostname = error.value!.data)
);
async function handleSubmit() {
try {
await xenApiStore.connect(login.value, password.value);
} catch (err) {
if ((err as Error).message === "SESSION_AUTHENTICATION_FAILED") {
} catch (err: any) {
if (err.message === "SESSION_AUTHENTICATION_FAILED") {
focusPasswordInput();
isInvalidPassword.value = true;
error.value = t("password-invalid");
} else {
error.value = t("error-occurred");
console.error(err);
console.error(error);
}
error.value = err;
}
}
</script>
@@ -85,14 +106,11 @@ async function handleSubmit() {
<style lang="postcss" scoped>
.remember-me-label {
cursor: pointer;
display: flex;
margin: 1rem;
width: fit-content;
& .form-checkbox {
margin: 1rem 1rem 1rem 0;
vertical-align: middle;
}
& p {
display: inline;
vertical-align: middle;
margin: auto 1rem auto auto;
}
}
@@ -116,6 +134,10 @@ form {
margin: 0 auto;
padding: 8.5rem;
background-color: var(--background-color-secondary);
.error {
color: var(--color-red-vates-base);
}
}
h1 {

View File

@@ -48,7 +48,7 @@ whenever(isOpen, () => {
overflow: auto;
width: 37rem;
max-width: 37rem;
height: calc(100vh - 8rem);
height: calc(100vh - 5.5rem);
padding: 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);

View File

@@ -3,79 +3,27 @@
<UiFilter
v-for="filter in activeFilters"
:key="filter"
@edit="editFilter(filter)"
@edit="openModal(filter)"
@remove="emit('removeFilter', filter)"
>
{{ filter }}
</UiFilter>
<UiActionButton :icon="faPlus" class="add-filter" @click="open">
<UiActionButton :icon="faPlus" class="add-filter" @click="openModal()">
{{ $t("add-filter") }}
</UiActionButton>
</UiFilterGroup>
<UiModal v-model="isOpen">
<ConfirmModalLayout @submit.prevent="handleSubmit">
<template #default>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"
:key="newFilter.id"
v-model="newFilters[index]"
:available-filters="availableFilters"
@remove="removeNewFilter"
/>
</div>
<div
v-if="newFilters.some((filter) => filter.isAdvanced)"
class="available-properties"
>
{{ $t("available-properties-for-advanced-filter") }}
<div class="properties">
<UiBadge
v-for="(filter, property) in availableFilters"
:key="property"
:icon="getFilterIcon(filter)"
>
{{ property }}
</UiBadge>
</div>
</div>
</template>
<template #buttons>
<UiButton transparent @click="addNewFilter">
{{ $t("add-or") }}
</UiButton>
<UiButton :disabled="!isFilterValid" type="submit">
{{ $t(editedFilter ? "update" : "add") }}
</UiButton>
<UiButton outlined @click="close">
{{ $t("cancel") }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import UiBadge from "@/components/ui/UiBadge.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import useModal from "@/composables/modal.composable";
import { getFilterIcon } from "@/libs/utils";
import type { Filters, NewFilter } from "@/types/filter";
import { useModal } from "@/composables/modal.composable";
import type { Filters } from "@/types/filter";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { Or, parse } from "complex-matcher";
import { computed, ref } from "vue";
defineProps<{
const props = defineProps<{
activeFilters: string[];
availableFilters: Filters;
}>();
@@ -85,110 +33,19 @@ const emit = defineEmits<{
(event: "removeFilter", filter: string): void;
}>();
const { isOpen, open, close } = useModal({ onClose: () => reset() });
const newFilters = ref<NewFilter[]>([]);
let newFilterId = 0;
const addNewFilter = () =>
newFilters.value.push({
id: newFilterId++,
content: "",
isAdvanced: false,
builder: { property: "", comparison: "", value: "", negate: false },
});
const removeNewFilter = (id: number) => {
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
if (index >= 0) {
newFilters.value.splice(index, 1);
}
};
addNewFilter();
const generatedFilter = computed(() => {
const filters = newFilters.value.filter(
(newFilter) => newFilter.content !== ""
const openModal = (editedFilter?: string) => {
const { onApprove } = useModal<string>(
() => import("@/components/modals/CollectionFilterModal.vue"),
{
availableFilters: props.availableFilters,
editedFilter,
}
);
if (filters.length === 0) {
return "";
if (editedFilter !== undefined) {
onApprove(() => emit("removeFilter", editedFilter));
}
if (filters.length === 1) {
return filters[0].content;
}
return `|(${filters.map((filter) => filter.content).join(" ")})`;
});
const isFilterValid = computed(() => generatedFilter.value !== "");
const editedFilter = ref();
const editFilter = (filter: string) => {
const parsedFilter = parse(filter);
const nodes =
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
newFilters.value = nodes.map((node) => ({
id: newFilterId++,
content: node.toString(),
isAdvanced: true,
builder: { property: "", comparison: "", value: "", negate: false },
}));
editedFilter.value = filter;
open();
};
const reset = () => {
editedFilter.value = "";
newFilters.value = [];
addNewFilter();
};
const handleSubmit = () => {
if (editedFilter.value) {
emit("removeFilter", editedFilter.value);
}
emit("addFilter", generatedFilter.value);
reset();
close();
onApprove((newFilter) => emit("addFilter", newFilter));
};
</script>
<style lang="postcss" scoped>
.properties {
font-size: 1.6rem;
margin-top: 1rem;
ul {
margin-left: 1rem;
}
li {
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
}
.available-properties {
margin-top: 1rem;
}
.properties {
display: flex;
margin-top: 0.6rem;
gap: 0.5rem;
}
.rows {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@@ -12,65 +12,27 @@
</span>
</UiFilter>
<UiActionButton :icon="faPlus" class="add-sort" @click="open">
<UiActionButton :icon="faPlus" class="add-sort" @click="openModal()">
{{ $t("add-sort") }}
</UiActionButton>
</UiFilterGroup>
<UiModal v-model="isOpen">
<ConfirmModalLayout @submit.prevent="handleSubmit">
<template #default>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">
<option v-if="!newSortProperty"></option>
<option
v-for="(sort, property) in availableSorts"
:key="property"
:value="property"
>
{{ sort.label ?? property }}
</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newSortIsAscending">
<option :value="true">{{ $t("ascending") }}</option>
<option :value="false">{{ $t("descending") }}</option>
</select>
</FormWidget>
</div>
</template>
<template #buttons>
<UiButton type="submit">{{ $t("add") }}</UiButton>
<UiButton outlined @click="close">
{{ $t("cancel") }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormWidget from "@/components/FormWidget.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import useModal from "@/composables/modal.composable";
import type { ActiveSorts, Sorts } from "@/types/sort";
import { useModal } from "@/composables/modal.composable";
import type { ActiveSorts, NewSort, Sorts } from "@/types/sort";
import {
faCaretDown,
faCaretUp,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
import { ref } from "vue";
import { computed } from "vue";
defineProps<{
const props = defineProps<{
availableSorts: Sorts;
activeSorts: ActiveSorts<Record<string, any>>;
}>();
@@ -81,29 +43,19 @@ const emit = defineEmits<{
(event: "removeSort", property: string): void;
}>();
const { isOpen, open, close } = useModal({ onClose: () => reset() });
const openModal = () => {
const { onApprove } = useModal<NewSort>(
() => import("@/components/modals/CollectionSorterModal.vue"),
{ availableSorts: computed(() => props.availableSorts) }
);
const newSortProperty = ref();
const newSortIsAscending = ref<boolean>(true);
const reset = () => {
newSortProperty.value = undefined;
newSortIsAscending.value = true;
};
const handleSubmit = () => {
emit("addSort", newSortProperty.value, newSortIsAscending.value);
reset();
close();
onApprove(({ property, isAscending }) =>
emit("addSort", property, isAscending)
);
};
</script>
<style lang="postcss" scoped>
.form-widgets {
display: flex;
gap: 1rem;
}
.property {
display: inline-flex;
align-items: center;

View File

@@ -2,26 +2,25 @@
<UiCardSpinner v-if="!areSomeLoaded" />
<UiTable v-else class="hosts-patches-table" :class="{ desktop: isDesktop }">
<tr v-for="patch in sortedPatches" :key="patch.$id">
<th>{{ patch.name }}</th>
<td>
<div class="version">
{{ patch.version }}
<template v-if="hasMultipleHosts">
<UiSpinner v-if="!areAllLoaded" />
<UiCounter
v-else
v-tooltip="{
placement: 'left',
content: $t('n-hosts-awaiting-patch', {
n: patch.$hostRefs.size,
}),
}"
:value="patch.$hostRefs.size"
class="counter"
color="error"
/>
</template>
</div>
<th>
<span v-tooltip="{ placement: 'left', content: patch.version }">
{{ patch.name }}
</span>
</th>
<td v-if="hasMultipleHosts">
<UiSpinner v-if="!areAllLoaded" />
<UiCounter
v-else
v-tooltip="{
placement: 'left',
content: $t('n-hosts-awaiting-patch', {
n: patch.$hostRefs.size,
}),
}"
:value="patch.$hostRefs.size"
class="counter"
color="error"
/>
</td>
</tr>
</UiTable>
@@ -45,9 +44,15 @@ const props = defineProps<{
}>();
const sortedPatches = computed(() =>
[...props.patches].sort(
(patch1, patch2) => patch1.changelog.date - patch2.changelog.date
)
[...props.patches].sort((patch1, patch2) => {
if (patch1.changelog == null) {
return 1;
} else if (patch2.changelog == null) {
return -1;
}
return patch1.changelog.date - patch2.changelog.date;
})
);
const { isDesktop } = useUiStore();
@@ -58,13 +63,6 @@ const { isDesktop } = useUiStore();
max-width: 45rem;
}
.version {
display: flex;
gap: 1rem;
justify-content: flex-end;
align-items: center;
}
.counter {
font-size: 1rem;
}

View File

@@ -0,0 +1,34 @@
<template>
<div class="error" v-if="error !== undefined">
<UiIcon :icon="faExclamationCircle" />
<span v-if="error.message === 'SESSION_AUTHENTICATION_FAILED'">
{{ $t("password-invalid") }}
</span>
<span v-else>
{{ $t("error-occurred") }}
</span>
</div>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { XenApiError } from "@/libs/xen-api/xen-api.types";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
defineProps<{
error: XenApiError | undefined;
}>();
</script>
<style lang="postcss" scoped>
.error {
font-size: 1.3rem;
line-height: 150%;
margin: 0.5rem 0;
color: var(--color-red-vates-base);
& svg {
margin-right: 0.5rem;
}
}
</style>

View File

@@ -1,49 +1,28 @@
<template>
<div class="page-under-construction">
<img alt="Under construction" src="@/assets/under-construction.svg" />
<p class="title">{{ $t("xo-lite-under-construction") }}</p>
<p class="subtitle">{{ $t("new-features-are-coming") }}</p>
<UiStatusPanel
:image-source="underConstruction"
:subtitle="$t('new-features-are-coming')"
:title="$t('xo-lite-under-construction')"
>
<p class="contact">
{{ $t("do-you-have-needs") }}
<a
href="https://xcp-ng.org/forum/topic/5018/xo-lite-building-an-embedded-ui-in-xcp-ng"
target="_blank"
rel="noopener noreferrer"
target="_blank"
>
{{ $t("here") }}
</a>
</p>
</div>
</UiStatusPanel>
</template>
<script lang="ts" setup>
import underConstruction from "@/assets/under-construction.svg";
import UiStatusPanel from "@/components/ui/UiStatusPanel.vue";
</script>
<style lang="postcss" scoped>
.page-under-construction {
width: 100%;
min-height: 76.5vh;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-extra-blue-base);
}
img {
margin-bottom: 40px;
width: 30%;
}
.title {
font-weight: 400;
font-size: 36px;
text-align: center;
}
.subtitle {
font-weight: 500;
font-size: 24px;
margin: 21px 0;
text-align: center;
}
.contact {
font-weight: 400;
font-size: 20px;

View File

@@ -0,0 +1,59 @@
<template>
<div
v-if="xenApi.isPoolOverridden"
class="warning-not-current-pool"
@click="xenApi.resetPoolMasterIp"
v-tooltip="
asTooltip && {
placement: 'right',
content: `
${$t('you-are-currently-on', [masterSessionStorage])}.
${$t('click-to-return-default-pool')}
`,
}
"
>
<div class="wrapper">
<UiIcon :icon="faWarning" />
<p v-if="!asTooltip">
<i18n-t keypath="you-are-currently-on">
<strong>{{ masterSessionStorage }}</strong>
</i18n-t>
<br />
{{ $t("click-to-return-default-pool") }}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { useSessionStorage } from "@vueuse/core";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
import { vTooltip } from "@/directives/tooltip.directive";
defineProps<{
asTooltip?: boolean;
}>();
const xenApi = useXenApiStore();
const masterSessionStorage = useSessionStorage("master", null);
</script>
<style lang="postcss" scoped>
.warning-not-current-pool {
color: var(--color-orange-world-base);
cursor: pointer;
.wrapper {
display: flex;
justify-content: center;
svg {
margin: auto 1rem;
}
}
}
</style>

View File

@@ -27,20 +27,20 @@ defineProps<{
.title-bar {
display: flex;
align-items: center;
height: 8rem;
padding: 0 2rem;
height: 6rem;
padding: 0 1.5rem;
border-bottom: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
gap: 1.5rem;
gap: 0.8rem;
}
.icon {
font-size: 3.8rem;
font-size: 2.5rem;
color: var(--color-extra-blue-base);
}
.title {
font-size: 3rem;
font-size: 2.5rem;
color: var(--color-blue-scale-100);
}
</style>

View File

@@ -1,76 +0,0 @@
<template>
<UiModal v-model="isSslModalOpen" color="error">
<ConfirmModalLayout :icon="faServer">
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #default>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
</template>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="closeSslModal">{{ $t("cancel") }}</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import useModal from "@/composables/modal.composable";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { difference } from "lodash-es";
import { ref, watch } from "vue";
const { records: hosts } = useHostCollection();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const reload = () => window.location.reload();
const { isOpen: isSslModalOpen, close: closeSslModal } = useModal({
onClose: () => unreachableHostsUrls.value.clear(),
});
watch(
() => unreachableHostsUrls.value.size,
(size) => {
isSslModalOpen.value = size > 0;
},
{ immediate: true }
);
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
</script>
<style lang="postcss" scoped>
.description {
text-align: center;
p {
margin: 1rem 0;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="usage-bar">
<template v-if="data !== undefined">
<div
v-for="item in computedData.sortedArray"
@@ -67,6 +67,12 @@ const computedData = computed(() => {
</script>
<style lang="postcss" scoped>
.usage-bar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.progress-item:nth-child(1) {
--progress-bar-color: var(--color-extra-blue-d60);
}

View File

@@ -1,9 +1,4 @@
<template>
<UiModal v-model="isRawValueModalOpen">
<BasicModalLayout>
<CodeHighlight :code="rawValueModalPayload" />
</BasicModalLayout>
</UiModal>
<StoryParamsTable>
<thead>
<tr>
@@ -101,9 +96,7 @@ import CodeHighlight from "@/components/CodeHighlight.vue";
import StoryParamsTable from "@/components/component-story/StoryParamsTable.vue";
import StoryWidget from "@/components/component-story/StoryWidget.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import useModal from "@/composables/modal.composable";
import { useModal } from "@/composables/modal.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import type { PropParam } from "@/libs/story/story-param";
@@ -131,11 +124,10 @@ const emit = defineEmits<{
const model = useVModel(props, "modelValue", emit);
const {
open: openRawValueModal,
isOpen: isRawValueModalOpen,
payload: rawValueModalPayload,
} = useModal<string>();
const openRawValueModal = (code: string) =>
useModal(() => import("@/components/modals/CodeHighlightModal.vue"), {
code,
});
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,80 @@
<template>
<FormInputGroup>
<FormNumber v-model="sizeInput" :max-decimals="3" />
<FormSelect v-model="prefixInput">
<option
v-for="currentPrefix in availablePrefixes"
:key="currentPrefix"
:value="currentPrefix"
>
{{ currentPrefix }}B
</option>
</FormSelect>
</FormInputGroup>
</template>
<script lang="ts" setup>
import FormInputGroup from "@/components/form/FormInputGroup.vue";
import FormNumber from "@/components/form/FormNumber.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import { useVModel } from "@vueuse/core";
import humanFormat, { type Prefix } from "human-format";
import { ref, watch } from "vue";
const props = defineProps<{
modelValue: number | undefined;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: number): number;
}>();
const availablePrefixes: Prefix<"binary">[] = ["Ki", "Mi", "Gi"];
const model = useVModel(props, "modelValue", emit, {
shouldEmit: (value) => value !== props.modelValue,
});
const sizeInput = ref();
const prefixInput = ref();
const scale = humanFormat.Scale.create(availablePrefixes, 1024, 1);
watch([sizeInput, prefixInput], ([newSize, newPrefix]) => {
if (newSize === "" || newSize === undefined) {
return;
}
model.value = humanFormat.parse(`${newSize || 0} ${newPrefix || "Ki"}`, {
scale,
});
});
watch(
() => props.modelValue,
(newValue) => {
if (newValue === undefined) {
sizeInput.value = undefined;
if (prefixInput.value === undefined) {
prefixInput.value = availablePrefixes[0];
}
return;
}
const { value, prefix } = humanFormat.raw(newValue, {
scale,
prefix: prefixInput.value,
});
console.log(value);
sizeInput.value = value;
if (value !== 0) {
prefixInput.value = prefix;
}
},
{ immediate: true }
);
</script>

View File

@@ -6,7 +6,7 @@
>
<input
v-model="value"
:class="{ indeterminate: type === 'checkbox' && value === undefined }"
:class="{ indeterminate: isIndeterminate }"
:disabled="isDisabled"
:type="type === 'radio' ? 'radio' : 'checkbox'"
class="input"
@@ -60,6 +60,10 @@ const icon = computed(() => {
return faCheck;
});
const isIndeterminate = computed(
() => (type === "checkbox" || type === "toggle") && value.value === undefined
);
</script>
<style lang="postcss" scoped>
@@ -127,6 +131,12 @@ const icon = computed(() => {
.input:checked + .fake-checkbox > .icon {
transform: translateX(0.7em);
}
.input.indeterminate + .fake-checkbox > .icon {
opacity: 1;
color: var(--color-blue-scale-300);
transform: translateX(0);
}
}
.input {

View File

@@ -273,6 +273,7 @@ defineExpose({
.textarea {
height: auto;
min-height: 2em;
overflow: hidden;
}
.input {

View File

@@ -1,46 +1,18 @@
<template>
<UiModal
v-model="isCodeModalOpen"
:color="isJsonValid ? 'success' : 'error'"
closable
>
<FormModalLayout @submit.prevent="saveJson" :icon="faCode">
<template #default>
<FormTextarea class="modal-textarea" v-model="editedJson" />
</template>
<template #buttons>
<UiButton transparent @click="formatJson">
{{ $t("reformat") }}
</UiButton>
<UiButton outlined @click="closeCodeModal">
{{ $t("cancel") }}
</UiButton>
<UiButton :disabled="!isJsonValid" type="submit">
{{ $t("save") }}
</UiButton>
</template>
</FormModalLayout>
</UiModal>
<FormInput
@click="openCodeModal"
:model-value="jsonValue"
:before="faCode"
:model-value="jsonValue"
readonly
@click="openModal()"
/>
</template>
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
import FormTextarea from "@/components/form/FormTextarea.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import useModal from "@/composables/modal.composable";
import { useModal } from "@/composables/modal.composable";
import { faCode } from "@fortawesome/free-solid-svg-icons";
import { useVModel, whenever } from "@vueuse/core";
import { computed, ref } from "vue";
import { useVModel } from "@vueuse/core";
import { computed } from "vue";
const props = defineProps<{
modelValue: any;
@@ -52,51 +24,14 @@ const emit = defineEmits<{
const model = useVModel(props, "modelValue", emit);
const {
open: openCodeModal,
close: closeCodeModal,
isOpen: isCodeModalOpen,
} = useModal();
const jsonValue = computed(() => JSON.stringify(model.value, undefined, 2));
const isJsonValid = computed(() => {
try {
JSON.parse(editedJson.value);
return true;
} catch {
return false;
}
});
const openModal = () => {
const { onApprove } = useModal<string>(
() => import("@/components/modals/JsonEditorModal.vue"),
{ initialValue: jsonValue.value }
);
const formatJson = () => {
if (!isJsonValid.value) {
return;
}
editedJson.value = JSON.stringify(JSON.parse(editedJson.value), undefined, 2);
onApprove((newValue) => (model.value = JSON.parse(newValue)));
};
const saveJson = () => {
if (!isJsonValid.value) {
return;
}
formatJson();
model.value = JSON.parse(editedJson.value);
closeCodeModal();
};
whenever(isCodeModalOpen, () => (editedJson.value = jsonValue.value));
const editedJson = ref();
</script>
<style lang="postcss" scoped>
:deep(.modal-textarea) {
min-width: 50rem;
min-height: 20rem;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<FormInput v-model="localValue" inputmode="decimal" />
</template>
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
import { computed, ref, watch } from "vue";
const props = defineProps<{
modelValue: number | undefined;
maxDecimals?: number;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: number | undefined): void;
}>();
const localValue = ref("");
const hasTrailingDot = ref(false);
const cleaningRegex = computed(() => {
if (props.maxDecimals === undefined) {
// Any number with optional decimal part
return /(\d*\.?\d*)/;
}
if (props.maxDecimals > 0) {
// Numbers with up to `props.maxDecimals` decimal places
return new RegExp(`(\\d*\\.?\\d{0,${props.maxDecimals}})`);
}
// Integer numbers only
return /(\d*)/;
});
watch(
localValue,
(newLocalValue) => {
const cleanValue =
localValue.value
.replace(",", ".")
.replace(/[^0-9.]/g, "")
.match(cleaningRegex.value)?.[0] ?? "";
hasTrailingDot.value = cleanValue.endsWith(".");
if (cleanValue !== newLocalValue) {
localValue.value = cleanValue;
return;
}
if (newLocalValue === "") {
emit("update:modelValue", undefined);
return;
}
const parsedValue = parseFloat(cleanValue);
emit(
"update:modelValue",
Number.isNaN(parsedValue) ? undefined : parsedValue
);
},
{ flush: "post" }
);
watch(
() => props.modelValue,
(newModelValue) => {
localValue.value = `${newModelValue?.toString() ?? ""}${
hasTrailingDot.value ? "." : ""
}`;
},
{ immediate: true }
);
</script>

View File

@@ -63,12 +63,12 @@ const [isExpanded, toggle] = useToggle(true);
<style lang="postcss" scoped>
.infra-host-item:deep(.link),
.infra-host-item:deep(.link-placeholder) {
padding-left: 3rem;
padding-left: 2rem;
}
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 4.5rem;
padding-left: 3rem;
}
.master-icon {

View File

@@ -72,7 +72,7 @@ const hasTooltip = computed(() => hasEllipsis(textElement.value));
align-items: center;
flex: 1;
min-width: 0;
padding: 1.5rem;
padding: 1rem;
text-decoration: none;
color: inherit;
gap: 1rem;

View File

@@ -42,7 +42,7 @@ const { isReady, hasError, pool } = usePoolCollection();
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 3rem;
padding-left: 2rem;
}
.text-error {

View File

@@ -43,10 +43,6 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
</script>
<style lang="postcss" scoped>
.infra-vm-item {
height: 6rem;
}
.infra-action {
color: var(--color-extra-blue-d60);

View File

@@ -0,0 +1,17 @@
<template>
<UiModal>
<BasicModalLayout>
<CodeHighlight :code="code" />
</BasicModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import CodeHighlight from "@/components/CodeHighlight.vue";
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
defineProps<{
code: any;
}>();
</script>

View File

@@ -0,0 +1,154 @@
<template>
<UiModal @submit.prevent="modal.approve(generatedFilter)">
<ConfirmModalLayout>
<template #default>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"
:key="newFilter.id"
v-model="newFilters[index]"
:available-filters="availableFilters"
@remove="removeNewFilter($event)"
/>
</div>
<div
v-if="newFilters.some((filter) => filter.isAdvanced)"
class="available-properties"
>
{{ $t("available-properties-for-advanced-filter") }}
<div class="properties">
<UiBadge
v-for="(filter, property) in availableFilters"
:key="property"
:icon="getFilterIcon(filter)"
>
{{ property }}
</UiBadge>
</div>
</div>
</template>
<template #buttons>
<UiButton transparent @click="addNewFilter()">
{{ $t("add-or") }}
</UiButton>
<ModalDeclineButton />
<ModalApproveButton :disabled="!isFilterValid">
{{ $t(editedFilter ? "update" : "add") }}
</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiBadge from "@/components/ui/UiBadge.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { getFilterIcon } from "@/libs/utils";
import type { Filters, NewFilter } from "@/types/filter";
import { IK_MODAL } from "@/types/injection-keys";
import { Or, parse } from "complex-matcher";
import { computed, inject, onMounted, ref } from "vue";
const props = defineProps<{
availableFilters: Filters;
editedFilter?: string;
}>();
const modal = inject(IK_MODAL)!;
const newFilters = ref<NewFilter[]>([]);
let newFilterId = 0;
const addNewFilter = () =>
newFilters.value.push({
id: newFilterId++,
content: "",
isAdvanced: false,
builder: { property: "", comparison: "", value: "", negate: false },
});
const removeNewFilter = (id: number) => {
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
if (index >= 0) {
newFilters.value.splice(index, 1);
}
};
const generatedFilter = computed(() => {
const filters = newFilters.value.filter(
(newFilter) => newFilter.content !== ""
);
if (filters.length === 0) {
return "";
}
if (filters.length === 1) {
return filters[0].content;
}
return `|(${filters.map((filter) => filter.content).join(" ")})`;
});
const isFilterValid = computed(() => generatedFilter.value !== "");
onMounted(() => {
if (props.editedFilter === undefined) {
addNewFilter();
return;
}
const parsedFilter = parse(props.editedFilter);
const nodes =
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
newFilters.value = nodes.map((node) => ({
id: newFilterId++,
content: node.toString(),
isAdvanced: true,
builder: { property: "", comparison: "", value: "", negate: false },
}));
});
</script>
<style lang="postcss" scoped>
.properties {
font-size: 1.6rem;
margin-top: 1rem;
ul {
margin-left: 1rem;
}
li {
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
}
.available-properties {
margin-top: 1rem;
}
.properties {
display: flex;
margin-top: 0.6rem;
gap: 0.5rem;
}
.rows {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<UiModal @submit.prevent="handleSubmit">
<ConfirmModalLayout>
<template #default>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">
<option v-if="!newSortProperty"></option>
<option
v-for="(sort, property) in availableSorts"
:key="property"
:value="property"
>
{{ sort.label ?? property }}
</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newSortIsAscending">
<option :value="true">{{ $t("ascending") }}</option>
<option :value="false">{{ $t("descending") }}</option>
</select>
</FormWidget>
</div>
</template>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>{{ $t("add") }}</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormWidget from "@/components/FormWidget.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { IK_MODAL } from "@/types/injection-keys";
import type { NewSort, Sorts } from "@/types/sort";
import { inject, ref } from "vue";
defineProps<{
availableSorts: Sorts;
}>();
const newSortProperty = ref();
const newSortIsAscending = ref<boolean>(true);
const modal = inject(IK_MODAL)!;
const handleSubmit = () => {
modal.approve<NewSort>({
property: newSortProperty.value,
isAscending: newSortIsAscending.value,
});
};
</script>
<style lang="postcss" scoped>
.form-widgets {
display: flex;
gap: 1rem;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<UiModal
:color="isJsonValid ? 'success' : 'error'"
@submit.prevent="handleSubmit()"
>
<FormModalLayout :icon="faCode" class="layout">
<template #default>
<FormTextarea v-model="editedJson" class="modal-textarea" />
</template>
<template #buttons>
<UiButton transparent @click="formatJson()">
{{ $t("reformat") }}
</UiButton>
<ModalDeclineButton />
<ModalApproveButton :disabled="!isJsonValid">
{{ $t("save") }}
</ModalApproveButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormTextarea from "@/components/form/FormTextarea.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { faCode } from "@fortawesome/free-solid-svg-icons";
import { computed, inject, ref } from "vue";
const props = defineProps<{
initialValue?: string;
}>();
const editedJson = ref<string>(props.initialValue ?? "");
const modal = inject(IK_MODAL)!;
const isJsonValid = computed(() => {
try {
JSON.parse(editedJson.value);
return true;
} catch {
return false;
}
});
const formatJson = () => {
if (!isJsonValid.value) {
return;
}
editedJson.value = JSON.stringify(JSON.parse(editedJson.value), undefined, 2);
};
const handleSubmit = () => {
if (!isJsonValid.value) {
return;
}
formatJson();
modal.approve(editedJson.value);
};
</script>
<style lang="postcss" scoped>
.layout:deep(.modal-textarea) {
min-width: 50rem;
min-height: 20rem;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<UiModal color="error" @submit="modal.approve()">
<ConfirmModalLayout :icon="faServer">
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #default>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in urls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">
{{ url }}
</a>
</li>
</ul>
</div>
</template>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>
{{ $t("unreachable-hosts-reload-page") }}
</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
defineProps<{
urls: string[];
}>();
const modal = inject(IK_MODAL)!;
</script>
<style lang="postcss" scoped>
.description {
text-align: center;
p {
margin: 1rem 0;
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<UiModal @submit.prevent="handleSubmit()">
<ConfirmModalLayout :icon="faSatellite">
<template #title>
<i18n-t keypath="confirm-delete" scope="global" tag="div">
<span :class="textClass">
{{ $t("n-vms", { n: vmRefs.length }) }}
</span>
</i18n-t>
</template>
<template #subtitle>
{{ $t("please-confirm") }}
</template>
<template #buttons>
<ModalDeclineButton>
{{ $t("go-back") }}
</ModalDeclineButton>
<ModalApproveButton>
{{ $t("delete-vms", { n: vmRefs.length }) }}
</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useXenApiStore } from "@/stores/xen-api.store";
import { IK_MODAL } from "@/types/injection-keys";
import { faSatellite } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const modal = inject(IK_MODAL)!;
const { textClass } = useContext(ColorContext);
const handleSubmit = () => {
const xenApi = useXenApiStore().getXapi();
modal.approve(xenApi.vm.delete(props.vmRefs));
};
</script>

View File

@@ -0,0 +1,56 @@
<template>
<UiModal>
<FormModalLayout :icon="faDisplay">
<template #title>
{{ $t("export-n-vms-manually", { n: labelWithUrl.length }) }}
</template>
<p>
{{ $t("export-vms-manually-information") }}
</p>
<ul class="list">
<li v-for="({ url, label }, index) in labelWithUrl" :key="index">
<a :href="url.href" target="_blank">
{{ label }}
</a>
</li>
</ul>
<template #buttons>
<ModalDeclineButton />
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
blockedUrls: URL[];
}>();
const { getByOpaqueRef } = useVmCollection();
const labelWithUrl = computed(() =>
props.blockedUrls.map((url) => {
const ref = url.searchParams.get("ref") as XenApiVm["$ref"];
return {
url: url,
label: getByOpaqueRef(ref)?.name_label ?? ref,
};
})
);
</script>
<style lang="postcss" scoped>
.list {
margin-top: 2rem;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<UiModal @submit.prevent="handleSubmit">
<FormModalLayout :icon="faDisplay">
<template #title>
{{ $t("export-n-vms", { n: vmRefs.length }) }}
</template>
<FormInputWrapper
light
learn-more-url="https://xcp-ng.org/blog/2018/12/19/zstd-compression-for-xcp-ng/"
:label="$t('select-compression')"
>
<FormSelect v-model="compressionType">
<option
v-for="key in Object.keys(VM_COMPRESSION_TYPE)"
:key="key"
:value="
VM_COMPRESSION_TYPE[key as keyof typeof VM_COMPRESSION_TYPE]
"
>
{{ $t(key.toLowerCase()) }}
</option>
</FormSelect>
</FormInputWrapper>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>
{{ $t("export-n-vms", { n: vmRefs.length }) }}
</ModalApproveButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { inject, ref } from "vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { useXenApiStore } from "@/stores/xen-api.store";
import { VM_COMPRESSION_TYPE } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const modal = inject(IK_MODAL)!;
const compressionType = ref(VM_COMPRESSION_TYPE.DISABLED);
const handleSubmit = () => {
const xenApi = useXenApiStore().getXapi();
xenApi.vm.export(props.vmRefs, compressionType.value);
modal.approve();
};
</script>

View File

@@ -0,0 +1,64 @@
<template>
<UiModal @submit.prevent="handleSubmit()">
<FormModalLayout>
<template #title>
{{ $t("migrate-n-vms", { n: vmRefs.length }) }}
</template>
<div>
<FormInputWrapper :label="$t('select-destination-host')" light>
<FormSelect v-model="selectedHost">
<option :value="undefined">
{{ $t("select-destination-host") }}
</option>
<option
v-for="host in availableHosts"
:key="host.$ref"
:value="host"
>
{{ host.name_label }}
</option>
</FormSelect>
</FormInputWrapper>
</div>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>
{{ $t("migrate-n-vms", { n: vmRefs.length }) }}
</ModalApproveButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { useVmMigration } from "@/composables/vm-migration.composable";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { IK_MODAL } from "@/types/injection-keys";
import { inject } from "vue";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const modal = inject(IK_MODAL)!;
const { selectedHost, availableHosts, isValid, migrate } = useVmMigration(
() => props.vmRefs
);
const handleSubmit = () => {
if (!isValid.value) {
return;
}
modal.approve(migrate());
};
</script>

View File

@@ -82,7 +82,7 @@ const {
}
}
.table-container {
max-height: 24rem;
max-height: 25rem;
overflow: auto;
}
</style>

View File

@@ -3,8 +3,14 @@
<UiCardTitle>
{{ $t("cpu-provisioning") }}
<template v-if="!hasError" #right>
<!-- TODO: add a tooltip for the warning icon -->
<UiStatusIcon v-if="state !== 'success'" :state="state" />
<UiStatusIcon
v-if="state !== 'success'"
v-tooltip="{
content: $t('cpu-provisioning-warning'),
placement: 'left',
}"
:state="state"
/>
</template>
</UiCardTitle>
<NoDataError v-if="hasError" />
@@ -12,7 +18,7 @@
<UiProgressBar :max-value="maxValue" :value="value" color="custom" />
<UiProgressScale :max-value="maxValue" :steps="1" unit="%" />
<UiProgressLegend :label="$t('vcpus')" :value="`${value}%`" />
<UiCardFooter>
<UiCardFooter class="ui-card-footer">
<template #left>
<p>{{ $t("vcpus-used") }}</p>
<p class="footer-value">{{ nVCpuInUse }}</p>
@@ -37,11 +43,12 @@ 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 "@/stores/xen-api/host.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { useVmMetricsCollection } from "@/stores/xen-api/vm-metrics.store";
import { vTooltip } from "@/directives/tooltip.directive";
import { percent } from "@/libs/utils";
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useVmMetricsCollection } from "@/stores/xen-api/vm-metrics.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { logicAnd } from "@vueuse/math";
import { computed } from "vue";
@@ -113,4 +120,8 @@ const hasError = computed(
color: var(--footer-value-color);
}
}
.ui-card-footer {
margin-top: 2rem;
}
</style>

View File

@@ -2,7 +2,7 @@
<UiCard>
<UiCardTitle class="patches-title">
{{ $t("patches") }}
<template v-if="areAllLoaded" #right>
<template v-if="areAllLoaded && count > 0" #right>
{{ $t("n-missing", { n: count }) }}
</template>
</UiCardTitle>
@@ -35,7 +35,7 @@ const { count, patches, areSomeLoaded, areAllLoaded } = useHostPatches(hosts);
}
.table-container {
max-height: 40rem;
max-height: 25rem;
overflow: auto;
}
</style>

View File

@@ -43,6 +43,7 @@ const isDisplayed = computed(
font-weight: 700;
font-size: 14px;
color: var(--color-blue-scale-300);
margin-top: 2rem;
}
.summary-card {

View File

@@ -0,0 +1,47 @@
<template>
<div class="ui-status-panel">
<img :src="imageSource" alt="" class="image" />
<p v-if="title !== undefined" class="title">{{ title }}</p>
<p v-if="subtitle !== undefined" class="subtitle">{{ subtitle }}</p>
<slot />
</div>
</template>
<script lang="ts" setup>
defineProps<{
imageSource: string;
title?: string;
subtitle?: string;
}>();
</script>
<style lang="postcss" scoped>
.ui-status-panel {
width: 100%;
min-height: 76.5vh;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-extra-blue-base);
}
.title {
font-weight: 400;
font-size: 36px;
text-align: center;
}
.subtitle {
font-weight: 500;
font-size: 24px;
margin: 21px 0;
text-align: center;
}
.image {
margin-bottom: 40px;
width: 30%;
}
</style>

View File

@@ -26,11 +26,11 @@ const isTabBarDisabled = useContext(DisabledContext, () => props.disabled);
<style lang="postcss" scoped>
.ui-tab {
font-size: 1.8rem;
font-size: 1.6rem;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 1.2em;
padding: 0 1.5rem;
text-decoration: none;
text-transform: uppercase;
color: var(--color-blue-scale-100);

View File

@@ -22,7 +22,7 @@ useContext(DisabledContext, () => props.disabled);
.ui-tab-bar {
display: flex;
align-items: stretch;
height: 6.5rem;
height: 5rem;
border-bottom: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
max-width: 100%;

View File

@@ -20,7 +20,7 @@ defineProps<{
border-spacing: 0;
background-color: var(--background-color-primary);
font-weight: 400;
font-size: 1.6rem;
font-size: 1.4rem;
line-height: 2.4rem;
color: var(--color-blue-scale-200);

View File

@@ -0,0 +1,13 @@
<template>
<UiButton :busy="modal.isBusy" type="submit">
<slot />
</UiButton>
</template>
<script lang="ts" setup>
import UiButton from "@/components/ui/UiButton.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { inject } from "vue";
const modal = inject(IK_MODAL)!;
</script>

View File

@@ -3,7 +3,7 @@
:class="textClass"
:icon="faXmark"
class="modal-close-icon"
@click="close"
@click="modal?.decline()"
/>
</template>
@@ -11,13 +11,13 @@
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
import { IK_MODAL } from "@/types/injection-keys";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
const { textClass } = useContext(ColorContext);
const close = inject(IK_MODAL_CLOSE, undefined);
const modal = inject(IK_MODAL, undefined);
</script>
<style lang="postcss" scoped>

View File

@@ -1,9 +1,5 @@
<template>
<component
:is="tag"
:class="[backgroundClass, { nested: isNested }]"
class="modal-container"
>
<div :class="[backgroundClass, { nested: isNested }]" class="modal-container">
<header v-if="$slots.header" class="modal-header">
<slot name="header" />
</header>
@@ -13,7 +9,7 @@
<footer v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</footer>
</component>
</div>
</template>
<script lang="ts" setup>
@@ -23,13 +19,9 @@ import type { Color } from "@/types";
import { IK_MODAL_NESTED } from "@/types/injection-keys";
import { inject, provide } from "vue";
const props = withDefaults(
defineProps<{
tag?: string;
color?: Color;
}>(),
{ tag: "div" }
);
const props = defineProps<{
color?: Color;
}>();
defineSlots<{
header: () => any;

View File

@@ -0,0 +1,13 @@
<template>
<UiButton outlined :busy="modal.isBusy" @click="modal.decline()">
<slot>{{ $t("cancel") }}</slot>
</UiButton>
</template>
<script lang="ts" setup>
import UiButton from "@/components/ui/UiButton.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { inject } from "vue";
const modal = inject(IK_MODAL)!;
</script>

View File

@@ -0,0 +1,14 @@
<template>
<ModalListItem
v-for="modal in modalStore.modals"
:key="modal.id"
:modal="modal"
/>
</template>
<script lang="ts" setup>
import ModalListItem from "@/components/ui/modals/ModalListItem.vue";
import { useModalStore } from "@/stores/modal.store";
const modalStore = useModalStore();
</script>

View File

@@ -0,0 +1,15 @@
<template>
<component :is="modal.component" v-bind="modal.props" />
</template>
<script lang="ts" setup>
import type { ModalController } from "@/types";
import { IK_MODAL } from "@/types/injection-keys";
import { provide } from "vue";
const props = defineProps<{
modal: ModalController;
}>();
provide(IK_MODAL, props.modal);
</script>

View File

@@ -1,39 +1,36 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="ui-modal" @click.self="close">
<form class="ui-modal" v-bind="$attrs" @click.self="modal.decline()">
<slot />
</div>
</form>
</Teleport>
</template>
<script lang="ts" setup>
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import { ColorContext, DisabledContext } from "@/context";
import type { Color } from "@/types";
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
import { useMagicKeys, useVModel, whenever } from "@vueuse/core/index";
import { provide } from "vue";
import { IK_MODAL } from "@/types/injection-keys";
import { useMagicKeys, whenever } from "@vueuse/core";
import { inject } from "vue";
const props = defineProps<{
modelValue: boolean;
color?: Color;
disabled?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
}>();
defineOptions({
inheritAttrs: false,
});
const isOpen = useVModel(props, "modelValue", emit);
const close = () => (isOpen.value = false);
provide(IK_MODAL_CLOSE, close);
const modal = inject(IK_MODAL)!;
useContext(ColorContext, () => props.color);
useContext(DisabledContext, () => props.disabled || modal.isBusy);
const { escape } = useMagicKeys();
whenever(escape, () => close());
whenever(escape, () => modal.decline());
</script>
<style lang="postcss" scoped>

View File

@@ -1,5 +1,5 @@
<template>
<ModalContainer tag="form">
<ModalContainer>
<template #header>
<div class="close-bar">
<ModalCloseIcon />

View File

@@ -26,16 +26,12 @@ import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext, DisabledContext } from "@/context";
import { ColorContext } from "@/context";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
const props = withDefaults(
defineProps<{
icon?: IconDefinition;
disabled?: boolean;
}>(),
{ disabled: undefined }
);
defineProps<{
icon?: IconDefinition;
}>();
defineSlots<{
title: () => void;
@@ -44,8 +40,6 @@ defineSlots<{
}>();
const { textClass, borderClass } = useContext(ColorContext);
useContext(DisabledContext, () => props.disabled);
</script>
<style lang="postcss" scoped>

View File

@@ -51,7 +51,6 @@ const isTooltipEnabled = computed(() =>
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin: 1.6em 0;
}
.label-container {

View File

@@ -7,59 +7,23 @@
>
{{ $t("delete") }}
</MenuItem>
<UiModal v-model="isDeleteModalOpen">
<ConfirmModalLayout :icon="faSatellite">
<template #title>
<i18n-t keypath="confirm-delete" scope="global" tag="div">
<span :class="textClass">
{{ $t("n-vms", { n: vmRefs.length }) }}
</span>
</i18n-t>
</template>
<template #subtitle>
{{ $t("please-confirm") }}
</template>
<template #buttons>
<UiButton outlined @click="closeDeleteModal">
{{ $t("go-back") }}
</UiButton>
<UiButton @click="deleteVms">
{{ $t("delete-vms", { n: vmRefs.length }) }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import MenuItem from "@/components/menu/MenuItem.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useContext } from "@/composables/context.composable";
import useModal from "@/composables/modal.composable";
import { ColorContext } from "@/context";
import { useModal } from "@/composables/modal.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import { useXenApiStore } from "@/stores/xen-api.store";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const xenApi = useXenApiStore().getXapi();
const { getByOpaqueRef: getVm } = useVmCollection();
const {
open: openDeleteModal,
close: closeDeleteModal,
isOpen: isDeleteModalOpen,
} = useModal();
const vms = computed<XenApiVm[]>(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
@@ -73,10 +37,8 @@ const isDisabled = computed(
() => vms.value.length === 0 || areSomeVmsInExecution.value
);
const deleteVms = async () => {
await xenApi.vm.delete(props.vmRefs);
closeDeleteModal();
};
const { textClass } = useContext(ColorContext);
const openDeleteModal = () =>
useModal(() => import("@/components/modals/VmDeleteModal.vue"), {
vmRefs: props.vmRefs,
});
</script>

View File

@@ -1,51 +1,53 @@
<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
v-tooltip="
vmRefs.length > 0 &&
!isSomeExportable &&
$t(isSingleAction ? 'vm-is-running' : 'no-selected-vm-can-be-exported')
"
:icon="faDisplay"
:disabled="isDisabled"
@click="openModal"
>
{{ $t(isSingleAction ? "export-vm" : "export-vms") }}
</MenuItem>
</template>
<script lang="ts" setup>
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { computed } from "vue";
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import MenuItem from "@/components/menu/MenuItem.vue";
import {
faCode,
faDisplay,
faFileCsv,
faFileExport,
} from "@fortawesome/free-solid-svg-icons";
import { DisabledContext } from "@/context";
import { useContext } from "@/composables/context.composable";
import { useModal } from "@/composables/modal.composable";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
isSingleAction?: boolean;
}>();
const { getByOpaqueRef: getVm } = useVmCollection();
const vms = computed(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
const { getByOpaqueRefs, areSomeOperationAllowed } = useVmCollection();
const isParentDisabled = useContext(DisabledContext);
const isSomeExportable = computed(() =>
getByOpaqueRefs(props.vmRefs).some((vm) =>
areSomeOperationAllowed(vm, VM_OPERATION.EXPORT)
)
);
const isDisabled = computed(
() => isParentDisabled.value || !isSomeExportable.value
);
const openModal = () => {
useModal(() => import("@/components/modals/VmExportModal.vue"), {
vmRefs: props.vmRefs,
});
};
</script>

View File

@@ -0,0 +1,45 @@
<template>
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<VmActionExportItem :vmRefs="vmRefs" />
<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 "@/stores/xen-api/vm.store";
import { computed } from "vue";
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
import MenuItem from "@/components/menu/MenuItem.vue";
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import {
faCode,
faFileCsv,
faFileExport,
} from "@fortawesome/free-solid-svg-icons";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
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

@@ -1,95 +1,65 @@
<template>
<MenuItem
v-tooltip="
!areAllVmsMigratable && $t('some-selected-vms-can-not-be-migrated')
selectedRefs.length > 0 &&
!isMigratable &&
$t(
isSingleAction
? 'this-vm-cant-be-migrated'
: 'no-selected-vm-can-be-migrated'
)
"
:busy="isMigrating"
:disabled="isParentDisabled || !areAllVmsMigratable"
:disabled="isParentDisabled || !isMigratable"
:icon="faRoute"
@click="openModal"
@click="openModal()"
>
{{ $t("migrate") }}
</MenuItem>
<UiModal v-model="isModalOpen">
<FormModalLayout :disabled="isMigrating" @submit.prevent="handleMigrate">
<template #title>
{{ $t("migrate-n-vms", { n: selectedRefs.length }) }}
</template>
<div>
<FormInputWrapper :label="$t('select-destination-host')" light>
<FormSelect v-model="selectedHost">
<option :value="undefined">
{{ $t("select-destination-host") }}
</option>
<option
v-for="host in availableHosts"
:key="host.$ref"
:value="host"
>
{{ host.name_label }}
</option>
</FormSelect>
</FormInputWrapper>
</div>
<template #buttons>
<UiButton outlined @click="closeModal">
{{ isMigrating ? $t("close") : $t("cancel") }}
</UiButton>
<UiButton :busy="isMigrating" :disabled="!isValid" type="submit">
{{ $t("migrate-n-vms", { n: selectedRefs.length }) }}
</UiButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useContext } from "@/composables/context.composable";
import useModal from "@/composables/modal.composable";
import { useVmMigration } from "@/composables/vm-migration.composable";
import { useModal } from "@/composables/modal.composable";
import { DisabledContext } from "@/context";
import { vTooltip } from "@/directives/tooltip.directive";
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faRoute } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
isSingleAction?: boolean;
}>();
const { getByOpaqueRefs, isOperationPending, areSomeOperationAllowed } =
useVmCollection();
const isParentDisabled = useContext(DisabledContext);
const {
open: openModal,
isOpen: isModalOpen,
close: closeModal,
} = useModal({
onClose: () => (selectedHost.value = undefined),
});
const isMigratable = computed(() =>
getByOpaqueRefs(props.selectedRefs).some((vm) =>
areSomeOperationAllowed(vm, [
VM_OPERATION.POOL_MIGRATE,
VM_OPERATION.MIGRATE_SEND,
])
)
);
const {
selectedHost,
availableHosts,
isValid,
migrate,
isMigrating,
areAllVmsMigratable,
} = useVmMigration(() => props.selectedRefs);
const isMigrating = computed(() =>
getByOpaqueRefs(props.selectedRefs).some((vm) =>
isOperationPending(vm, [
VM_OPERATION.POOL_MIGRATE,
VM_OPERATION.MIGRATE_SEND,
])
)
);
const handleMigrate = async () => {
try {
await migrate();
closeModal();
} catch (e) {
console.error("Error while migrating", e);
}
};
const openModal = () =>
useModal(() => import("@/components/modals/VmMigrateModal.vue"), {
vmRefs: props.selectedRefs,
});
</script>

View File

@@ -26,7 +26,9 @@
/>
</template>
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
<VmActionExportItem :vm-refs="[vm.$ref]" is-single-action />
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
<VmActionMigrateItem :selected-refs="[vm.$ref]" is-single-action />
</AppMenu>
</template>
</TitleBar>
@@ -37,9 +39,11 @@ 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 VmActionMigrateItem from "@/components/vm/VmActionItems/VmActionMigrateItem.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";

View File

@@ -21,7 +21,7 @@
{{ $t("edit-config") }}
</MenuItem>
<VmActionSnapshotItem :vm-refs="selectedRefs" />
<VmActionExportItem :vm-refs="selectedRefs" />
<VmActionExportItems :vm-refs="selectedRefs" />
<VmActionDeleteItem :vm-refs="selectedRefs" />
</AppMenu>
</template>
@@ -32,7 +32,7 @@ import MenuItem from "@/components/menu/MenuItem.vue";
import UiButton from "@/components/ui/UiButton.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import VmActionExportItems from "@/components/vm/VmActionItems/VmActionExportItems.vue";
import VmActionMigrateItem from "@/components/vm/VmActionItems/VmActionMigrateItem.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";

View File

@@ -1,77 +0,0 @@
# useModal composable
### Usage
#### API
`useModal<T>(options: ModalOptions)`
Type parameter:
- `T`: The type for the modal's payload.
Parameters:
- `options`: An optional object of type `ModalOptions`.
Returns an object with:
- `payload: ReadOnly<Ref<T | undefined>>`: The payload data of the modal. Mainly used if a single modal is used for
multiple items (typically with `v-for`)
- `isOpen: WritableComputedRef<boolean>`: A writable computed indicating if the modal is open or not.
- `open(currentPayload?: T)`: A function to open the modal and optionally set its payload.
- `close(force = false)`: A function to close the modal. If force is set to `true`, the modal will be closed without
calling the `confirmClose` callback.
#### Types
`ModalOptions`
An object type that accepts:
- `confirmClose?: () => boolean`: An optional callback that is called before the modal is closed. If this function
returns `false`, the modal will not be closed.
- `onClose?: () => void`: An optional callback that is called after the modal is closed.
### Example
```vue
<template>
<div v-for="item in items">
{{ item.name }}
<button @click="openRemoveModal(item)">Delete</button>
</div>
<UiModal v-model="isRemoveModalOpen">
<ModalContainer>
<template #header>
Are you sure you want to delete {{ removeModalPayload.name }}?
</template>
<template #footer>
<button @click="handleRemove">Yes</button>
<button @click="closeRemoveModal">No</button>
</template>
</ModalContainer>
</UiModal>
</template>
<script lang="ts" setup>
import useModal from "@/composables/modal.composable";
const {
payload: removeModalPayload,
isOpen: isRemoveModalOpen,
open: openRemoveModal,
close: closeRemoveModal,
} = useModal({
confirmClose: () =>
window.confirm("Are you sure you want to close this modal?"),
onClose: () => console.log("Modal closed"),
});
async function handleRemove() {
await removeItem(removeModalPayload.id);
closeRemoveModal();
}
</script>
```

View File

@@ -1,48 +1,5 @@
import { computed, readonly, ref } from "vue";
import { useModalStore } from "@/stores/modal.store";
import type { AsyncComponentLoader } from "vue";
type ModalOptions = {
confirmClose?: () => boolean;
onClose?: () => void;
};
export default function useModal<T>(options: ModalOptions = {}) {
const $payload = ref<T>();
const $isOpen = ref(false);
const open = (payload?: T) => {
$isOpen.value = true;
$payload.value = payload;
};
const close = (force = false) => {
if (!force && options.confirmClose?.() === false) {
return;
}
if (options.onClose) {
options.onClose();
}
$isOpen.value = false;
$payload.value = undefined;
};
const isOpen = computed({
get() {
return $isOpen.value;
},
set(value) {
if (value) {
open();
} else {
close();
}
},
});
return {
payload: readonly($payload),
isOpen,
open,
close,
};
}
export const useModal = <T>(loader: AsyncComponentLoader, props: object = {}) =>
useModalStore().open<T>(loader, props);

View File

@@ -0,0 +1,37 @@
import { useModal } from "@/composables/modal.composable";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { whenever } from "@vueuse/core";
import { difference } from "lodash-es";
import { computed, ref, watch } from "vue";
export const useUnreachableHosts = () => {
const { records: hosts } = useHostCollection();
const unreachableHostsUrls = ref<Set<string>>(new Set());
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
whenever(
() => unreachableHostsUrls.value.size > 0,
() => {
const { onApprove, onDecline } = useModal(
() => import("@/components/modals/UnreachableHostsModal.vue"),
{
urls: computed(() => Array.from(unreachableHostsUrls.value.values())),
}
);
onApprove(() => window.location.reload());
onDecline(() => unreachableHostsUrls.value.clear());
},
{ immediate: true }
);
};

View File

@@ -39,12 +39,6 @@ export const useVmMigration = (
.sort(sortRecordsByNameLabel);
});
const areAllVmsMigratable = computed(() =>
vms.value.every((vm) =>
vm.allowed_operations.includes(VM_OPERATION.POOL_MIGRATE)
)
);
const isValid = computed(
() =>
!isMigrating.value &&
@@ -75,7 +69,6 @@ export const useVmMigration = (
isMigrating,
availableHosts,
selectedHost,
areAllVmsMigratable,
isValid,
migrate,
};

View File

@@ -18,6 +18,12 @@ export const useXenApiStoreBaseContext = <
return recordsByOpaqueRef.get(opaqueRef);
};
const getByOpaqueRefs = (opaqueRefs: XRecord["$ref"][]) => {
return opaqueRefs
.map(getByOpaqueRef)
.filter((record) => record !== undefined) as XRecord[];
};
const getByUuid = (uuid: XRecord["uuid"]) => {
return recordsByUuid.get(uuid);
};
@@ -49,6 +55,7 @@ export const useXenApiStoreBaseContext = <
lastError,
records,
getByOpaqueRef,
getByOpaqueRefs,
getByUuid,
hasUuid,
add,

View File

@@ -1,4 +1,4 @@
import type { HighlightResult, Language } from "highlight.js";
import type { HighlightResult } from "highlight.js";
import HLJS from "highlight.js/lib/core";
import cssLang from "highlight.js/lib/languages/css";
import jsonLang from "highlight.js/lib/languages/json";
@@ -19,10 +19,6 @@ export const highlight: (
ignoreIllegals?: boolean
) => HighlightResult = HLJS.highlight;
export const getLanguage: (
languageName: AcceptedLanguage
) => Language | undefined = HLJS.getLanguage;
export type AcceptedLanguage =
| "xml"
| "css"

View File

@@ -1,8 +1,5 @@
import {
type AcceptedLanguage,
getLanguage,
highlight,
} from "@/libs/highlight";
import { type AcceptedLanguage, highlight } from "@/libs/highlight";
import HLJS from "highlight.js/lib/core";
import { marked } from "marked";
enum VUE_TAG {
@@ -11,15 +8,26 @@ enum VUE_TAG {
STYLE = "vue-style",
}
function extractLang(lang: string | undefined): AcceptedLanguage | VUE_TAG {
if (lang === undefined) {
return "plaintext";
}
if (Object.values(VUE_TAG).includes(lang as VUE_TAG)) {
return lang as VUE_TAG;
}
if (HLJS.getLanguage(lang) !== undefined) {
return lang as AcceptedLanguage;
}
return "plaintext";
}
marked.use({
renderer: {
code(str: string, lang: AcceptedLanguage) {
const code = customHighlight(
str,
Object.values(VUE_TAG).includes(lang as VUE_TAG) || getLanguage(lang)
? lang
: "plaintext"
);
code(str, lang) {
const code = customHighlight(str, extractLang(lang));
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
},
},

View File

@@ -82,8 +82,8 @@ const testMetric = (
typeof test === "string"
? test === type
: typeof test === "function"
? test(type)
: test.exec(type);
? test(type)
: test.exec(type);
const findMetric = (metrics: any, metricType: string) => {
let testResult;

View File

@@ -491,3 +491,9 @@ export enum CERTIFICATE_TYPE {
HOST = "host",
HOST_INTERNAL = "host_internal",
}
export enum VM_COMPRESSION_TYPE {
DISABLED = "false",
GZIP = "true",
ZSTD = "zstd",
}

View File

@@ -18,6 +18,8 @@ import type {
import { buildXoObject, typeToRawType } from "@/libs/xen-api/xen-api.utils";
import { JSONRPCClient } from "json-rpc-2.0";
import { castArray } from "lodash-es";
import type { VM_COMPRESSION_TYPE } from "@/libs/xen-api/xen-api.enums";
import { useModal } from "@/composables/modal.composable";
export default class XenApi {
private client: JSONRPCClient;
@@ -27,10 +29,12 @@ export default class XenApi {
Set<(...args: any[]) => void>
>();
private fromToken: string | undefined;
private hostUrl: string;
constructor(hostUrl: string) {
this.hostUrl = hostUrl;
this.client = new JSONRPCClient(async (request) => {
const response = await fetch(`${hostUrl}/jsonrpc`, {
const response = await fetch(`${this.hostUrl}/jsonrpc`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
@@ -380,6 +384,36 @@ export default class XenApi {
)
);
},
export: (vmRefs: VmRefs, compression: VM_COMPRESSION_TYPE) => {
const blockedUrls: URL[] = [];
castArray(vmRefs).forEach((vmRef) => {
const url = new URL(this.hostUrl);
url.pathname = "/export/";
url.search = new URLSearchParams({
session_id: this.sessionId!,
ref: vmRef,
use_compression: compression,
}).toString();
const _window = window.open(url.href, "_blank");
if (_window === null) {
blockedUrls.push(url);
} else {
URL.revokeObjectURL(url.toString());
}
});
if (blockedUrls.length > 0) {
const { onClose } = useModal(
() => import("@/components/modals/VmExportBlockedUrlsModal.vue"),
{ blockedUrls }
);
onClose(() =>
blockedUrls.forEach((url) => URL.revokeObjectURL(url.toString()))
);
}
},
};
}
}

View File

@@ -629,3 +629,7 @@ export type XenApiEvent<
ref: XRecord["$ref"];
snapshot: RawXenApiRecord<XRecord>;
};
export interface XenApiError extends Error {
data?: any;
}

View File

@@ -27,6 +27,7 @@
"cancel": "Cancel",
"change-state": "Change state",
"click-to-display-alarms": "Click to display alarms:",
"click-to-return-default-pool": "Click here to return to the default pool",
"close": "Close",
"coming-soon": "Coming soon!",
"community": "Community",
@@ -37,12 +38,14 @@
"console-unavailable": "Console unavailable",
"copy": "Copy",
"cpu-provisioning": "CPU provisioning",
"cpu-provisioning-warning": "The number of vCPUs allocated exceeds the number of physical CPUs available. System performance could be affected",
"cpu-usage": "CPU usage",
"dashboard": "Dashboard",
"delete": "Delete",
"delete-vms": "Delete 1 VM | Delete {n} VMs",
"descending": "descending",
"description": "Description",
"disabled": "Disabled",
"display": "Display",
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"documentation": "Documentation",
@@ -51,8 +54,12 @@
"error-no-data": "Error, can't collect data.",
"error-occurred": "An error has occurred",
"export": "Export",
"export-n-vms": "Export 1 VM | Export {n} VMs",
"export-n-vms-manually": "Export 1 VM manually | Export {n} VMs manually",
"export-table-to": "Export table to {type}",
"export-vm": "Export VM",
"export-vms": "Export VMs",
"export-vms-manually-information": "Some VM exports were not able to start automatically, probably due to your browser settings. To export them, you should click on each one. (Alternatively, copy the link as well.)",
"fetching-fresh-data": "Fetching fresh data",
"filter": {
"comparison": {
@@ -78,6 +85,7 @@
"fullscreen": "Fullscreen",
"fullscreen-leave": "Leave fullscreen",
"go-back": "Go back",
"gzip": "gzip",
"here": "Here",
"hosts": "Hosts",
"keep-me-logged": "Keep me logged in",
@@ -88,6 +96,7 @@
"loading-hosts": "Loading hosts…",
"log-out": "Log out",
"login": "Login",
"login-only-on-master": "Login is only possible on the master host",
"more-actions": "More actions",
"migrate": "Migrate",
"migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
@@ -103,6 +112,8 @@
"news": "News",
"news-name": "{name} news",
"no-alarm-triggered": "No alarm triggered",
"no-selected-vm-can-be-exported": "No selected VM can be exported",
"no-selected-vm-can-be-migrated": "No selected VM can be migrated",
"no-tasks": "No tasks",
"not-found": "Not found",
"object": "Object",
@@ -137,6 +148,7 @@
},
"resume": "Resume",
"save": "Save",
"select-compression": "Select a compression",
"select-destination-host": "Select a destination host",
"selected-vms-in-execution": "Some selected VMs are running",
"send-ctrl-alt-del": "Send Ctrl+Alt+Del",
@@ -144,7 +156,6 @@
"settings": "Settings",
"shutdown": "Shutdown",
"snapshot": "Snapshot",
"some-selected-vms-can-not-be-migrated": "Some selected VMs can't be migrated",
"sort-by": "Sort by",
"stacked-cpu-usage": "Stacked CPU usage",
"stacked-ram-usage": "Stacked RAM usage",
@@ -168,6 +179,7 @@
"theme-auto": "Auto",
"theme-dark": "Dark",
"theme-light": "Light",
"this-vm-cant-be-migrated": "This VM can't be migrated",
"top-#": "Top {n}",
"total-cpus": "Total CPUs",
"total-free": "Total free",
@@ -179,5 +191,7 @@
"version": "Version",
"vm-is-running": "The VM is running",
"vms": "VMs",
"xo-lite-under-construction": "XOLite is under construction"
"xo-lite-under-construction": "XOLite is under construction",
"you-are-currently-on": "You are currently on: {0}",
"zstd": "zstd"
}

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