Compare commits

..

91 Commits

Author SHA1 Message Date
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
Mathieu
ad928ec23d fix(xo-web/licenses/XOSTOR): various fixes on XOSTOR licenses (#7137)
Introduced by #6983
2023-10-30 14:55:11 +01:00
Pierre Donias
1d7559ded2 fix(xo-server-netbox/VM): explicitly assign site (#7124)
See Zammad#17766
See https://xcp-ng.org/forum/topic/7887
2023-10-30 11:32:12 +01:00
Mathieu
9099b58557 feat: technical release (#7132) 2023-10-27 16:13:04 +02:00
Julien Fontanet
9e70397240 fix(xo-server/redis): fix indexes handling
Introduced by 225a67ae3
2023-10-27 11:27:25 +02:00
Thierry Goettelmann
5f69b0e9a0 feat(lite/console): new console toolbar (#7088) 2023-10-27 10:27:51 +02:00
Julien Fontanet
2a9bff1607 chore(xo-server/importConfig): don't use deptree 2023-10-27 10:14:02 +02:00
Pierre Donias
9e621d7de8 feat(lite/header): replace logo with "XO LITE" (#7118) 2023-10-27 09:16:28 +02:00
Mathieu
3e5c73528d feat(xo-server,xo-web/XOSTOR): XOSTOR implementation (#6983)
See https://xcp-ng.org/forum/topic/5361
2023-10-26 16:58:59 +02:00
Pierre Donias
397b5cd56d fix(xo-server/snapshot): allow self user that is member of a group to snapshot (#7129)
Introduced by a88798cc22
See Zammad#18478
2023-10-26 16:08:43 +02:00
Julien Fontanet
55cb6042e8 chore(yarn.lock): update dev deps 2023-10-26 11:00:14 +02:00
Pierre Donias
339d920b78 feat(xo-web/proxy): ability to open support tunnel on XO Proxy (#7127)
Requires #7126
2023-10-25 17:26:06 +02:00
Julien Fontanet
f14f716f3d feat(xo-server/api): proxy.openSupportTunnel (#7126)
The goal is to provide an easier way for the support team to open a tunnel on a proxy appliance.

This is the server side of this feature.
2023-10-25 17:12:17 +02:00
Julien Fontanet
fb83d1fc98 feat(xo-server/api): ignorable parameters (#7125) 2023-10-25 15:49:41 +02:00
Julien Fontanet
62208e7847 fix(xo-server-transport-xmpp): fix loading (#7082)
Fixes https://xcp-ng.org/forum/post/66402

Introduced by d6fc86b6b
2023-10-25 14:36:40 +02:00
Julien Fontanet
df91772f5c chore(xo-server/server): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
cf8a9d40be chore(xo-server/remote): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
93d1c6c3fc chore(xo-server/plugin-metadata): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
f1fa811e5c chore(xo-server/user): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
5a9812c492 chore(xo-server/group): use builtin (un)serialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
b53d613a64 chore(xo-server/token): use builtin unserialize 2023-10-25 11:48:53 +02:00
Julien Fontanet
225a67ae3b chore(xo-server/redis): proper (un)serialization support 2023-10-25 11:48:53 +02:00
Mathieu
c7eb7db463 feat(xo-web/about): display if XO from source is up to date (#7091)
Fixes #5934
2023-10-24 17:14:01 +02:00
Pierre Donias
edfa729672 chore(lite/assets): remove darkreader properties in SVG files (#7121) 2023-10-24 16:40:37 +02:00
Mathieu
77d9798319 fix(xo-web/vtpm): fix various an error has occured (#7122)
Introduced by 8834af65f7
Introduced by 1a1dd0531d

Fix `an error has occurred` in the VM advanced tab and on the VM creation form
if the user does not have pool permission.
2023-10-24 16:26:36 +02:00
Pierre Donias
680f1e2f07 chore(lite): serve Poppins font internally (#7117) 2023-10-24 15:19:54 +02:00
Julien Fontanet
7c009b0fc0 feat(xo-server): support reading JSON records in Redis
This allows forward compatibility with future versions which will use JSON records in the future.
2023-10-23 15:13:28 +02:00
Pierre Donias
eb7de4f2dd feat(xo-web/self): show # of VMs that belong to each Resource Set (#7114)
See Zammad#17568
2023-10-23 15:03:30 +02:00
Olivier Lambert
2378399981 docs: update project's README (#7116) 2023-10-23 14:25:03 +02:00
Florent BEAUCHAMP
37b2113763 feat(fs/s3): compute sensible chunk size for uploads 2023-10-23 10:23:50 +02:00
Florent BEAUCHAMP
5048485a85 feat(fs/s3): object lock mode need content md5
and the middleware consume addiitionnal memory
2023-10-23 10:23:50 +02:00
Florent BEAUCHAMP
9e667533e9 fix(fs/s3): throw error if upload >50GB 2023-10-23 10:23:50 +02:00
MlssFrncJrg
1fac7922b4 feat(xo-web/dashboard/health): VDIs to coalesce warning contains the number (#7111)
Fixes Zammad#17577
2023-10-20 15:53:24 +02:00
Julien Fontanet
1a0e5eb6fc chore: format with Prettier 2023-10-20 15:52:10 +02:00
Pierre Donias
321e322492 feat(xo-server/clearHost): pass optional batch size arg (#7107)
Fixes #7105
See https://github.com/xapi-project/xen-api/issues/5202
See https://github.com/xapi-project/xen-api/pull/5203

`host.evacuate`: try passing optional batch size argument.
If not supported: remove it and try again.
2023-10-19 17:03:14 +02:00
Mathieu
8834af65f7 feat(xo-server/xo-web/VM/new): VTPM creation (#7077)
See #7066
See #6802
See #7085
2023-10-19 16:48:56 +02:00
Mathieu
1a1dd0531d feat(xo-web/VM/advanced): VTPM management (#7085)
See #7066
See #6802
See #7074
2023-10-19 15:46:03 +02:00
Pierre Donias
8752487280 docs(installation): add nfs-common dependency for Debian/Ubuntu (#7108) 2023-10-18 22:50:29 +02:00
Pierre Donias
4b12a6d31d fix(xo-server-usage-report): handle null and nested stats (#7092)
Introduced by 083483645e

Fixes Zammad#18120
Fixes Zammad#18266

- Always assume that data can be `null`
- Handle edge cases where all values are `null`
- Properly handle nested RRD collections: collections have different depths (`memory`: 1, `cpus[0]`: 2, `pifs.rx[0]`: 3, ...). This PR replaces `getLastDays` which wouldn't handle those depths properly, with `getDeepLastValues` which is run on the whole stat object and doesn't assume the depth of the collections. It finds any Array at any depth and slices it to only keep the last N values.
2023-10-18 22:50:08 +02:00
Julien Fontanet
2924f82754 fix(xo-web): don't sign out on connection error (#7103)
May fix zammad#17717

Introduced by 005ab47d9
2023-10-18 18:07:16 +02:00
Pierre Donias
9b236a6191 fix(netbox/test): test custom fields first (#7104)
More atomic and it makes more sense for users to check that the Netbox
configuration is correct before doing any write operations
2023-10-18 11:56:10 +02:00
Julien Fontanet
a3b8553cec fix(xo-server,xo-web): fix total number of VDIs to coalesce (#7098)
Fixes #7016

Summing all chains does take not common chains into account, the total must be computed on the server side.
2023-10-18 11:52:43 +02:00
Pierre Donias
00a1778a6d feat(lite): set color-scheme CSS property to "dark" in dark mode (#7101) 2023-10-17 16:50:13 +02:00
MlssFrncJrg
3b6bc629bc fix(xo-web/home): fix misaligned descriptions (#7090) 2023-10-16 15:53:35 +02:00
Pierre Donias
04dfd9a02c fix(xo-server-usage-report): use @xen-orchestra/log to log errors (#7096)
Fixes Zammad#14579
Fixes Zammad#18183

Better handles error objects with a circular structure and avoids "Converting
circular structure to JSON" error on stringify
2023-10-16 10:07:57 +02:00
Pierre Donias
fb52868074 fix(xo-server/patching): always check that XS credentials are configured on XS (#7093)
Introduced by a30d962b1d
2023-10-13 16:49:04 +02:00
Pierre Donias
77d53d2abf fix(xo-server/patching): always pass xsCredentials to installPatches on XS (#7089)
Fixes Zammad#18284

Introduced by a30d962b1d
2023-10-13 11:45:17 +02:00
Julien Fontanet
6afb87def1 feat(xo-server/vm.set): support xenStoreData
Fixes #7055
2023-10-13 11:26:48 +02:00
Mathieu
8bfe293414 feat(lite/VM): add copy, snapshot single action (#7087) 2023-10-12 11:09:11 +02:00
Mathieu
2e634a9d1c feat(xapi/VTPM): ability to create, destroy VTPM (#7074) 2023-10-12 09:19:38 +02:00
Pierre Donias
bea771ca90 fix(xo-server/RPU): do not migrate VM back if already on host (#7071)
See https://xcp-ng.org/forum/topic/7802
2023-10-11 16:16:44 +02:00
Pierre Donias
99e3622f31 feat(xo-web/SelectPif): show network name (#7081)
See Zammad#17381
2023-10-10 15:59:24 +02:00
Pizzosaure
a16522241e docs(netbox): remove extra backtick (#7083)
Introduced by 3b3f927e4b
2023-10-10 14:14:15 +02:00
Julien Fontanet
b86cb12649 chore(yarn.lock): update dev deps 2023-10-09 17:06:54 +02:00
Julien Fontanet
2af74008b2 feat(xo-server-backup-reports): errors are logged as XO tasks 2023-10-09 09:35:24 +02:00
Julien Fontanet
2e689592f1 feat(xo-server-backup-reports): error when transports not enabled 2023-10-09 09:35:24 +02:00
Julien Fontanet
3f8436b58b fix(xo-server/authenticateUser): use clearLogOnSuccess
This fixes success logs not deleted due to race conditions.
2023-10-09 09:35:24 +02:00
Julien Fontanet
e3dd59d684 feat(mixins/Tasks#create): clearLogOnSuccess option 2023-10-09 09:35:24 +02:00
mathieuRA
549d9b70a9 feat(xo-web/host): allow to force smartReboot 2023-10-06 16:52:26 +02:00
mathieuRA
3bf6aae103 feat(xapi/host_smartReboot): ability to bypass blocked operations 2023-10-06 16:52:26 +02:00
Julien Fontanet
afb110c473 fix(fs/rmtree): fix huge memory usage (#7073)
Fixes zammad#15258

This adds a sane concurrency limit of 2 per depth level.

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.fr>
2023-10-06 09:52:11 +02:00
Pierre Donias
8727c3cf96 docs(patches): update URLs that need to be accessible from XOA (#7075) 2023-10-05 09:45:50 +02:00
Julien Fontanet
b13302ddeb fix(xen-api/cli): dont run default export when imported by ESM
Fix a bug in `@xen-orchestra/xapi` introduced by c3e0308ad

`module.parent` is `null` when the module is the entry point but `undefined` when imported via ESM.
2023-10-04 10:06:17 +02:00
Julien Fontanet
e89ed06314 docs(installation): Node 18 required
XO is not compatible with Node > 18 for the moment, as Node 20 will
likely graduate to LTS soon, the docs must explicitly recommend 18.
2023-10-04 09:25:37 +02:00
Malcolm Scott
e3f57998f7 fix(signin): try to preserve current page across reauthentication (#7013)
If an authentication session expires or is lost for whatever reason, XO redirects to `/signin`.  This redirect generally preserves the URL fragment (hash) which contains the page selected prior to reauthentication, i.e. if the user had been in settings/servers just beforehand, they end up at `/signin#settings/servers`.  However, currently when they log back in they end up on the home page; the page they were on is forgotten.

This commit tries to send the user back to the page they were viewing before reauthentication, by preserving the URL fragment in the login form action / by appending it to the links to authentication plugins.  (Not all authentication plugins will necessarily preserve it internally, but we can optimistically try it and see; at worst the old behaviour will remain.)
2023-10-03 12:39:57 +02:00
Julien Fontanet
8cdb5ee31b chore: update dev deps 2023-10-03 11:24:51 +02:00
Pierre Donias
5b734db656 feat(lite): 0.1.4 (#7068) 2023-10-03 10:26:05 +02:00
rbarhtaoui
e853f9d04f feat(lite): display loading icon and error message when data is not fetched (#6775) 2023-10-03 10:03:44 +02:00
Mathieu
2a5e09719e feat(lite/login): add remember me checkbox (#7030) 2023-10-03 10:01:07 +02:00
193 changed files with 5954 additions and 3531 deletions

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

@@ -29,7 +29,7 @@
"ensure-array": "^1.0.0"
},
"devDependencies": {
"sinon": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.2.1"
}
}

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.0",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",

View File

@@ -681,11 +681,13 @@ export class RemoteAdapter {
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
async outputStream(path, input, { checksum = true, maxStreamLength, streamLength, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
maxStreamLength,
streamLength,
async validator() {
await input.task
return validator.apply(this, arguments)

View File

@@ -29,6 +29,8 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
writer =>
writer.run({
stream: forkStreamUnpipe(stream),
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
streamLength: stream.length,
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,

View File

@@ -35,13 +35,25 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
useSnapshot: false,
})
)
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('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)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
maxStreamLength,
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,

View File

@@ -24,7 +24,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
)
}
async _run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
const settings = this._settings
const job = this._job
const scheduleId = this._scheduleId
@@ -65,6 +65,8 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
await Task.run({ name: 'transfer' }, async () => {
await adapter.outputStream(dataFilename, stream, {
maxStreamLength,
streamLength,
validator: tmpPath => adapter.isValidXva(tmpPath),
})
return { size: sizeContainer.size }

View File

@@ -1,9 +1,9 @@
import { AbstractWriter } from './_AbstractWriter.mjs'
export class AbstractFullWriter extends AbstractWriter {
async run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
async run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
try {
return await this._run({ timestamp, sizeContainer, stream, vm, vmSnapshot })
return await this._run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot })
} finally {
// ensure stream is properly closed
stream.destroy()

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.43.0",
"version": "0.43.2",
"engines": {
"node": ">=14.18"
},
@@ -28,7 +28,7 @@
"@vates/nbd-client": "^2.0.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"app-conf": "^2.3.0",
@@ -51,12 +51,12 @@
"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.2.0"
"@xen-orchestra/xapi": "^3.3.0"
},
"license": "AGPL-3.0-or-later",
"author": {

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.0",
"version": "4.1.2",
"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",
@@ -17,7 +17,7 @@
"xo-fs": "./cli.js"
},
"engines": {
"node": ">=15"
"node": ">=14.13"
},
"dependencies": {
"@aws-sdk/abort-controller": "^3.272.0",
@@ -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

@@ -1,74 +0,0 @@
const noop = Function.prototype
const BARRIER = Symbol('runAsync.BARRIER')
export default function runAsync(stack, worker, { concurrency = 10, signal, stopOnError = true } = {}) {
return new Promise((resolve, reject) => {
const errors = []
let onAbort
let working = 0
if (signal !== undefined) {
onAbort = () => {
stop(reject, signal.reason)
}
signal.addEventListener('abort', onAbort)
}
function stop(cb, arg) {
if (run !== noop) {
run = noop
if (signal !== undefined) {
signal.removeEventListener('abort', onAbort)
}
cb(arg)
}
}
function push(...args) {
stack.push(...args)
run()
}
async function runOne(entry) {
++working
try {
await worker(entry, push)
} catch (error) {
if (stopOnError) {
stop(reject, error)
} else {
errors.push(error)
}
}
--working
run()
}
let run = function () {
if (stack.length === 0) {
if (working === 0) {
if (errors.length !== 0) {
stop(reject, new AggregateError(errors))
} else {
stop(resolve)
}
}
return
}
while (working < concurrency && stack.length !== 0) {
const entry = stack.pop()
if (entry === BARRIER) {
if (working === 0) {
continue
} else {
stack.push(entry)
return
}
}
runOne(entry)
}
}
run()
})
}
runAsync.BARRIER = BARRIER

View File

@@ -1,5 +1,6 @@
import assert from 'assert'
import getStream from 'get-stream'
import { asyncEach } from '@vates/async-each'
import { coalesceCalls } from '@vates/coalesce-calls'
import { createLogger } from '@xen-orchestra/log'
import { fromCallback, fromEvent, ignoreErrors, timeout } from 'promise-toolbox'
@@ -12,7 +13,6 @@ import { synchronized } from 'decorator-synchronized'
import { basename, dirname, normalize as normalizePath } from './path'
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
import runAsync from './_runAsync.js'
const { info, warn } = createLogger('xo:fs:abstract')
@@ -189,7 +189,7 @@ export default class RemoteHandlerAbstract {
* @param {number} [options.dirMode]
* @param {(this: RemoteHandlerAbstract, path: string) => Promise<undefined>} [options.validator] Function that will be called before the data is commited to the remote, if it fails, file should not exist
*/
async outputStream(path, input, { checksum = true, dirMode, validator } = {}) {
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
path = normalizePath(path)
let checksumStream
@@ -201,6 +201,8 @@ export default class RemoteHandlerAbstract {
}
await this._outputStream(path, input, {
dirMode,
maxStreamLength,
streamLength,
validator,
})
if (checksum) {
@@ -315,7 +317,7 @@ export default class RemoteHandlerAbstract {
return p
}
async __rmdir(dir) {
async rmdir(dir) {
await timeout.call(this._rmdir(normalizePath(dir)).catch(ignoreEnoent), this._timeout)
}
@@ -614,29 +616,30 @@ export default class RemoteHandlerAbstract {
}
async _rmtree(dir) {
await runAsync([dir], async (entry, push) => {
try {
await this.__unlink(entry)
} catch (error) {
const { code } = error
// Unlink dir behavior is not consistent across platforms
// https://github.com/nodejs/node-v0.x-archive/issues/5791
if (code !== 'EISDIR' && code === 'EPERM') {
throw error
}
try {
await this.__rmdir(entry)
} catch (error) {
if (error.code !== 'ENOTEMPTY') {
throw error
}
push(entry, runAsync.BARRIER, ...(await this.__list(entry, { prependDir: true })))
}
try {
return await this._rmdir(dir)
} catch (error) {
if (error.code !== 'ENOTEMPTY') {
throw error
}
})
}
const files = await this._list(dir)
await asyncEach(files, file =>
this._unlink(`${dir}/${file}`).catch(
error => {
// Unlink dir behavior is not consistent across platforms
// https://github.com/nodejs/node-v0.x-archive/issues/5791
if (error.code === 'EISDIR' || error.code === 'EPERM') {
return this._rmtree(`${dir}/${file}`)
}
throw error
},
// real unlink concurrency will be 2**max directory depth
{ concurrency: 2 }
)
)
return this._rmtree(dir)
}
// called to initialize the remote

View File

@@ -5,6 +5,7 @@ import {
CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
GetObjectLockConfigurationCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
@@ -17,7 +18,7 @@ import { getApplyMd5BodyChecksumPlugin } from '@aws-sdk/middleware-apply-body-ch
import { Agent as HttpAgent } from 'http'
import { Agent as HttpsAgent } from 'https'
import { createLogger } from '@xen-orchestra/log'
import { PassThrough, pipeline } from 'stream'
import { PassThrough, Transform, pipeline } from 'stream'
import { parse } from 'xo-remote-parser'
import copyStreamToBuffer from './_copyStreamToBuffer.js'
import guessAwsRegion from './_guessAwsRegion.js'
@@ -30,6 +31,8 @@ import { pRetry } from 'promise-toolbox'
// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
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')
export default class S3Handler extends RemoteHandlerAbstract {
@@ -71,9 +74,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
}),
})
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
const parts = split(path)
this.#bucket = parts.shift()
this.#dir = join(...parts)
@@ -223,11 +223,35 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
}
async _outputStream(path, input, { validator }) {
async _outputStream(path, input, { streamLength, maxStreamLength = streamLength, validator }) {
// S3 storage is limited to 10K part, each part is limited to 5GB. And the total upload must be smaller than 5TB
// a bigger partSize increase the memory consumption of aws/lib-storage exponentially
let partSize
if (maxStreamLength === undefined) {
warn(`Writing ${path} to a S3 remote without a max size set will cut it to 50GB`, { path })
partSize = MIN_PART_SIZE // min size for S3
} else {
partSize = Math.min(Math.max(Math.ceil(maxStreamLength / MAX_PART_NUMBER), MIN_PART_SIZE), MAX_PART_SIZE)
}
// ensure we don't try to upload a stream to big for this partSize
let readCounter = 0
const MAX_SIZE = MAX_PART_NUMBER * partSize
const streamCutter = new Transform({
transform(chunk, encoding, callback) {
readCounter += chunk.length
if (readCounter > MAX_SIZE) {
callback(new Error(`read ${readCounter} bytes, maximum size allowed is ${MAX_SIZE} `))
} else {
callback(null, chunk)
}
},
})
// Workaround for "ReferenceError: ReadableStream is not defined"
// https://github.com/aws/aws-sdk-js-v3/issues/2522
const Body = new PassThrough()
pipeline(input, Body, () => {})
pipeline(input, streamCutter, Body, () => {})
const upload = new Upload({
client: this.#s3,
@@ -235,6 +259,8 @@ export default class S3Handler extends RemoteHandlerAbstract {
...this.#createParams(path),
Body,
},
partSize,
leavePartsOnError: false,
})
await upload.done()
@@ -418,6 +444,24 @@ export default class S3Handler extends RemoteHandlerAbstract {
async _closeFile(fd) {}
async _sync() {
await super._sync()
try {
// if Object Lock is enabled, each upload must come with a contentMD5 header
// the computation of this md5 is memory-intensive, especially when uploading a stream
const res = await this.#s3.send(new GetObjectLockConfigurationCommand({ Bucket: this.#bucket }))
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
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
}
} catch (error) {
if (error.Code !== 'ObjectLockConfigurationNotFoundError' && error.$metadata.httpStatusCode !== 501) {
throw error
}
}
}
useVhdDirectory() {
return true
}

View File

@@ -2,9 +2,19 @@
## **next**
## **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)
- Ability to migrate selected VMs to another host (PR [#7040](https://github.com/vatesfr/xen-orchestra/pull/7040))
- Ability to snapshot selected VMs (PR [#7021](https://github.com/vatesfr/xen-orchestra/pull/7021))
- Add Patches to Pool Dashboard (PR [#6709](https://github.com/vatesfr/xen-orchestra/pull/6709))
- Add remember me checkbox on the login page (PR [#7030](https://github.com/vatesfr/xen-orchestra/pull/7030))
## **0.1.3** (2023-09-01)

View File

@@ -0,0 +1,103 @@
# 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"));
```
## 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.3",
"version": "0.1.5",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
@@ -11,6 +11,7 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@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",

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

View File

@@ -1,7 +1,11 @@
@import "reset.css";
@import "theme.css";
/* TODO Serve fonts locally */
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");
@import "@fontsource/poppins/400.css";
@import "@fontsource/poppins/500.css";
@import "@fontsource/poppins/600.css";
@import "@fontsource/poppins/700.css";
@import "@fontsource/poppins/900.css";
@import "@fontsource/poppins/400-italic.css";
body {
min-height: 100vh;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,6 @@
:root {
--color-logo: #282467;
--color-blue-scale-000: #000000;
--color-blue-scale-100: #1a1b38;
--color-blue-scale-200: #595a6f;
@@ -59,6 +61,10 @@
}
:root.dark {
color-scheme: dark;
--color-logo: #e5e5e7;
--color-blue-scale-000: #ffffff;
--color-blue-scale-100: #e5e5e7;
--color-blue-scale-200: #9899a5;

View File

@@ -7,7 +7,8 @@
class="toggle-navigation"
/>
<RouterLink :to="{ name: 'home' }">
<img alt="XO Lite" src="../assets/logo.svg" />
<img v-if="isMobile" alt="XO Lite" src="../assets/logo.svg" />
<TextLogo v-else />
</RouterLink>
<slot />
<div class="right">
@@ -18,6 +19,7 @@
<script lang="ts" setup>
import AccountButton from "@/components/AccountButton.vue";
import TextLogo from "@/components/TextLogo.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
@@ -44,6 +46,10 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
img {
width: 4rem;
}
.text-logo {
margin: 1rem;
}
}
.right {

View File

@@ -16,6 +16,10 @@
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>
@@ -28,6 +32,9 @@ 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 FormCheckbox from "@/components/form/FormCheckbox.vue";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import UiButton from "@/components/ui/UiButton.vue";
@@ -42,12 +49,16 @@ const password = ref("");
const error = ref<string>();
const passwordRef = ref<InstanceType<typeof FormInput>>();
const isInvalidPassword = ref(false);
const rememberMe = useLocalStorage("rememberMe", false);
const focusPasswordInput = () => passwordRef.value?.focus();
onMounted(() => {
xenApiStore.reconnect();
focusPasswordInput();
if (rememberMe.value) {
xenApiStore.reconnect();
} else {
focusPasswordInput();
}
});
watch(password, () => {
@@ -72,6 +83,19 @@ async function handleSubmit() {
</script>
<style lang="postcss" scoped>
.remember-me-label {
cursor: pointer;
width: fit-content;
& .form-checkbox {
margin: 1rem 1rem 1rem 0;
vertical-align: middle;
}
& p {
display: inline;
vertical-align: middle;
}
}
.form-container {
display: flex;
align-items: center;
@@ -87,7 +111,6 @@ form {
font-size: 2rem;
min-width: 30em;
max-width: 100%;
align-items: center;
flex-direction: column;
justify-content: center;
margin: 0 auto;
@@ -104,7 +127,7 @@ h1 {
img {
width: 40rem;
margin-bottom: 5rem;
margin: auto auto 5rem auto;
}
input {
@@ -118,6 +141,6 @@ input {
}
button {
margin-top: 2rem;
margin: 2rem auto;
}
</style>

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

@@ -105,6 +105,10 @@ watchEffect(() => {
onBeforeUnmount(() => {
clearVncClient();
});
defineExpose({
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
});
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,37 @@
<template>
<svg
class="text-logo"
viewBox="300.85 622.73 318.32 63.27"
xmlns="http://www.w3.org/2000/svg"
width="100"
height="22"
>
<g>
<polygon
points="355.94 684.92 341.54 684.92 327.84 664.14 315.68 684.92 301.81 684.92 317.59 659.25 338.96 659.25 355.94 684.92"
/>
<path
d="M406.2,627.17c4.62,2.64,8.27,6.33,10.94,11.07,2.67,4.74,4.01,10.1,4.01,16.07s-1.34,11.35-4.01,16.12c-2.67,4.77-6.32,8.48-10.94,11.12-4.63,2.64-9.78,3.97-15.47,3.97s-10.85-1.32-15.47-3.97c-4.63-2.64-8.27-6.35-10.95-11.12-2.67-4.77-4.01-10.14-4.01-16.12s1.34-11.33,4.01-16.07c2.67-4.74,6.32-8.43,10.95-11.07,4.62-2.64,9.78-3.97,15.47-3.97s10.84,1.32,15.47,3.97Zm-24.86,9.65c-2.7,1.61-4.81,3.92-6.33,6.94-1.52,3.02-2.28,6.54-2.28,10.56s.76,7.54,2.28,10.56c1.52,3.02,3.63,5.33,6.33,6.94,2.7,1.61,5.83,2.41,9.39,2.41s6.69-.8,9.39-2.41c2.7-1.61,4.81-3.92,6.33-6.94,1.52-3.02,2.28-6.53,2.28-10.56s-.76-7.54-2.28-10.56-3.63-5.33-6.33-6.94c-2.7-1.61-5.83-2.41-9.39-2.41s-6.69,.8-9.39,2.41Z"
/>
<polygon
points="354.99 624.06 339.53 649.22 317.49 649.22 300.86 624.06 315.26 624.06 328.96 644.84 341.12 624.06 354.99 624.06"
/>
<g>
<path d="M476.32,675.94h20.81v10.04h-33.47v-63.14h12.66v53.1Z" />
<path d="M517.84,622.84v63.14h-12.66v-63.14h12.66Z" />
<path
d="M573.29,622.84v10.22h-16.82v52.92h-12.66v-52.92h-16.83v-10.22h46.31Z"
/>
<path
d="M595.18,633.06v15.83h21.26v10.04h-21.26v16.73h23.97v10.31h-36.64v-63.23h36.64v10.31h-23.97Z"
/>
</g>
</g>
</svg>
</template>
<style lang="postcss" scoped>
.text-logo {
fill: var(--color-logo);
}
</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

@@ -2,12 +2,7 @@
```vue
<template>
<LinearChart
title="Chart title"
subtitle="Chart subtitle"
:data="data"
:value-formatter="customValueFormatter"
/>
<LinearChart :data="data" :value-formatter="customValueFormatter" />
</template>
<script lang="ts" setup>

View File

@@ -1,12 +1,8 @@
<template>
<UiCard class="linear-chart">
<VueCharts :option="option" autoresize class="chart" />
<slot name="summary" />
</UiCard>
<VueCharts :option="option" autoresize class="chart" />
</template>
<script lang="ts" setup>
import UiCard from "@/components/ui/UiCard.vue";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_CHART_VALUE_FORMATTER } from "@/types/injection-keys";
import { utcFormat } from "d3-time-format";
@@ -15,7 +11,6 @@ import { LineChart } from "echarts/charts";
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from "echarts/components";
import { use } from "echarts/core";
@@ -26,8 +21,6 @@ import VueCharts from "vue-echarts";
const Y_AXIS_MAX_VALUE = 200;
const props = defineProps<{
title?: string;
subtitle?: string;
data: LinearChartData;
valueFormatter?: ValueFormatter;
maxValue?: number;
@@ -52,15 +45,10 @@ use([
LineChart,
GridComponent,
TooltipComponent,
TitleComponent,
LegendComponent,
]);
const option = computed<EChartsOption>(() => ({
title: {
text: props.title,
subtext: props.subtitle,
},
legend: {
data: props.data.map((series) => series.label),
},

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,8 @@ 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/CodeHighlight.vue"), { code });
</script>
<style lang="postcss" scoped>

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

@@ -25,10 +25,11 @@ defineProps<{
align-items: center;
height: 4.4rem;
padding-right: 1.5rem;
padding-left: 1rem;
padding-left: 1.5rem;
white-space: nowrap;
border-radius: 0.8rem;
gap: 1rem;
background-color: var(--color-blue-scale-500);
&.disabled {
color: var(--color-blue-scale-400);

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: string;
}>();
</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,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

@@ -1,33 +1,44 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoData component in case of a data recovery error -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('network-throughput')"
:value-formatter="customValueFormatter"
/>
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("network-throughput") }}</UiCardTitle>
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
{{ $t("last-week") }}
</UiCardTitle>
<NoDataError v-if="hasError" />
<UiCardSpinner v-else-if="isLoading" />
<LinearChart
v-else
:data="data"
:max-value="customMaxValue"
:value-formatter="customValueFormatter"
/>
</UiCard>
</template>
<script lang="ts" setup>
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { computed, defineAsyncComponent, inject } from "vue";
import { map } from "lodash-es";
import { useI18n } from "vue-i18n";
import { formatSize } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import type { LinearChartData } from "@/types/chart";
import { map } from "lodash-es";
import NoDataError from "@/components/NoDataError.vue";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import { UiCardTitleLevel } from "@/types/enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const LinearChart = defineAsyncComponent(
() => import("@/components/charts/LinearChart.vue")
);
const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const { hasError, isFetching } = useHostCollection();
const data = computed<LinearChartData>(() => {
const stats = hostLastWeekStats?.stats?.value;
@@ -82,6 +93,25 @@ const data = computed<LinearChartData>(() => {
];
});
const isStatFetched = computed(() => {
const stats = hostLastWeekStats?.stats?.value;
if (stats === undefined) {
return false;
}
return stats.every((host) => {
const hostStats = host.stats;
return (
hostStats != null &&
Object.values(hostStats.pifs["rx"])[0].length +
Object.values(hostStats.pifs["tx"])[0].length ===
data.value[0].data.length + data.value[1].data.length
);
});
});
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
// TODO: improve the way to get the max value of graph
// See: https://github.com/vatesfr/xen-orchestra/pull/6610/files#r1072237279
const customMaxValue = computed(

View File

@@ -1,22 +1,23 @@
<template>
<UiCardTitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
subtitle
/>
<NoDataError v-if="hasError" />
<UsageBar v-else :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
</template>
<script lang="ts" setup>
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { computed, inject, type ComputedRef } from "vue";
import { getAvgCpuUsage } from "@/libs/utils";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { UiCardTitleLevel } from "@/types/enums";
import UsageBar from "@/components/UsageBar.vue";
import { useHostCollection } from "@/stores/xen-api/host.store";
const { hasError } = useHostCollection();

View File

@@ -1,24 +1,33 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: Display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('pool-cpu-usage')"
:value-formatter="customValueFormatter"
/>
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("pool-cpu-usage") }}</UiCardTitle>
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
{{ $t("last-week") }}
</UiCardTitle>
<NoDataError v-if="hasError" />
<UiCardSpinner v-else-if="isLoading" />
<LinearChart
v-else
:data="data"
:max-value="customMaxValue"
:value-formatter="customValueFormatter"
/>
</UiCard>
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/stores/xen-api/host.store";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
import { computed, defineAsyncComponent, inject } from "vue";
import type { HostStats } from "@/libs/xapi-stats";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import NoDataError from "@/components/NoDataError.vue";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { sumBy } from "lodash-es";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import { UiCardTitleLevel } from "@/types/enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useI18n } from "vue-i18n";
const LinearChart = defineAsyncComponent(
@@ -29,8 +38,7 @@ const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const { records: hosts } = useHostCollection();
const { records: hosts, isFetching, hasError } = useHostCollection();
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
);
@@ -79,6 +87,22 @@ const data = computed<LinearChartData>(() => {
},
];
});
const isStatFetched = computed(() => {
const stats = hostLastWeekStats?.stats?.value;
if (stats === undefined) {
return false;
}
return stats.every((host) => {
const hostStats = host.stats;
return (
hostStats != null &&
Object.values(hostStats.cpus)[0].length === data.value[0].data.length
);
});
});
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
const customValueFormatter: ValueFormatter = (value) => `${value}%`;
</script>

View File

@@ -1,6 +1,6 @@
<template>
<UiCardTitle
subtitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
@@ -9,6 +9,7 @@
</template>
<script lang="ts" setup>
import { type ComputedRef, computed, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
@@ -16,7 +17,7 @@ import { useVmCollection } from "@/stores/xen-api/vm.store";
import { getAvgCpuUsage } from "@/libs/utils";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
import { UiCardTitleLevel } from "@/types/enums";
const { hasError } = useVmCollection();

View File

@@ -1,6 +1,6 @@
<template>
<UiCardTitle
subtitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
/>
@@ -13,6 +13,7 @@ import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { type ComputedRef, computed, inject } from "vue";
import { UiCardTitleLevel } from "@/types/enums";
import UsageBar from "@/components/UsageBar.vue";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";

View File

@@ -1,37 +1,43 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('pool-ram-usage')"
:value-formatter="customValueFormatter"
>
<template #summary>
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
</template>
</LinearChart>
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("pool-ram-usage") }}</UiCardTitle>
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
{{ $t("last-week") }}
</UiCardTitle>
<NoDataError v-if="hasError" />
<UiCardSpinner v-else-if="isLoading" />
<LinearChart
v-else
:data="data"
:max-value="customMaxValue"
:value-formatter="customValueFormatter"
/>
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
</UiCard>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, inject } from "vue";
import { formatSize } from "@/libs/utils";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import type { LinearChartData } from "@/types/chart";
import NoDataError from "@/components/NoDataError.vue";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import { sumBy } from "lodash-es";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import { UiCardTitleLevel } from "@/types/enums";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { useHostMetricsCollection } from "@/stores/xen-api/host-metrics.store";
import { formatSize } from "@/libs/utils";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
import { computed, defineAsyncComponent, inject } from "vue";
import { useI18n } from "vue-i18n";
const LinearChart = defineAsyncComponent(
() => import("@/components/charts/LinearChart.vue")
);
const { runningHosts } = useHostCollection();
const { runningHosts, isFetching, hasError } = useHostCollection();
const { getHostMemory } = useHostMetricsCollection();
const { t } = useI18n();
@@ -92,6 +98,23 @@ const data = computed<LinearChartData>(() => {
];
});
const customValueFormatter: ValueFormatter = (value) =>
String(formatSize(value));
const isStatFetched = computed(() => {
const stats = hostLastWeekStats?.stats?.value;
if (stats === undefined) {
return false;
}
return stats.every((host) => {
const hostStats = host.stats;
return (
hostStats != null && hostStats.memory.length === data.value[0].data.length
);
});
});
const isLoading = computed(
() => (isFetching.value && !hasError.value) || !isStatFetched.value
);
const customValueFormatter = (value: number) => String(formatSize(value));
</script>

View File

@@ -1,6 +1,6 @@
<template>
<UiCardTitle
subtitle
:level="UiCardTitleLevel.SubtitleWithUnderline"
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
@@ -9,14 +9,15 @@
</template>
<script lang="ts" setup>
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { computed, inject, type ComputedRef } from "vue";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { UiCardTitleLevel } from "@/types/enums";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
const { hasError } = useVmCollection();

View File

@@ -1,35 +1,40 @@
<template>
<div :class="{ subtitle }" class="ui-section-title">
<component
:is="subtitle ? 'h5' : 'h4'"
v-if="$slots.default || left"
class="left"
>
<div :class="['ui-section-title', tags.left]">
<component :is="tags.left" v-if="$slots.default || left" class="left">
<slot>{{ left }}</slot>
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
</component>
<component
:is="subtitle ? 'h6' : 'h5'"
v-if="$slots.right || right"
class="right"
>
<component :is="tags.right" v-if="$slots.right || right" class="right">
<slot name="right">{{ right }}</slot>
</component>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import { UiCardTitleLevel } from "@/types/enums";
withDefaults(
const props = withDefaults(
defineProps<{
subtitle?: boolean;
count?: number;
level?: UiCardTitleLevel;
left?: string;
right?: string;
count?: number;
}>(),
{ count: 0 }
{ count: 0, level: UiCardTitleLevel.Title }
);
const tags = computed(() => {
switch (props.level) {
case UiCardTitleLevel.Subtitle:
return { left: "h6", right: "h6" };
case UiCardTitleLevel.SubtitleWithUnderline:
return { left: "h5", right: "h6" };
default:
return { left: "h4", right: "h5" };
}
});
</script>
<style lang="postcss" scoped>
@@ -37,7 +42,6 @@ withDefaults(
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
--section-title-left-size: 2rem;
--section-title-left-color: var(--color-blue-scale-100);
@@ -46,9 +50,17 @@ withDefaults(
--section-title-right-color: var(--color-extra-blue-base);
--section-title-right-weight: 700;
&.subtitle {
border-bottom: 1px solid var(--color-extra-blue-base);
&.h6 {
margin-bottom: 1rem;
--section-title-left-size: 1.5rem;
--section-title-left-color: var(--color-blue-scale-300);
--section-title-left-weight: 400;
}
&.h5 {
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-extra-blue-base);
--section-title-left-size: 1.6rem;
--section-title-left-color: var(--color-extra-blue-base);
--section-title-left-weight: 700;

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/index";
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

@@ -1,6 +1,9 @@
<template>
<MenuItem
v-tooltip="!areAllSelectedVmsHalted && $t('selected-vms-in-execution')"
v-tooltip="
!areAllSelectedVmsHalted &&
$t(isSingleAction ? 'vm-is-running' : 'selected-vms-in-execution')
"
:busy="areSomeSelectedVmsCloning"
:disabled="isDisabled"
:icon="faCopy"
@@ -22,6 +25,7 @@ import { computed } from "vue";
const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
isSingleAction?: boolean;
}>();
const { getByOpaqueRef, isOperationPending } = useVmCollection();

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,95 +1,60 @@
<template>
<MenuItem
v-tooltip="
!areAllVmsMigratable && $t('some-selected-vms-can-not-be-migrated')
selectedRefs.length > 0 &&
!isMigratable &&
$t('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"][];
}>();
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

@@ -11,6 +11,23 @@
</template>
<VmActionPowerStateItems :vm-refs="[vm.$ref]" />
</AppMenu>
<AppMenu v-if="vm !== undefined" placement="bottom-end" shadow>
<template #trigger="{ open, isOpen }">
<UiButton
:active="isOpen"
:icon="faEllipsisVertical"
@click="open"
transparent
class="more-actions-button"
v-tooltip="{
placement: 'left',
content: $t('more-actions'),
}"
/>
</template>
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
</AppMenu>
</template>
</TitleBar>
</template>
@@ -21,11 +38,15 @@ import TitleBar from "@/components/TitleBar.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.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 { useVmCollection } from "@/stores/xen-api/vm.store";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import {
faAngleDown,
faDisplay,
faEllipsisVertical,
faPowerOff,
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
@@ -40,3 +61,9 @@ const vm = computed(() =>
const name = computed(() => vm.value?.name_label);
</script>
<style lang="postcss">
.more-actions-button {
font-size: 1.2em;
}
</style>

View File

@@ -10,8 +10,7 @@ export const useChartTheme = () => {
const getColors = () => ({
background: style.getPropertyValue("--background-color-primary"),
title: style.getPropertyValue("--color-blue-scale-100"),
subtitle: style.getPropertyValue("--color-blue-scale-300"),
text: style.getPropertyValue("--color-blue-scale-300"),
splitLine: style.getPropertyValue("--color-blue-scale-400"),
primary: style.getPropertyValue("--color-extra-blue-base"),
secondary: style.getPropertyValue("--color-orange-world-base"),
@@ -28,24 +27,10 @@ export const useChartTheme = () => {
backgroundColor: colors.value.background,
textStyle: {},
grid: {
top: 80,
top: 40,
left: 80,
right: 20,
},
title: {
textStyle: {
color: colors.value.title,
fontFamily: "Poppins, sans-serif",
fontWeight: 500,
fontSize: 20,
},
subtextStyle: {
color: colors.value.subtitle,
fontFamily: "Poppins, sans-serif",
fontWeight: 400,
fontSize: 14,
},
},
line: {
itemStyle: {
borderWidth: 2,
@@ -235,7 +220,7 @@ export const useChartTheme = () => {
},
axisLabel: {
show: true,
color: colors.value.subtitle,
color: colors.value.text,
},
splitLine: {
show: true,
@@ -295,7 +280,7 @@ export const useChartTheme = () => {
},
axisLabel: {
show: true,
color: colors.value.subtitle,
color: colors.value.text,
},
splitLine: {
show: true,
@@ -325,7 +310,7 @@ export const useChartTheme = () => {
left: "right",
top: "bottom",
textStyle: {
color: colors.value.subtitle,
color: colors.value.text,
},
},
tooltip: {

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

@@ -75,9 +75,12 @@
"following-hosts-unreachable": "The following hosts are unreachable",
"force-reboot": "Force reboot",
"force-shutdown": "Force shutdown",
"fullscreen": "Fullscreen",
"fullscreen-leave": "Leave fullscreen",
"go-back": "Go back",
"here": "Here",
"hosts": "Hosts",
"keep-me-logged": "Keep me logged in",
"language": "Language",
"last-week": "Last week",
"learn-more": "Learn more",
@@ -85,6 +88,7 @@
"loading-hosts": "Loading hosts…",
"log-out": "Log out",
"login": "Login",
"more-actions": "More actions",
"migrate": "Migrate",
"migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"n-hosts-awaiting-patch": "{n} host is awaiting this patch | {n} hosts are awaiting this patch",
@@ -99,12 +103,13 @@
"news": "News",
"news-name": "{name} news",
"no-alarm-triggered": "No alarm triggered",
"no-selected-vm-can-be-migrated": "No selected VM can be migrated",
"no-tasks": "No tasks",
"not-found": "Not found",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"on-object": "on {object}",
"open-in-new-window": "Open in new window",
"open-console-in-new-tab": "Open console in new tab",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
@@ -135,11 +140,11 @@
"save": "Save",
"select-destination-host": "Select a destination host",
"selected-vms-in-execution": "Some selected VMs are running",
"send-ctrl-alt-del": "Send Ctrl+Alt+Del",
"send-us-feedback": "Send us feedback",
"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",
@@ -172,6 +177,7 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs used",
"version": "Version",
"vm-is-running": "The VM is running",
"vms": "VMs",
"xo-lite-under-construction": "XOLite is under construction"
}

View File

@@ -75,9 +75,12 @@
"following-hosts-unreachable": "Les hôtes suivants sont inaccessibles",
"force-reboot": "Forcer le redémarrage",
"force-shutdown": "Forcer l'arrêt",
"fullscreen": "Plein écran",
"fullscreen-leave": "Quitter plein écran",
"go-back": "Revenir en arrière",
"here": "Ici",
"hosts": "Hôtes",
"keep-me-logged": "Rester connecté",
"language": "Langue",
"last-week": "Semaine dernière",
"learn-more": "En savoir plus",
@@ -85,6 +88,7 @@
"loading-hosts": "Chargement des hôtes…",
"log-out": "Se déconnecter",
"login": "Connexion",
"more-actions": "Plus d'actions",
"migrate": "Migrer",
"migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"n-hosts-awaiting-patch": "{n} hôte attend ce patch | {n} hôtes attendent ce patch",
@@ -99,12 +103,13 @@
"news": "Actualités",
"news-name": "Actualités {name}",
"no-alarm-triggered": "Aucune alarme déclenchée",
"no-selected-vm-can-be-migrated": "Aucune VM sélectionnée ne peut être migrée",
"no-tasks": "Aucune tâche",
"not-found": "Non trouvé",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"on-object": "sur {object}",
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
"open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
"or": "Ou",
"page-not-found": "Cette page est introuvable…",
"password": "Mot de passe",
@@ -135,11 +140,11 @@
"save": "Enregistrer",
"select-destination-host": "Sélectionnez un hôte de destination",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"send-ctrl-alt-del": "Envoyer Ctrl+Alt+Suppr",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
"shutdown": "Arrêter",
"snapshot": "Instantané",
"some-selected-vms-can-not-be-migrated": "Certaines VMs sélectionnées ne peuvent pas être migrées",
"sort-by": "Trier par",
"stacked-cpu-usage": "Utilisation CPU empilée",
"stacked-ram-usage": "Utilisation RAM empilée",
@@ -172,6 +177,7 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs utilisés",
"version": "Version",
"vm-is-running": "La VM est en cours d'exécution",
"vms": "VMs",
"xo-lite-under-construction": "XOLite est en construction"
}

View File

@@ -0,0 +1,73 @@
import type { ModalController } from "@/types";
import { createEventHook } from "@vueuse/core";
import { defineStore } from "pinia";
import {
type AsyncComponentLoader,
computed,
defineAsyncComponent,
markRaw,
reactive,
ref,
} from "vue";
export const useModalStore = defineStore("modal", () => {
const modals = ref(new Map<symbol, ModalController>());
const close = (id: symbol) => {
modals.value.delete(id);
};
const open = <T>(loader: AsyncComponentLoader, props: object) => {
const id = Symbol();
const isBusy = ref(false);
const component = defineAsyncComponent(loader);
const approveEvent = createEventHook<T>();
const declineEvent = createEventHook();
const approve = async (payload: any) => {
try {
isBusy.value = true;
const result = await payload;
await approveEvent.trigger(result);
close(id);
} finally {
isBusy.value = false;
}
};
const decline = async () => {
try {
isBusy.value = true;
await declineEvent.trigger(undefined);
close(id);
} finally {
isBusy.value = false;
}
};
modals.value.set(
id,
reactive({
id,
component: markRaw(component),
props,
approve,
decline,
isBusy,
})
);
return {
onApprove: approveEvent.on,
onDecline: declineEvent.on,
id,
};
};
return {
open,
close,
modals: computed(() => modals.value.values()),
};
});

View File

@@ -1,7 +1,7 @@
import { useBreakpoints, useColorMode } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
@@ -14,8 +14,15 @@ export const useUiStore = defineStore("ui", () => {
const isMobile = computed(() => !isDesktop.value);
const router = useRouter();
const route = useRoute();
const hasUi = computed(() => route.query.ui !== "0");
const hasUi = computed<boolean>({
get: () => route.query.ui !== "0",
set: (value: boolean) => {
void router.replace({ query: { ui: value ? undefined : "0" } });
},
});
return {
colorMode,

View File

@@ -2,7 +2,7 @@ import XapiStats from "@/libs/xapi-stats";
import XenApi from "@/libs/xen-api/xen-api";
import { useLocalStorage } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { computed, ref, watchEffect } from "vue";
const HOST_URL = import.meta.env.PROD
? window.origin
@@ -17,16 +17,24 @@ enum STATUS {
export const useXenApiStore = defineStore("xen-api", () => {
const xenApi = new XenApi(HOST_URL);
const xapiStats = new XapiStats(xenApi);
const currentSessionId = useLocalStorage<string | undefined>(
const storedSessionId = useLocalStorage<string | undefined>(
"sessionId",
undefined
);
const currentSessionId = ref(storedSessionId.value);
const rememberMe = useLocalStorage("rememberMe", false);
const status = ref(STATUS.DISCONNECTED);
const isConnected = computed(() => status.value === STATUS.CONNECTED);
const isConnecting = computed(() => status.value === STATUS.CONNECTING);
const getXapi = () => xenApi;
const getXapiStats = () => xapiStats;
watchEffect(() => {
storedSessionId.value = rememberMe.value
? currentSessionId.value
: undefined;
});
const connect = async (username: string, password: string) => {
status.value = STATUS.CONNECTING;
@@ -63,7 +71,7 @@ export const useXenApiStore = defineStore("xen-api", () => {
async function disconnect() {
await xenApi.disconnect();
currentSessionId.value = null;
currentSessionId.value = undefined;
status.value = STATUS.DISCONNECTED;
}

View File

@@ -36,6 +36,17 @@ export const useVmStore = defineStore("xen-api-vm", () => {
);
};
const areSomeOperationAllowed = (
vm: XenApiVm,
operations: VM_OPERATION[] | VM_OPERATION
) => {
const allowedOperations = Object.values(vm.allowed_operations);
return castArray(operations).some((operation) =>
allowedOperations.includes(operation)
);
};
const runningVms = computed(() =>
records.value.filter((vm) => vm.power_state === VM_POWER_STATE.RUNNING)
);
@@ -92,6 +103,7 @@ export const useVmStore = defineStore("xen-api-vm", () => {
...context,
records,
isOperationPending,
areSomeOperationAllowed,
runningVms,
recordsByHostRef,
getStats,

View File

@@ -16,8 +16,6 @@ type LinearChartData = {
```vue-template
<LinearChart
title="Chart title"
subtitle="Chart subtitle"
:data="data"
/>
```

View File

@@ -1,8 +1,6 @@
<template>
<ComponentStory
:params="[
prop('title').preset('Chart title').widget(),
prop('subtitle').preset('Here is a subtitle').widget(),
prop('data')
.preset(data)
.required()
@@ -58,8 +56,6 @@ const data: LinearChartData = [
const presets = {
"Network bandwidth": {
props: {
title: "Network bandwidth",
subtitle: "Last week",
"value-formatter": byteFormatter,
"max-value": 500000000,
data: [

View File

@@ -1,11 +1,7 @@
```vue-template
<UiModal v-model="isOpen">
<UiModal>
<BasicModalLayout>
Here is a basic modal...
</BasicModalLayout>
</UiModal>
```
```vue-script
const { isOpen } = useModal();
```

View File

@@ -3,7 +3,9 @@
v-slot="{ settings }"
:params="[
slot(),
setting('defaultSlotContent').preset('Modal content').widget(text()),
setting('defaultSlotContent')
.preset('Here is a basic modal...')
.widget(text()),
]"
>
<BasicModalLayout>

View File

@@ -1,21 +1,18 @@
```vue-template
<UiModal v-model="isOpen">
<UiModal @submit.prevent="approve()">
<ConfirmModalLayout :icon="faShip">
<template #title>Do you confirm?</template>
<template #subtitle>You should be sure about this</template>
<template #buttons>
<UiButton outlined @click="close">I prefer not</UiButton>
<UiButton @click="accept">Yes, I'm sure!</UiButton>
<ModalDeclineButton>I prefer not</UiButton>
<ModalApproveButton>Yes, I'm sure!</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
```
```vue-script
const { isOpen, close } = useModal();
import { IK_MODAL } from "@/types/injection-keys";
const accept = async () => {
// do something
close();
}
const { approve } = inject(IK_MODAL)!;
```

View File

@@ -2,21 +2,21 @@
<ComponentStory
v-slot="{ properties, settings }"
:params="[
iconProp(),
iconProp().preset(faShip),
slot('title'),
slot('subtitle'),
slot('default'),
slot('buttons').help('Meant to receive UiButton components'),
setting('title').preset('Modal Title').widget(),
setting('subtitle').preset('Modal Subtitle').widget(),
setting('title').preset('Do you confirm?').widget(),
setting('subtitle').preset('You should be sure about this').widget(),
]"
>
<ConfirmModalLayout v-bind="properties">
<template #title>{{ settings.title }}</template>
<template #subtitle>{{ settings.subtitle }}</template>
<template #buttons>
<UiButton outlined>Discard</UiButton>
<UiButton>Go</UiButton>
<UiButton outlined>I prefer not</UiButton>
<UiButton>Yes, I'm sure!</UiButton>
</template>
</ConfirmModalLayout>
</ComponentStory>
@@ -27,6 +27,5 @@ import ComponentStory from "@/components/component-story/ComponentStory.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { iconProp, setting, slot } from "@/libs/story/story-param";
import { faShip } from "@fortawesome/free-solid-svg-icons";
</script>
<style lang="postcss" scoped></style>

View File

@@ -1,6 +1,6 @@
```vue-template
<UiModal v-model="isOpen">
<FormModalLayout :icon="faShip" @submit.prevent="handleSubmit">
<UiModal @submit.prevent="handleSubmit()">
<FormModalLayout :icon="faShip">
<template #title>Migrate 3 VMs/template>
<template #default>
@@ -8,18 +8,23 @@
</template>
<template #buttons>
<UiButton outlined @click="close">Cancel</UiButton>
<UiButton type="submit">Migrate 3 VMs</UiButton>
<ModalDeclineButton />
<ModalApproveButton>Migrate 3 VMs</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
```
```vue-script
const { isOpen, close } = useModal();
import { IK_MODAL } from "@/types/injection-keys";
const handleSubmit = async () => {
// Handling form submission...
close();
const { approve } = inject(IK_MODAL)!;
const migrate = async () => {
// Do the migration...
}
const handleSubmit = () => {
approve(migrate());
}
```

View File

@@ -1,12 +1,10 @@
A basic modal container containing 3 slots: `header`, `default` and `footer`.
Tag will be `div` by default but can be changed with the `tag` prop.
Color can be changed with the `color` prop.
To keep the content centered vertically, header and footer will always have the same height.
Modal content has an max height + overflow to prevent the modal growing out of the screen.
Modal content has a max height + overflow to prevent the modal growing out of the screen.
Modal containers can be nested.

View File

@@ -2,7 +2,6 @@
<ComponentStory
v-slot="{ properties, settings }"
:params="[
prop('tag').str().default('div').widget(),
colorProp(),
slot('header'),
slot(),
@@ -47,6 +46,6 @@
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
import { colorProp, prop, setting, slot } from "@/libs/story/story-param";
import { colorProp, setting, slot } from "@/libs/story/story-param";
import { boolean, text } from "@/libs/story/story-widget";
</script>

View File

@@ -1,21 +0,0 @@
This component only handle the modal backdrop and content positioning.
You can use any pre-made layouts, create your own or use the `ModalContainer` component.
It is meant to be used with `useModal` composable.
```vue-template
<button @click="open">Delete all items</button>
<UiModal v-model="isOpen">
<ModalContainer...>
<!-- <ConfirmModalLayout ...> (Or you can use a pre-made layout) -->
</UiModal>
```
```vue-script
import { faRemove } from "@fortawesome/free-solid-svg-icons";
import { useModal } from "@composable/modal.composable";
const { open, close, isOpen } = useModal().
```

View File

@@ -1,24 +0,0 @@
<template>
<ComponentStory
:params="[
model()
.required()
.type('boolean')
.help('Whether the modal is opened or not'),
colorProp().ctx(),
slot().help('Place your ModalContainer here'),
]"
>
<button type="button" @click="open">Open modal</button>
<UiModal v-model="isOpen" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import useModal from "@/composables/modal.composable";
import { colorProp, model, slot } from "@/libs/story/story-param";
const { isOpen, open } = useModal();
</script>

View File

@@ -0,0 +1,5 @@
export enum UiCardTitleLevel {
Title,
Subtitle,
SubtitleWithUnderline,
}

View File

@@ -1 +1,10 @@
export type Color = "info" | "error" | "warning" | "success";
export type ModalController = {
id: symbol;
component: any;
props: object;
approve: <P>(payload?: P) => void;
decline: () => void;
isBusy: boolean;
};

View File

@@ -2,6 +2,7 @@ import type { FetchedStats, Stat } from "@/composables/fetch-stats.composable";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api/xen-api.types";
import type { ValueFormatter } from "@/types/chart";
import type { ModalController } from "@/types/index";
import type { ComputedRef, InjectionKey } from "vue";
export const IK_MENU_TELEPORTED = Symbol() as InjectionKey<boolean>;
@@ -51,3 +52,5 @@ export const IK_INPUT_ID = Symbol() as InjectionKey<ComputedRef<string>>;
export const IK_MODAL_CLOSE = Symbol() as InjectionKey<() => void>;
export const IK_MODAL_NESTED = Symbol() as InjectionKey<boolean>;
export const IK_MODAL = Symbol() as InjectionKey<ModalController>;

View File

@@ -17,3 +17,8 @@ export interface SortConfig<T> {
queryStringParam?: string;
initialSorts?: InitialSorts<T>;
}
export type NewSort = {
property: string;
isAscending: boolean;
};

View File

@@ -7,40 +7,62 @@
{{ $t("power-on-for-console") }}
</div>
<template v-else-if="vm && vmConsole">
<AppMenu horizontal>
<MenuItem
:icon="faArrowUpRightFromSquare"
@click="openInNewTab"
v-if="uiStore.hasUi"
>
{{ $t("open-console-in-new-tab") }}
</MenuItem>
<MenuItem
:icon="
uiStore.hasUi
? faUpRightAndDownLeftFromCenter
: faDownLeftAndUpRightToCenter
"
@click="toggleFullScreen"
>
{{ $t(uiStore.hasUi ? "fullscreen" : "fullscreen-leave") }}
</MenuItem>
<MenuItem
:disabled="!consoleElement"
:icon="faKeyboard"
@click="sendCtrlAltDel"
>
{{ $t("send-ctrl-alt-del") }}
</MenuItem>
</AppMenu>
<RemoteConsole
ref="consoleElement"
:is-console-available="isConsoleAvailable"
:location="vmConsole.location"
class="remote-console"
/>
<div class="open-in-new-window">
<RouterLink
v-if="uiStore.hasUi"
:to="{ query: { ui: '0' } }"
class="link"
target="_blank"
>
<UiIcon :icon="faArrowUpRightFromSquare" />
{{ $t("open-in-new-window") }}
</RouterLink>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import RemoteConsole from "@/components/RemoteConsole.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useConsoleCollection } from "@/stores/xen-api/console.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { VM_OPERATION, VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { VM_POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useConsoleCollection } from "@/stores/xen-api/console.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import {
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faKeyboard,
faUpRightAndDownLeftFromCenter,
} from "@fortawesome/free-solid-svg-icons";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
const STOP_OPERATIONS = [
VM_OPERATION.SHUTDOWN,
@@ -54,6 +76,7 @@ const STOP_OPERATIONS = [
usePageTitleStore().setTitle(useI18n().t("console"));
const router = useRouter();
const route = useRoute();
const uiStore = useUiStore();
@@ -95,14 +118,26 @@ const isConsoleAvailable = computed(() =>
? !isOperationPending(vm.value, STOP_OPERATIONS)
: false
);
const consoleElement = ref();
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel();
const toggleFullScreen = () => {
uiStore.hasUi = !uiStore.hasUi;
};
const openInNewTab = () => {
const routeData = router.resolve({ query: { ui: "0" } });
window.open(routeData.href, "_blank");
};
</script>
<style lang="postcss" scoped>
.vm-console-view {
display: flex;
align-items: center;
justify-content: center;
height: calc(100% - 14.5rem);
flex-direction: column;
&.no-ui {
height: 100%;
@@ -160,4 +195,9 @@ const isConsoleAvailable = computed(() =>
}
}
}
.vm-console-view:deep(.app-menu) {
background-color: transparent;
align-self: center;
}
</style>

View File

@@ -24,6 +24,8 @@ const serializeError = error => ({
})
export default class Tasks extends EventEmitter {
#logsToClearOnSuccess = new Set()
// contains consolidated logs of all live and finished tasks
#store
@@ -36,6 +38,22 @@ export default class Tasks extends EventEmitter {
this.#tasks.delete(id)
},
onTaskUpdate: async taskLog => {
const { id, status } = taskLog
if (status !== 'pending') {
if (this.#logsToClearOnSuccess.has(id)) {
this.#logsToClearOnSuccess.delete(id)
if (status === 'success') {
try {
await this.#store.del(id)
} catch (error) {
warn('failure on deleting task log from store', { error, taskLog })
}
return
}
}
}
// Error objects are not JSON-ifiable by default
const { result } = taskLog
if (result instanceof Error && result.toJSON === undefined) {
@@ -135,7 +153,10 @@ export default class Tasks extends EventEmitter {
*
* @returns {Task}
*/
create({ name, objectId, userId = this.#app.apiContext?.user?.id, type, ...props }) {
create(
{ name, objectId, userId = this.#app.apiContext?.user?.id, type, ...props },
{ clearLogOnSuccess = false } = {}
) {
const tasks = this.#tasks
const task = new Task({ properties: { ...props, name, objectId, userId, type }, onProgress: this.#onProgress })
@@ -152,6 +173,9 @@ export default class Tasks extends EventEmitter {
task.id = id
tasks.set(id, task)
if (clearLogOnSuccess) {
this.#logsToClearOnSuccess.add(id)
}
return task
}

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "AGPL-3.0-or-later",
"version": "0.13.0",
"version": "0.14.0",
"engines": {
"node": ">=15.6"
},

View File

@@ -5,7 +5,6 @@ import { compose } from '@vates/compose'
import { createLogger } from '@xen-orchestra/log'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { defer } from 'golike-defer'
import { DurablePartition } from '@xen-orchestra/backups/DurablePartition.mjs'
import { execFile } from 'child_process'
import { formatVmBackups } from '@xen-orchestra/backups/formatVmBackups.mjs'
@@ -23,16 +22,18 @@ const noop = Function.prototype
const { warn } = createLogger('xo:proxy:backups')
const runWithLogs = (runner, args) =>
const runWithLogs = (runner, args, onEnd) =>
new Readable({
objectMode: true,
read() {
this._read = noop
runner(args, log => this.push(log)).then(
() => this.push(null),
error => this.emit('error', error)
)
runner(args, log => this.push(log))
.then(
() => this.push(null),
error => this.emit('error', error)
)
.then(onEnd)
},
})[Symbol.asyncIterator]()
@@ -190,30 +191,41 @@ export default class Backups {
},
],
importVmBackup: [
defer(($defer, { backupId, remote, srUuid, settings, streamLogs = false, xapi: xapiOpts }) =>
Disposable.use(this.getAdapter(remote), this.getXapi(xapiOpts), async (adapter, xapi) => {
const metadata = await adapter.readVmBackupMetadata(backupId)
const run = () => new ImportVmBackup({ adapter, metadata, settings, srUuid, xapi }).run()
return streamLogs
? runWithLogs(
async (args, onLog) =>
Task.run(
{
data: {
backupId,
jobId: metadata.jobId,
srId: srUuid,
time: metadata.timestamp,
},
name: 'restore',
onLog,
},
run
).catch(() => {}) // errors are handled by logs
)
: run()
})
),
async ({ backupId, remote, srUuid, settings, streamLogs = false, xapi: xapiOpts }) => {
const {
dispose,
value: [adapter, xapi],
} = await Disposable.all([this.getAdapter(remote), this.getXapi(xapiOpts)])
const metadata = await adapter.readVmBackupMetadata(backupId)
const run = () => new ImportVmBackup({ adapter, metadata, settings, srUuid, xapi }).run()
if (streamLogs) {
return runWithLogs(
async (args, onLog) =>
Task.run(
{
data: {
backupId,
jobId: metadata.jobId,
srId: srUuid,
time: metadata.timestamp,
},
name: 'restore',
onLog,
},
run
).catch(() => {}), // errors are handled by logs,
dispose
)
}
try {
return await run()
} finally {
await dispose()
}
},
{
description: 'create a new VM from a backup',
params: {

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.26.35",
"version": "0.26.38",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -32,13 +32,13 @@
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.0",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.13.0",
"@xen-orchestra/mixins": "^0.14.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/xapi": "^3.2.0",
"@xen-orchestra/xapi": "^3.3.0",
"ajv": "^8.0.3",
"app-conf": "^2.3.0",
"async-iterator-to-stream": "^1.1.0",

View File

@@ -5,3 +5,4 @@ export { default as VBD } from './vbd.mjs'
export { default as VDI } from './vdi.mjs'
export { default as VIF } from './vif.mjs'
export { default as VM } from './vm.mjs'
export { default as VTPM } from './vtpm.mjs'

View File

@@ -1,6 +1,8 @@
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { decorateClass } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { incorrectState, operationFailed } from 'xo-common/api-errors.js'
import { getCurrentVmUuid } from './_XenStore.mjs'
@@ -31,7 +33,38 @@ class Host {
*
* @param {string} ref - Opaque reference of the host
*/
async smartReboot($defer, ref) {
async smartReboot($defer, ref, bypassBlockedSuspend = false, bypassCurrentVmCheck = false) {
let currentVmRef
try {
currentVmRef = await this.call('VM.get_by_uuid', await getCurrentVmUuid())
} catch (error) {}
const residentVmRefs = await this.getField('host', ref, 'resident_VMs')
const vmsWithSuspendBlocked = await asyncMap(residentVmRefs, ref => this.getRecord('VM', ref)).filter(
vm =>
vm.$ref !== currentVmRef &&
!vm.is_control_domain &&
vm.power_state !== 'Halted' &&
vm.power_state !== 'Suspended' &&
vm.blocked_operations.suspend !== undefined
)
if (!bypassBlockedSuspend && vmsWithSuspendBlocked.length > 0) {
throw incorrectState({ actual: vmsWithSuspendBlocked.map(vm => vm.uuid), expected: [], object: 'suspendBlocked' })
}
if (!bypassCurrentVmCheck && residentVmRefs.includes(currentVmRef)) {
throw operationFailed({
objectId: await this.getField('VM', currentVmRef, 'uuid'),
code: 'xoaOnHost',
})
}
await asyncEach(vmsWithSuspendBlocked, vm => {
$defer(() => vm.update_blocked_operations('suspend', vm.blocked_operations.suspend ?? null))
return vm.update_blocked_operations('suspend', null)
})
const suspendedVms = []
if (await this.getField('host', ref, 'enabled')) {
await this.callAsync('host.disable', ref)
@@ -42,13 +75,8 @@ class Host {
})
}
let currentVmRef
try {
currentVmRef = await this.call('VM.get_by_uuid', await getCurrentVmUuid())
} catch (error) {}
await asyncEach(
await this.getField('host', ref, 'resident_VMs'),
residentVmRefs,
async vmRef => {
if (vmRef === currentVmRef) {
return

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/xapi",
"version": "3.2.0",
"version": "3.3.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {

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