Compare commits

..

203 Commits

Author SHA1 Message Date
ggunullu
124fd97d38 revert to vhd-util 2023-02-01 10:30:04 +01:00
ggunullu
7037b15cb3 update 2023-02-01 10:22:27 +01:00
ggunullu
9253718d5c try with debian 2023-02-01 10:21:18 +01:00
ggunullu
99f2b42243 update 2023-02-01 10:21:18 +01:00
ggunullu
43104d908c update 2023-02-01 10:21:18 +01:00
ggunullu
fde65bcd6a update dockerfile 2023-02-01 10:21:18 +01:00
ggunullu
26711456d5 try with bionic version 2023-02-01 10:21:18 +01:00
ggunullu
73a0356e4f remove blktap-utils installation 2023-02-01 10:21:18 +01:00
ggunullu
a4a828a71c first try with qemu check 2023-02-01 10:21:18 +01:00
ggunullu
3d9b9efc04 fix(dockerfile): change Node.js version 2023-02-01 10:21:18 +01:00
Julien Fontanet
675405f7ac feat: release 5.79.0 2023-01-31 17:49:51 +01:00
Thierry Goettelmann
f8a3536a88 feat(lite): RelativeTime component (#6620) 2023-01-31 17:10:26 +01:00
Julien Fontanet
e527a13b50 feat(xo-server): 5.109.0 2023-01-31 17:04:31 +01:00
Julien Fontanet
3be03451f8 feat(@xen-orchestra/vmware-explorer): 0.0.3 2023-01-31 17:02:24 +01:00
Florent BEAUCHAMP
9fa15d9c84 feat(xo-server): import VM from ESXi (#6595) 2023-01-31 16:54:18 +01:00
Mathieu
9c3d39b4a7 feat: technical release (#6648) 2023-01-31 11:18:19 +01:00
Mathieu
28800f43ee fix(lite): use browser timestamps for stats (#6623) 2023-01-31 10:26:07 +01:00
Gabriel Gunullu
5c0b29c51f feat(xo-web/network): NBD option (#6646) 2023-01-30 17:34:21 +01:00
Gabriel Gunullu
62d9d0208b feat(xo-server/network.set): support (un)setting NBD (#6635) 2023-01-30 16:02:28 +01:00
Pierre Donias
4bf871e52f fix(lite): stats.memory is undefined (#6647)
Introduced by 4f31b7007a
2023-01-30 14:40:57 +01:00
Florent BEAUCHAMP
103972808c fix(xo-vmdk-to-vhd): better computation of overprovisioning of very sparse disks (#6639) 2023-01-30 14:15:44 +01:00
Julien Fontanet
dc65bb87b5 feat(upload-ova): special handling of invalid params error (#6626)
Fixes #6622

Similar to 036b30212 & 65daa39eb
2023-01-30 14:09:28 +01:00
Mathieu
bfa0282ecc feat: technical release (#6645) 2023-01-27 16:16:26 +01:00
Mathieu
aa66ec0ccd fix(changelog): fix release type on a package (#6644) 2023-01-27 15:09:10 +01:00
Mathieu
18fe19c680 fix(lite): fix getHostMemoryFunction error (#6643) 2023-01-27 14:43:11 +01:00
Julien Fontanet
ab0e411ac0 chore(xo-server/rest-api): improve code
- mutualize object fetching
- mutualize error handling
2023-01-27 13:01:21 +01:00
Pierre Donias
79671e6d61 fix(lite/build): "Big integer literals are not available in the configured target environment" (#6638)
Introduced by a281682f7a
2023-01-26 11:42:29 +01:00
Mathieu
71ad9773da feat(lite/vm): ability to change state of a VM (#6608) 2023-01-26 09:43:12 +01:00
Julien Fontanet
34ecc2bcbb feat(xo-server/rest-api): support setting name_label/name_description 2023-01-25 17:29:49 +01:00
Pierre Donias
53f4f265dc fix(xo-web/host/network): remove extra "mode" column (#6640)
Introduced by 7ede6bdbce
2023-01-25 17:19:03 +01:00
Florent BEAUCHAMP
97624ef836 fix(xo-vmdk-to-vhd): memory consumption during ova generation (#6637) 2023-01-25 10:23:50 +01:00
Julien Fontanet
fb8d0ed924 fix(xen-api/examples/import-vdi): fix tasks watching
Introduced by 3e351f852
2023-01-24 16:31:03 +01:00
Gabriel Gunullu
fedbdba13d feat(xo-web/recipes): static network config for k8s recipe (#6598) 2023-01-24 11:04:02 +01:00
Julien Fontanet
a281682f7a chore: update dev deps 2023-01-23 18:31:07 +01:00
Julien Fontanet
07e9f09692 docs(xo-server/rest-api): minor fix 2023-01-23 17:16:32 +01:00
Julien Fontanet
29d6e590de feat(xo-server/rest-api): support exporting VDI in raw format 2023-01-23 17:14:24 +01:00
Julien Fontanet
3e351f8529 feat(xen-api/examples/import-vdi): can create the VDI and various flags 2023-01-23 17:13:41 +01:00
Julien Fontanet
bfbfb9379a feat(xo-cli): improve no server message 2023-01-23 09:31:01 +01:00
rajaa-b
4f31b7007a feat(lite): RAM usage graph (#6604) 2023-01-20 11:44:54 +01:00
rajaa-b
fe0cc2ebb9 feat(lite): network throughput chart (#6610) 2023-01-19 16:10:36 +01:00
Mathieu
2fd6f521f8 feat(xo-web/licenses): make id and boundObjectId copyable (#6634) 2023-01-19 15:11:10 +01:00
Florent BEAUCHAMP
ec00728112 feat(xo-web): add toggle for viridian flag (#6631)
Fixes #6572
2023-01-19 09:33:36 +01:00
Julien Fontanet
7174c1edeb chore(xo-server/rest-api): doc fixes and changelog entry
Introduced by 7bd27e743
2023-01-18 23:43:57 +01:00
Julien Fontanet
7bd27e7437 feat(xo-server/rest-api): support to destroy VMs/VDIs 2023-01-18 23:35:49 +01:00
Florent BEAUCHAMP
0a28e30003 fix(xo-web): clarify windows update label (#6632)
Fix #6628
2023-01-18 17:31:28 +01:00
Mathieu
246c793c28 fix(xo-web/licenses): move message for XCP-ng license binding (#6630) 2023-01-18 17:11:21 +01:00
Florent BEAUCHAMP
5f0ea4d586 fix(xo-web): show bootable status for VM running pv_in_pvh virtualisation mode (#6629)
Fix #6432
2023-01-18 17:09:26 +01:00
Julien Fontanet
3c7d316b3c feat(xo-server): initial tasks infrastructure (#6625) 2023-01-17 16:12:04 +01:00
Julien Fontanet
645c8f32e3 chore(xo-server-perf-alert): use @xen-orchestra/log@0.5.0
Introduced by #6550
2023-01-17 15:42:38 +01:00
Gabriel Gunullu
adc5e7d0c0 test(vhd-cli): from Jest to test (#6605) 2023-01-17 10:39:41 +01:00
Thierry Goettelmann
b9b74ab1ac feat(lite/ui): first implementation of responsive UI (#6612) 2023-01-17 10:22:08 +01:00
Thierry Goettelmann
64298c04f2 feat(lite/ui): UiModal fix (#6617) 2023-01-17 09:25:29 +01:00
Gabriel Gunullu
3dfb7db039 chore(xo-server-perf-alert): print error (#6550) 2023-01-16 22:53:53 +01:00
Julien Fontanet
b64d8f5cbf fix(xo-server/rest-api): handle filter parsing errors 2023-01-16 17:34:23 +01:00
Julien Fontanet
c2e5225728 feat(xo-server): expose host.residentVms 2023-01-16 17:33:47 +01:00
Florent BEAUCHAMP
6c44a94bf4 fix(vhd-lib/parseVhdStream): also consume stream in NBD mode (#6613)
Consuming the stream is necessary for all writers including DeltaBackupWriter) otherwise other writers (e.g. DeltaBackupWriter i.e. CR) will stay stuck.
2023-01-16 10:54:45 +01:00
Florent BEAUCHAMP
a2d9310d0a fix(backups): fix size of NBD backups (#6599) 2023-01-16 10:43:29 +01:00
Julien Fontanet
05197b93ee feat(proxy): dedupe logs 2023-01-15 13:08:57 +01:00
Julien Fontanet
448d115d49 feat(xo-server): dedupe logs 2023-01-15 13:04:52 +01:00
Julien Fontanet
ae993dff45 feat(log/dedupe): helper to remove duplicated logs 2023-01-15 12:59:31 +01:00
Julien Fontanet
1bc4805f3d chore(log): move Log into own module 2023-01-15 12:59:31 +01:00
Julien Fontanet
98fe8f3955 chore(log): move createTransport into own module 2023-01-15 12:59:31 +01:00
Julien Fontanet
e902bcef67 chore(log): prefix internal modules by _ 2023-01-15 12:59:31 +01:00
Julien Fontanet
cb2a6e43a8 chore(log/utils.test.js): rename to _compileLogPattern.test.js 2023-01-15 12:59:31 +01:00
Julien Fontanet
b73a0992f8 feat(log): define public entry points
BREAKING CHANGE: Importing modules with extensions is now unsupported, i.e. use `@xen-orchestra/log/configure` instead of `@xen-orchestra/log/configure.js`.

Allows ESM modules to import modules without specifying extensions (just like CJS module) which will make migrating this lib to ESM painless in the future.
2023-01-15 12:58:35 +01:00
Julien Fontanet
d0b3d78639 feat(xo-server): round up host memory to nearest GiB
Fixes #5776

Improves the display of the value by ignoring the micro-kernel size (~50MiB), ie `128 GiB` instead of `127.96 GiB`.
2023-01-13 15:06:06 +01:00
Julien Fontanet
e6b8939772 fix(xapi/VM_snapshot): don't fail on NOBAK VDIs destruction failure 2023-01-12 15:25:09 +01:00
Julien Fontanet
bc372a982c fix(xapi/VM_checkpoint): remove unsupported ignoreNobakVdis 2023-01-12 15:20:40 +01:00
Florent Beauchamp
3ff8064f1b feat(backups): add more info about NBD backups in logs 2023-01-12 10:28:30 +01:00
Florent Beauchamp
834459186d fix(backups): useNbd must follow the config 2023-01-12 10:28:30 +01:00
Mathieu
12220ad4cf fix(lite/UsageBar): add color for dangerous cases (#6606) 2023-01-12 09:22:07 +01:00
Julien Fontanet
f6fd1db1ef feat(xo-server): increase HTTP server request timeout to 1 day
Fixes #6590
2023-01-11 22:07:35 +01:00
Julien Fontanet
a1050882ae docs(installation): explicit FreeBSD/OpenBSD not officially supported 2023-01-11 15:11:54 +01:00
Mathieu
687df5ead4 feat(lite/vm): change state button (#6571) 2023-01-11 10:51:16 +01:00
Mathieu
b057881ad0 fix(lite): fix type checking (#6607) 2023-01-10 16:16:32 +01:00
Julien Fontanet
2b23550996 chore(vhd-lib/createVhdStreamWithLength): use readChunkStrict
Related to zammad#10996

Not only it simplified the code a bit, but it also provides better error messages, especially on stream end.
2023-01-10 11:11:38 +01:00
Thierry Goettelmann
afeb20e589 fix(lite/Console): fix isReady condition (#6594) 2023-01-06 10:44:45 +01:00
Julien Fontanet
d7794518a2 chore: update to fs-extra@11 & parse-pairs@2 2023-01-05 11:33:09 +01:00
Julien Fontanet
fee61a43e3 chore: update to sinon@15 2023-01-05 11:16:03 +01:00
Julien Fontanet
b201afd192 chore: update dev deps 2023-01-05 10:21:06 +01:00
Florent Beauchamp
feef1f8b0a fix(backups/cleanVm): fix tests 2023-01-04 10:54:22 +01:00
Florent Beauchamp
1a5e2fde4f fix(vhd-lib/merge): require aliases for VHD directories 2023-01-04 10:54:22 +01:00
Julien Fontanet
609e957a55 fix: build script should build xo-server plugins
Introduced by 3bfd6c697
2023-01-04 10:53:55 +01:00
Thierry Goettelmann
5c18404174 feat(lite): update useCollectionFilter composable (#6538)
- Query String support must now be explicitly enabled with the `queryStringParam` option
- Added `initialFilters` option
- Added generic type support
- Updated documentation
2023-01-04 09:51:39 +01:00
Thierry Goettelmann
866a1dd8ae feat(lite): update useCollectionSorter composable (#6540)
- Query String support must now be explicitly enabled with the `queryStringParam` option
- Added `initialFilters` option
- Added generic type support
- Updated documentation
2023-01-04 09:42:51 +01:00
Julien Fontanet
3bfd6c6979 chore: use Turborepo to build
Why?

- ordering: build dependencies before dependents
- cache: don't rebuild if no changes in files or dependencies
- possibility to restrict to specific scopes

Changes:

- `yarn build` now only build `xo-server` and `xo-web` (and dependencies)
- `yarn build:xo-lite` build `@xen-orchestra/lite\ (and dependencies)
2023-01-03 11:39:20 +01:00
Florent BEAUCHAMP
06564e9091 feat(backups): remove merge limitations (#6591)
following #0635b3316ea077fccaa8b2d1e7a4d801eb701811
2023-01-03 11:07:07 +01:00
Thierry Goettelmann
1702783cfb feat(lite): Reactive chart theme (#6587) 2022-12-21 15:00:26 +01:00
rajaa-b
4ea0cbaa37 feat(xo-lite): Pool CPU usage chart (#6577) 2022-12-21 12:03:04 +01:00
Mathieu
2246e065f7 feat: release 5.78.0 (#6588) 2022-12-20 13:54:30 +01:00
Mathieu
29a38cdf1a feat: technical release (#6586) 2022-12-19 14:30:41 +01:00
Julien Fontanet
960c569e86 fix(CHANGELOG): add missing backups changes
Introduced by f95a20173
2022-12-19 11:40:06 +01:00
Julien Fontanet
fa183fc97e fix(CHANGELOG): add missing Kubernetes changes
Introduced by a1d63118c
2022-12-19 10:42:53 +01:00
Gabriel Gunullu
a1d63118c0 feat(xo-web/recipes/kubernetes): CIDR is no longer necessary (#6583)
Related to 6227349725
2022-12-19 09:42:56 +01:00
Julien Fontanet
f95a20173c fix(backups/{Delta,Full}BackupWriter}): fix this._vmBackupDir access
May fix #6584

Introduced by 45dcb914b.
2022-12-17 10:57:49 +01:00
Mathieu
b82d0fadc3 feat: technical release (#6585) 2022-12-16 16:13:07 +01:00
Julien Fontanet
0635b3316e feat(xo-server/backups): remove merge limitations
Since 30fe9764a, merging range of VHDs is supported via synthetic VHD which limits the perf impact.

It's no longer necessary to limit the number of VHDs per run to merge.
2022-12-16 14:42:05 +01:00
Thierry Goettelmann
113235aec3 feat(lite): new useArrayRemovedItemsHistory composable (#6546) 2022-12-16 11:43:50 +01:00
Mathieu
3921401e96 fix(lite): fix 'not connected to xapi' (#6522)
Introduced by 1c3cad9235
2022-12-16 09:54:43 +01:00
Julien Fontanet
2e514478a4 fix(xo-server/remotes): always remove handler from cache when forgetting 2022-12-15 17:58:14 +01:00
Julien Fontanet
b3d53b230e fix(fs/abstract): use standard naming for logger 2022-12-15 17:58:14 +01:00
Julien Fontanet
45dcb914ba chore(backups/{Mixin,Delta,Full}BackupWriter}): mutualize VM backup dir computation 2022-12-15 17:58:14 +01:00
Mathieu
711087b686 feat(lite): feedback on login page (#6464) 2022-12-15 15:00:46 +01:00
Julien Fontanet
b100a59d1d feat(xapi/VM_snapshot): use ignore_vdis param 2022-12-14 23:36:03 +01:00
Mathieu
109b2b0055 feat(lite): not found views (page/object) (#6410) 2022-12-14 16:47:40 +01:00
Julien Fontanet
9dda99eb20 fix(xo-server/_handleBackupLog): fix sendPassiveCheck condition
Introduced by ba782d269

Fixes https://xcp-ng.org/forum/post/56175
2022-12-14 16:26:43 +01:00
Thierry Goettelmann
fa0f75b474 feat(lite): New UiCardTitle component (#6558) 2022-12-12 15:19:43 +01:00
Julien Fontanet
2d93e0d4be feat(xapi/waitObjectState): better timeout error stacktrace
Create the error synchronously for better stacktrace and debuggability.
2022-12-12 15:11:10 +01:00
Julien Fontanet
fe6406336d feat: release 5.77.2 2022-12-12 11:49:55 +01:00
Julien Fontanet
1037d44089 feat(xo-server): 5.107.3 2022-12-12 11:27:18 +01:00
Julien Fontanet
a8c3669f43 feat(@xen-orchestra/proxy): 0.26.7 2022-12-12 11:26:55 +01:00
Julien Fontanet
d91753aa82 feat(@xen-orchestra/backups): 0.29.3 2022-12-12 11:26:26 +01:00
Julien Fontanet
b548514d44 fix(backups): wait for cache to be updated before running cleanVm (#6580)
Introduced by 191c12413
2022-12-12 09:30:08 +01:00
Julien Fontanet
ba782d2698 fix(xo-server/_handleBackupLog): bail instead of failing if Nagios plugin is not loaded
Introduced by ed34d9cbc
2022-12-08 17:17:31 +01:00
Julien Fontanet
0552dc23a5 chore(CHANGELOG.unreleased): clarify format description 2022-12-08 17:17:31 +01:00
Cécile Morange
574bbbf5ff docs(manage infrastructure): add how to remove a host from pool (#6574)
Co-authored-by: Jon Sands <fohdeesha@gmail.com>
2022-12-08 15:38:02 +01:00
Julien Fontanet
df11a92cdb feat(scripts/gen-deps-list.js): add debug logs 2022-12-07 14:35:49 +01:00
Julien Fontanet
33ae59adf7 feat: release 5.77.1 2022-12-07 13:41:17 +01:00
Julien Fontanet
e0a115b41d feat(xo-server): 5.107.2 2022-12-07 13:19:15 +01:00
Julien Fontanet
f838d6c179 feat(@xen-orchestra/proxy): 0.26.6 2022-12-07 13:16:51 +01:00
Julien Fontanet
6c3229f517 feat(@xen-orchestra/backups): 0.29.2 2022-12-07 13:16:50 +01:00
Julien Fontanet
6973928b1a feat(backups/cleanVm): detect and fix cache inconsistencies (#6575) 2022-12-07 13:06:03 +01:00
Julien Fontanet
a5daba2a4d fix: work-around VuePress issues #2 2022-12-06 14:43:15 +01:00
Julien Fontanet
40ef83416e fix: work-around VuePress issues 2022-12-06 14:35:00 +01:00
Julien Fontanet
8518146455 fix: force classic Yarn 2022-12-06 10:53:35 +01:00
Florent BEAUCHAMP
d58f563de5 fix(xo-server/vm.warmMigration): fix start/delete params handling (#6570) 2022-12-06 10:42:51 +01:00
Thierry Goettelmann
ad2454adab feat(lite): replace ProgressBar with UiProgressBar & update UsageBar (#6544)
`ProgressBar` component handled too much logic (a progress bar + a circle icon + a label + a badge)

Since at various places we need a simple progress bar, all the additional logic should be handled by `UsageBar`.

- Move usage-specific logic from `ProgressBar` to `UsageBar`
- Removed `ProgressBar` component
- Created `ui/UiProgressBar` component containing only the bar itself
- Updated `UsageBar` to use `UiProgressBar` then adapting its style
2022-12-06 09:50:50 +01:00
Julien Fontanet
1f32557743 fix(scripts/gen-deps-list): fix packages order (#6564)
The release order computation is now uncoupled of the packages to release computation, and is now done for all packages so that transitive dependencies are still correctly ordered.
2022-11-30 14:52:46 +01:00
Julien Fontanet
e95aae2129 feat: release 5.77.0 2022-11-30 14:05:38 +01:00
Pierre Donias
9176171f20 feat: technical release (#6566) 2022-11-30 11:18:33 +01:00
Florent BEAUCHAMP
d4f2249a4d fix(xo-server/vm.warmMigration): use same job id in subsequent run (#6565)
Introduced by 72c69d7
2022-11-30 11:00:42 +01:00
Julien Fontanet
e0b4069c17 fix(scripts/bump-pkg): don't call git add --patch twice 2022-11-29 18:56:03 +01:00
Julien Fontanet
6b25a21151 feat(scripts/bump-pkg): ignore yarn.lock changes 2022-11-29 18:56:03 +01:00
Julien Fontanet
716dc45d85 chore(CHANGELOG): integrate released changes 2022-11-29 18:56:03 +01:00
Julien Fontanet
57850230c8 feat(xo-web): 5.108.0 2022-11-29 18:47:33 +01:00
Julien Fontanet
362d597031 feat(xo-server-web-hooks): 0.3.2 2022-11-29 18:47:14 +01:00
Julien Fontanet
e89b84b37b feat(xo-server-usage-report): 0.10.2 2022-11-29 18:46:54 +01:00
Julien Fontanet
ae6f6bf536 feat(xo-server-transport-nagios): 1.0.0 2022-11-29 18:46:27 +01:00
Julien Fontanet
6f765bdd6f feat(xo-server-sdn-controller): 1.0.7 2022-11-29 18:45:50 +01:00
Julien Fontanet
1982c6e6e6 feat(xo-server-netbox): 0.3.5 2022-11-29 18:45:30 +01:00
Julien Fontanet
527dceb43f feat(xo-server-load-balancer): 0.7.2 2022-11-29 18:44:12 +01:00
Julien Fontanet
f5a3d68d07 feat(xo-server-backup-reports): 0.17.2 2022-11-29 18:43:50 +01:00
Julien Fontanet
6c904fbc96 feat(xo-server-auth-ldap): 0.10.6 2022-11-29 18:43:22 +01:00
Julien Fontanet
295036a1e3 feat(xo-server-audit): 0.10.2 2022-11-29 18:42:30 +01:00
Julien Fontanet
5601d61b49 feat(xo-server): 5.107.0 2022-11-29 18:32:04 +01:00
Julien Fontanet
1c35c1a61a feat(xo-cli): 0.14.2 2022-11-29 18:31:24 +01:00
Julien Fontanet
4143014466 feat(xo-vmdk-to-vhd): 2.5.0 2022-11-29 18:29:33 +01:00
Julien Fontanet
90fea69b7e feat(@xen-orchestra/proxy): 0.26.5 2022-11-29 18:21:01 +01:00
Julien Fontanet
625663d619 feat(@xen-orchestra/xapi): 1.5.3 2022-11-29 18:18:09 +01:00
Julien Fontanet
403afc7aaf feat(@xen-orchestra/mixins): 0.8.2 2022-11-29 17:50:43 +01:00
Julien Fontanet
d295524c3c feat(@xen-orchestra/backups-cli): 1.0.0 2022-11-29 17:48:21 +01:00
Julien Fontanet
5eb4294e70 feat(@xen-orchestra/backups): 0.29.1 2022-11-29 17:48:21 +01:00
Julien Fontanet
90598522a6 feat(@xen-orchestra/audit-core): 0.2.2 2022-11-29 17:48:21 +01:00
Julien Fontanet
519fa1bcf8 feat(vhd-lib): 4.2.0 2022-11-29 17:48:21 +01:00
Julien Fontanet
7b0e5afe37 feat(@xen-orchestra/fs): 3.3.0 2022-11-29 17:48:21 +01:00
Julien Fontanet
0b6b3a47a2 feat(@vates/disposable): 0.1.3 2022-11-29 17:48:21 +01:00
Julien Fontanet
75db810508 feat(@xen-orchestra/log): 0.5.0 2022-11-29 17:48:21 +01:00
Julien Fontanet
2f52c564f5 chore(backups-cli): format package.json 2022-11-29 17:48:21 +01:00
Florent Beauchamp
011d582b80 fix(vhd-lib/merge): delete old data AFTER the alias has been overwritten 2022-11-29 16:42:57 +01:00
Julien Fontanet
32d21b2308 chore: use caret range for @vates/async-each
Introduced by 08298d328
2022-11-29 16:31:41 +01:00
Pierre Donias
45971ca622 fix(xo-web): remove duplicated imports (#6562) 2022-11-29 16:17:40 +01:00
Mathieu
f3a09f2dad feat(xo-web/VM/advanced): add button for warm migration (#6533)
See #6549
2022-11-29 15:14:41 +01:00
Mathieu
552a9c7b9f feat(xo-web/proxy): register an existing proxy (#6556) 2022-11-29 14:44:51 +01:00
Gabriel Gunullu
ed34d9cbc0 feat(xo-server-transport-nagios): make host and service configurable (#6560) 2022-11-29 14:34:41 +01:00
Julien Fontanet
187ee99931 fix(xo-server/plugin.configure): don't save injected defaults
Default values injected by Ajv from the configuration schema should not be saved.
2022-11-29 12:43:17 +01:00
Cécile Morange
ff78dd8f7c feat(xo-web/i18n): "XenServer" → "XCP-ng" (#6462)
See #6439
2022-11-29 11:47:16 +01:00
Julien Fontanet
b0eadb8ea4 fix: remove concurrency limit for dev script
Introduced by 9d5bc8af6

Limited concurrency (which is the default) is not compatible with never-ending commands.
2022-11-29 11:35:01 +01:00
Julien Fontanet
a95754715a fix: use --verbose for dev script
Introduced by 9d5bc8af6

Silent mode is not compatible (i.e. does not show a meaningful output) with never-ending commands.
2022-11-29 11:14:44 +01:00
Julien Fontanet
18ece4b90c fix(xo-server/MigrateVm): fix uuid import
Introduced by 72c69d791

Fixes #6561
2022-11-29 10:30:09 +01:00
Florent Beauchamp
3862fb2664 fix(fs/rename): throw ENOENT when source file is missing 2022-11-28 17:33:57 +01:00
Florent BEAUCHAMP
72c69d791a feat(xo-server): implement warm migration backend (#6549) 2022-11-28 17:28:19 +01:00
Julien Fontanet
d6192a4a7a chore: remove unused travis-tests.js 2022-11-28 15:51:47 +01:00
Julien Fontanet
0f824ffa70 lint(vhd-lib): remove unused var and fix formatting
Introduced by f6c227e7f
2022-11-26 10:10:08 +01:00
Florent BEAUCHAMP
f6c227e7f5 feat(vhd-lib): merge resume can resume when rename fails (#6530) 2022-11-25 20:51:33 +01:00
Julien Fontanet
9d5bc8af6e feat: run-script.js now only shows output on error by default 2022-11-25 15:45:52 +01:00
Julien Fontanet
9480079770 feat: script test-unit now bails on first error 2022-11-25 15:45:08 +01:00
Julien Fontanet
54fe9147ac chore: only enable Babel debug on prod builds
The output was making test results hard to see.
2022-11-25 14:43:36 +01:00
Gabriel Gunullu
b6a0477232 feat(xo-server-transport-nagios): report backed up VM individually (#6534) 2022-11-25 14:36:41 +01:00
Julien Fontanet
c60644c578 chore(lite): merge lint with the root config 2022-11-25 11:23:04 +01:00
Thierry Goettelmann
abdce94c5f feat(lite): type check on test (#6547) 2022-11-25 11:19:58 +01:00
Mathieu
d7dee04013 feat(xo-web/settings/users): remove OTP of users in admin panel (#6541)
See https://xcp-ng.org/forum/topic/6521
2022-11-25 11:15:07 +01:00
Julien Fontanet
dfc62132b7 fix(xo-web/remote): prevent browser from autocompleting encryption key 2022-11-24 18:48:45 +01:00
Julien Fontanet
36f7f193aa feat: run linter in CI 2022-11-24 17:00:59 +01:00
Julien Fontanet
ca4a82ec38 fix: make test-lint script ignore xo-web
Too many errors in this legacy package.
2022-11-24 16:26:40 +01:00
Julien Fontanet
37aea1888d chore: fix lint issues 2022-11-24 16:26:40 +01:00
Julien Fontanet
92f3b4ddd7 chore(backups/RemoteAdapter): remove unused invalidateVmBackupListCache 2022-11-24 16:26:40 +01:00
Mathieu
647995428c feat(lite/pool/dashboard): top 5 RAM usage (#6419) 2022-11-24 15:57:11 +01:00
Mathieu
407e9c25f3 feat(xo-web/licenses): text to explicit where to bind xcp-ng licenses (#6551)
See zammad#11037
2022-11-24 15:42:16 +01:00
Julien Fontanet
1612ab7335 fix(backups-cli/clean-vms): remove incorrect console.log
Introduced by 94c755b10
2022-11-23 23:03:46 +01:00
Julien Fontanet
b952c36210 fix(vhd-lib/merge): VhdAbstract.rename → handler.rename
Missing changed from c5b3acfce
2022-11-23 15:02:56 +01:00
Florent BEAUCHAMP
96b5cb2c61 feat(xo-vmdk-to-vhd): overprovision vmdk size to generate ova in one pass (#6487) 2022-11-23 14:48:18 +01:00
Florent Beauchamp
c5b3acfce2 fix(vhd-lib): remove unsafe VhdAbstract.rename implementation
actual implementation was deleting the target vhd even if the source did not exist, leading to ptential data loss
2022-11-23 14:31:37 +01:00
Julien Fontanet
20a01bf266 feat(lint-staged): format all files with Prettier 2022-11-22 18:20:01 +01:00
Julien Fontanet
a33b88cf1c chore: format with Prettier 2022-11-22 17:30:14 +01:00
Julien Fontanet
09a2f45ada feat: run test script for all pkgs with changed files 2022-11-22 17:30:14 +01:00
Julien Fontanet
83a7dd7ea1 chore: remove custom scripts/lint-staged 2022-11-22 17:30:14 +01:00
Julien Fontanet
afc1b6a5c0 Revert "feat: run pre-commit script for all packages"
This reverts commit f5b91cd45d.
2022-11-22 17:30:14 +01:00
Thierry Goettelmann
7f4f860735 feat(lite/color mode): "auto" mode + "D" shortcut to toggle (#6536)
The shortcut is only enabled in dev environment
2022-11-22 15:35:31 +01:00
Julien Fontanet
d789e3aa0d chore: update to husky@8 2022-11-22 15:33:43 +01:00
Julien Fontanet
f5b91cd45d feat: run pre-commit script for all packages 2022-11-22 11:37:40 +01:00
Julien Fontanet
92ab4b3309 chore(lite): format with Prettier (#6545) 2022-11-22 11:33:03 +01:00
Florent Beauchamp
2c456e4c89 fix(vhd-lib): create directory for merged blocks 2022-11-22 11:05:51 +01:00
Florent Beauchamp
1460e63449 fix(vhd-lib): write state at the begining 2022-11-22 11:05:51 +01:00
283 changed files with 8670 additions and 3628 deletions

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ yarn-error.log.*
# code coverage
.nyc_output/
coverage/
.turbo/

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

View File

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

View File

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

View File

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

54
@vates/task/.USAGE.md Normal file
View File

@@ -0,0 +1,54 @@
```js
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// if defined, a new detached task is created
//
// if not defined and created inside an existing task, the new task is considered a subtask
onProgress(event) {
// this function is called each time this task or one of it's subtasks change state
const { id, timestamp, type } = event
if (type === 'start') {
const { name, parentId } = event
} else if (type === 'end') {
const { result, status } = event
} else if (type === 'info' || type === 'warning') {
const { data, message } = event
} else if (type === 'property') {
const { name, value } = event
}
},
})
// this field is settable once before being observed
task.id
task.status
await task.abort()
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
// sends an info on the current task if any, otherwise does nothing
Task.info(message, data)
// sends an info on the current task if any, otherwise does nothing
Task.warning(message, data)
// attaches a property to the current task if any, otherwise does nothing
//
// the latest value takes precedence
//
// examples:
// - progress
Task.set(property, value)
```

1
@vates/task/.npmignore Symbolic link
View File

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

85
@vates/task/README.md Normal file
View File

@@ -0,0 +1,85 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/task
[![Package Version](https://badgen.net/npm/v/@vates/task)](https://npmjs.org/package/@vates/task) ![License](https://badgen.net/npm/license/@vates/task) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/task)](https://bundlephobia.com/result?p=@vates/task) [![Node compatibility](https://badgen.net/npm/node/@vates/task)](https://npmjs.org/package/@vates/task)
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/task):
```
> npm install --save @vates/task
```
## Usage
```js
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// if defined, a new detached task is created
//
// if not defined and created inside an existing task, the new task is considered a subtask
onProgress(event) {
// this function is called each time this task or one of it's subtasks change state
const { id, timestamp, type } = event
if (type === 'start') {
const { name, parentId } = event
} else if (type === 'end') {
const { result, status } = event
} else if (type === 'info' || type === 'warning') {
const { data, message } = event
} else if (type === 'property') {
const { name, value } = event
}
},
})
// this field is settable once before being observed
task.id
task.status
await task.abort()
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
// if fn rejects, the task will be marked as failed
// if fn resolves, the task will be marked as succeeded
const result = await task.run(fn)
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
// sends an info on the current task if any, otherwise does nothing
Task.info(message, data)
// sends an info on the current task if any, otherwise does nothing
Task.warning(message, data)
// attaches a property to the current task if any, otherwise does nothing
//
// the latest value takes precedence
//
// examples:
// - progress
Task.set(property, value)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

184
@vates/task/index.js Normal file
View File

@@ -0,0 +1,184 @@
'use strict'
const assert = require('node:assert').strict
const { AsyncLocalStorage } = require('node:async_hooks')
// define a read-only, non-enumerable, non-configurable property
function define(object, property, value) {
Object.defineProperty(object, property, { value })
}
const noop = Function.prototype
const ABORTED = 'aborted'
const ABORTING = 'aborting'
const FAILURE = 'failure'
const PENDING = 'pending'
const SUCCESS = 'success'
exports.STATUS = { ABORTED, ABORTING, FAILURE, PENDING, SUCCESS }
const asyncStorage = new AsyncLocalStorage()
const getTask = () => asyncStorage.getStore()
exports.Task = class Task {
static get abortSignal() {
const task = getTask()
if (task !== undefined) {
return task.#abortController.signal
}
}
static info(message, data) {
const task = getTask()
if (task !== undefined) {
task.#emit('info', { data, message })
}
}
static run(opts, fn) {
return new this(opts).run(fn)
}
static set(name, value) {
const task = getTask()
if (task !== undefined) {
task.#emit('property', { name, value })
}
}
static warning(message, data) {
const task = getTask()
if (task !== undefined) {
task.#emit('warning', { data, message })
}
}
static wrap(opts, fn) {
// compatibility with @decorateWith
if (typeof fn !== 'function') {
;[fn, opts] = [opts, fn]
}
return function taskRun() {
return Task.run(typeof opts === 'function' ? opts.apply(this, arguments) : opts, () => fn.apply(this, arguments))
}
}
#abortController = new AbortController()
#onProgress
#parent
get id() {
return (this.id = Math.random().toString(36).slice(2))
}
set id(value) {
define(this, 'id', value)
}
#startData
#status = PENDING
get status() {
return this.#status
}
constructor({ name, onProgress }) {
this.#startData = { name }
if (onProgress !== undefined) {
this.#onProgress = onProgress
} else {
const parent = getTask()
if (parent !== undefined) {
this.#parent = parent
const { signal } = parent.#abortController
signal.addEventListener('abort', () => {
this.#abortController.abort(signal.reason)
})
this.#onProgress = parent.#onProgress
this.#startData.parentId = parent.id
} else {
this.#onProgress = noop
}
}
const { signal } = this.#abortController
signal.addEventListener('abort', () => {
if (this.status === PENDING) {
this.#status = this.#running ? ABORTING : ABORTED
}
})
}
abort(reason) {
this.#abortController.abort(reason)
}
#emit(type, data) {
data.id = this.id
data.timestamp = Date.now()
data.type = type
this.#onProgress(data)
}
#handleMaybeAbortion(result) {
if (this.status === ABORTING) {
this.#status = ABORTED
this.#emit('end', { status: ABORTED, result })
return true
}
return false;
}
async run(fn) {
const result = await this.runInside(fn)
if (this.status === PENDING) {
this.#status = SUCCESS
this.#emit('end', { status: SUCCESS, result })
}
return result
}
#running = false
async runInside(fn) {
assert.equal(this.status, PENDING)
assert.equal(this.#running, false)
this.#running = true
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
}
try {
const result = await asyncStorage.run(this, fn)
this.#handleMaybeAbortion(result)
this.#running = false
return result
} catch (result) {
if (!this.#handleMaybeAbortion(result)) {
this.#status = FAILURE
this.#emit('end', { status: FAILURE, result })
}
throw result
}
}
wrap(fn) {
const task = this
return function taskRun() {
return task.run(() => fn.apply(this, arguments))
}
}
wrapInside(fn) {
const task = this
return function taskRunInside() {
return task.runInside(() => fn.apply(this, arguments))
}
}
}

23
@vates/task/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"private": false,
"name": "@vates/task",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/task",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/task",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.0.1",
"engines": {
"node": ">=14"
},
"scripts": {
"postversion": "npm publish --access public"
}
}

View File

@@ -30,6 +30,7 @@ if (args.length === 0) {
${name} v${version}
`)
// eslint-disable-next-line n/no-process-exit
process.exit()
}

View File

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

View File

@@ -7,7 +7,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.2.1",
"version": "0.2.3",
"engines": {
"node": ">=14"
},
@@ -17,7 +17,7 @@
},
"dependencies": {
"@vates/decorate-with": "^2.0.0",
"@xen-orchestra/log": "^0.4.0",
"@xen-orchestra/log": "^0.6.0",
"golike-defer": "^0.5.1",
"object-hash": "^2.0.1"
},

View File

@@ -5,7 +5,6 @@ const PRESETS_RE = /^@babel\/preset-.+$/
const NODE_ENV = process.env.NODE_ENV || 'development'
const __PROD__ = NODE_ENV === 'production'
const __TEST__ = NODE_ENV === 'test'
const configs = {
'@babel/plugin-proposal-decorators': {
@@ -15,7 +14,7 @@ const configs = {
proposal: 'minimal',
},
'@babel/preset-env': {
debug: !__TEST__,
debug: __PROD__,
// disabled until https://github.com/babel/babel/issues/8323 is resolved
// loose: true,

View File

@@ -22,7 +22,6 @@ export default async function cleanVms(args) {
await asyncMap(_, vmDir =>
Disposable.use(getSyncedHandler({ url: pathToFileURL(dirname(vmDir)).href }), async handler => {
console.log(handler, basename(vmDir))
try {
await new RemoteAdapter(handler).cleanVm(basename(vmDir), {
fixMetadata: fix,

View File

@@ -7,12 +7,12 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.29.0",
"@xen-orchestra/fs": "^3.2.0",
"@xen-orchestra/backups": "^0.29.5",
"@xen-orchestra/fs": "^3.3.1",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
"promise-toolbox":"^0.21.0"
"promise-toolbox": "^0.21.0"
},
"engines": {
"node": ">=14"
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "0.7.8",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -38,7 +38,7 @@ const DEFAULT_VM_SETTINGS = {
fullInterval: 0,
healthCheckSr: undefined,
healthCheckVmsWithTags: [],
maxMergedDeltasPerRun: 2,
maxMergedDeltasPerRun: Infinity,
offlineBackup: false,
offlineSnapshot: false,
snapshotRetention: 0,

View File

@@ -28,6 +28,7 @@ const { isMetadataFile } = require('./_backupType.js')
const { isValidXva } = require('./_isValidXva.js')
const { listPartitions, LVM_PARTITION_TYPE } = require('./_listPartitions.js')
const { lvs, pvs } = require('./_lvm.js')
const { watchStreamSize } = require('./_watchStreamSize')
// @todo : this import is marked extraneous , sould be fixed when lib is published
const { mount } = require('@vates/fuse-vhd')
const { asyncEach } = require('@vates/async-each')
@@ -232,21 +233,23 @@ class RemoteAdapter {
return promise
}
#removeVmBackupsFromCache(backups) {
for (const [dir, filenames] of Object.entries(
groupBy(
backups.map(_ => _._filename),
dirname
)
)) {
// detached async action, will not reject
this._updateCache(dir + '/cache.json.gz', backups => {
for (const filename of filenames) {
debug('removing cache entry', { entry: filename })
delete backups[filename]
}
})
}
async #removeVmBackupsFromCache(backups) {
await asyncEach(
Object.entries(
groupBy(
backups.map(_ => _._filename),
dirname
)
),
([dir, filenames]) =>
// will not reject
this._updateCache(dir + '/cache.json.gz', backups => {
for (const filename of filenames) {
debug('removing cache entry', { entry: filename })
delete backups[filename]
}
})
)
}
async deleteDeltaVmBackups(backups) {
@@ -255,7 +258,7 @@ class RemoteAdapter {
// this will delete the json, unused VHDs will be detected by `cleanVm`
await asyncMapSettled(backups, ({ _filename }) => handler.unlink(_filename))
this.#removeVmBackupsFromCache(backups)
await this.#removeVmBackupsFromCache(backups)
}
async deleteMetadataBackup(backupId) {
@@ -284,7 +287,7 @@ class RemoteAdapter {
Promise.all([handler.unlink(_filename), handler.unlink(resolveRelativeFromFile(_filename, xva))])
)
this.#removeVmBackupsFromCache(backups)
await this.#removeVmBackupsFromCache(backups)
}
deleteVmBackup(file) {
@@ -508,7 +511,7 @@ class RemoteAdapter {
return `${BACKUP_DIR}/${vmUuid}/cache.json.gz`
}
async #readCache(path) {
async _readCache(path) {
try {
return JSON.parse(await fromCallback(zlib.gunzip, await this.handler.readFile(path)))
} catch (error) {
@@ -521,15 +524,15 @@ class RemoteAdapter {
_updateCache = synchronized.withKey()(this._updateCache)
// eslint-disable-next-line no-dupe-class-members
async _updateCache(path, fn) {
const cache = await this.#readCache(path)
const cache = await this._readCache(path)
if (cache !== undefined) {
fn(cache)
await this.#writeCache(path, cache)
await this._writeCache(path, cache)
}
}
async #writeCache(path, data) {
async _writeCache(path, data) {
try {
await this.handler.writeFile(path, await fromCallback(zlib.gzip, JSON.stringify(data)), { flags: 'w' })
} catch (error) {
@@ -537,10 +540,6 @@ class RemoteAdapter {
}
}
async invalidateVmBackupListCache(vmUuid) {
await this.handler.unlink(this.#getVmBackupsCache(vmUuid))
}
async #getCachabledDataListVmBackups(dir) {
debug('generating cache', { path: dir })
@@ -581,7 +580,7 @@ class RemoteAdapter {
async _readCacheListVmBackups(vmUuid) {
const path = this.#getVmBackupsCache(vmUuid)
const cache = await this.#readCache(path)
const cache = await this._readCache(path)
if (cache !== undefined) {
debug('found VM backups cache, using it', { path })
return cache
@@ -594,7 +593,7 @@ class RemoteAdapter {
}
// detached async action, will not reject
this.#writeCache(path, backups)
this._writeCache(path, backups)
return backups
}
@@ -645,7 +644,7 @@ class RemoteAdapter {
})
// will not throw
this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
await this._updateCache(this.#getVmBackupsCache(vmUuid), backups => {
debug('adding cache entry', { entry: path })
backups[path] = {
...metadata,
@@ -663,7 +662,7 @@ class RemoteAdapter {
const handler = this._handler
if (this.#useVhdDirectory()) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
await createVhdDirectoryFromStream(handler, dataPath, input, {
const size = await createVhdDirectoryFromStream(handler, dataPath, input, {
concurrency: writeBlockConcurrency,
compression: this.#getCompressionType(),
async validator() {
@@ -673,12 +672,14 @@ class RemoteAdapter {
nbdClient,
})
await VhdAbstract.createAlias(handler, path, dataPath)
return size
} else {
await this.outputStream(path, input, { checksum, validator })
return this.outputStream(path, input, { checksum, validator })
}
}
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
@@ -687,6 +688,7 @@ class RemoteAdapter {
return validator.apply(this, arguments)
},
})
return container.size
}
// open the hierarchy of ancestors until we find a full one

View File

@@ -100,7 +100,7 @@ class Task {
* In case of error, the task will be failed.
*
* @typedef Result
* @param {() => Result)} fn
* @param {() => Result} fn
* @param {boolean} last - Whether the task should succeed if there is no error
* @returns Result
*/

View File

@@ -1,6 +1,6 @@
'use strict'
require('@xen-orchestra/log/configure.js').catchGlobalErrors(
require('@xen-orchestra/log/configure').catchGlobalErrors(
require('@xen-orchestra/log').createLogger('xo:backups:worker')
)

View File

@@ -311,7 +311,6 @@ exports.cleanVm = async function cleanVm(
}
const jsons = new Set()
let mustInvalidateCache = false
const xvas = new Set()
const xvaSums = []
const entries = await handler.list(vmDir, {
@@ -327,6 +326,20 @@ exports.cleanVm = async function cleanVm(
}
})
const cachePath = vmDir + '/cache.json.gz'
let mustRegenerateCache
{
const cache = await this._readCache(cachePath)
const actual = cache === undefined ? 0 : Object.keys(cache).length
const expected = jsons.size
mustRegenerateCache = actual !== expected
if (mustRegenerateCache) {
logWarn('unexpected number of entries in backup cache', { path: cachePath, actual, expected })
}
}
await asyncMap(xvas, async path => {
// check is not good enough to delete the file, the best we can do is report
// it
@@ -338,6 +351,8 @@ exports.cleanVm = async function cleanVm(
const unusedVhds = new Set(vhds)
const unusedXvas = new Set(xvas)
const backups = new Map()
// compile the list of unused XVAs and VHDs, and remove backup metadata which
// reference a missing XVA/VHD
await asyncMap(jsons, async json => {
@@ -350,19 +365,16 @@ exports.cleanVm = async function cleanVm(
return
}
let isBackupComplete
const { mode } = metadata
if (mode === 'full') {
const linkedXva = resolve('/', vmDir, metadata.xva)
if (xvas.has(linkedXva)) {
isBackupComplete = xvas.has(linkedXva)
if (isBackupComplete) {
unusedXvas.delete(linkedXva)
} else {
logWarn('the XVA linked to the backup is missing', { backup: json, xva: linkedXva })
if (remove) {
logInfo('deleting incomplete backup', { path: json })
jsons.delete(json)
mustInvalidateCache = true
await handler.unlink(json)
}
}
} else if (mode === 'delta') {
const linkedVhds = (() => {
@@ -371,22 +383,28 @@ exports.cleanVm = async function cleanVm(
})()
const missingVhds = linkedVhds.filter(_ => !vhds.has(_))
isBackupComplete = missingVhds.length === 0
// FIXME: find better approach by keeping as much of the backup as
// possible (existing disks) even if one disk is missing
if (missingVhds.length === 0) {
if (isBackupComplete) {
linkedVhds.forEach(_ => unusedVhds.delete(_))
linkedVhds.forEach(path => {
vhdsToJSons[path] = json
})
} else {
logWarn('some VHDs linked to the backup are missing', { backup: json, missingVhds })
if (remove) {
logInfo('deleting incomplete backup', { path: json })
mustInvalidateCache = true
jsons.delete(json)
await handler.unlink(json)
}
}
}
if (isBackupComplete) {
backups.set(json, metadata)
} else {
jsons.delete(json)
if (remove) {
logInfo('deleting incomplete backup', { backup: json })
mustRegenerateCache = true
await handler.unlink(json)
}
}
})
@@ -496,7 +514,7 @@ exports.cleanVm = async function cleanVm(
// check for the other that the size is the same as the real file size
await asyncMap(jsons, async metadataPath => {
const metadata = JSON.parse(await handler.readFile(metadataPath))
const metadata = backups.get(metadataPath)
let fileSystemSize
const merged = metadataWithMergedVhd[metadataPath] !== undefined
@@ -538,6 +556,7 @@ exports.cleanVm = async function cleanVm(
// systematically update size after a merge
if ((merged || fixMetadata) && size !== fileSystemSize) {
metadata.size = fileSystemSize
mustRegenerateCache = true
try {
await handler.writeFile(metadataPath, JSON.stringify(metadata), { flags: 'w' })
} catch (error) {
@@ -546,9 +565,16 @@ exports.cleanVm = async function cleanVm(
}
})
// purge cache if a metadata file has been deleted
if (mustInvalidateCache) {
await handler.unlink(vmDir + '/cache.json.gz')
if (mustRegenerateCache) {
const cache = {}
for (const [path, content] of backups.entries()) {
cache[path] = {
_filename: path,
id: path,
...content,
}
}
await this._writeCache(cachePath, cache)
}
return {

View File

@@ -31,7 +31,7 @@ beforeEach(async () => {
})
afterEach(async () => {
await pFromCallback(cb => rimraf(tempDir, cb))
await rimraf(tempDir)
await handler.forget()
})
@@ -221,7 +221,7 @@ test('it merges delta of non destroyed chain', async () => {
loggued.push(message)
}
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
assert.equal(loggued[0], `incorrect backup size in metadata`)
assert.equal(loggued[0], `unexpected number of entries in backup cache`)
loggued = []
await adapter.cleanVm(rootPath, { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
@@ -378,7 +378,19 @@ describe('tests multiple combination ', () => {
],
})
)
if (!useAlias && vhdMode === 'directory') {
try {
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
} catch (err) {
assert.strictEqual(
err.code,
'NOT_SUPPORTED',
'Merging directory without alias should raise a not supported error'
)
return
}
assert.strictEqual(true, false, 'Merging directory without alias should raise an error')
}
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))

View File

@@ -4,7 +4,7 @@
'use strict'
const { catchGlobalErrors } = require('@xen-orchestra/log/configure.js')
const { catchGlobalErrors } = require('@xen-orchestra/log/configure')
const { createLogger } = require('@xen-orchestra/log')
const { getSyncedHandler } = require('@xen-orchestra/fs')
const { join } = require('path')

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.29.0",
"version": "0.29.5",
"engines": {
"node": ">=14.6"
},
@@ -21,38 +21,38 @@
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.2",
"@vates/disposable": "^0.1.4",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "*",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.2.0",
"@xen-orchestra/log": "^0.4.0",
"@xen-orchestra/fs": "^3.3.1",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1",
"d3-time-format": "^3.0.0",
"decorator-synchronized": "^0.6.0",
"end-of-stream": "^1.4.4",
"fs-extra": "^10.0.0",
"fs-extra": "^11.1.0",
"golike-defer": "^0.5.1",
"limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.20",
"node-zone": "^0.4.0",
"parse-pairs": "^1.1.0",
"parse-pairs": "^2.0.0",
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"uuid": "^9.0.0",
"vhd-lib": "^4.1.1",
"vhd-lib": "^4.2.1",
"yazl": "^2.5.1"
},
"devDependencies": {
"rimraf": "^3.0.2",
"sinon": "^14.0.1",
"rimraf": "^4.1.1",
"sinon": "^15.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^1.5.2"
"@xen-orchestra/xapi": "^1.6.1"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -11,7 +11,6 @@ const { dirname } = require('path')
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { Task } = require('../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
@@ -21,7 +20,7 @@ const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const NbdClient = require('@vates/nbd-client')
const { debug, warn } = createLogger('xo:backups:DeltaBackupWriter')
const { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
@@ -29,8 +28,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
const backup = this._backup
const adapter = this._adapter
const backupDir = getVmBackupDir(backup.vm.uuid)
const vdisDir = `${backupDir}/vdis/${backup.job.id}`
const vdisDir = `${this._vmBackupDir}/vdis/${backup.job.id}`
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
let found = false
@@ -135,7 +133,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
}
async _transfer({ timestamp, deltaExport, sizeContainers }) {
async _transfer({ timestamp, deltaExport }) {
const adapter = this._adapter
const backup = this._backup
@@ -143,7 +141,6 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
const jobId = job.id
const handler = adapter.handler
const backupDir = getVmBackupDir(vm.uuid)
// TODO: clean VM backup directory
@@ -175,9 +172,10 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
}
const { size } = await Task.run({ name: 'transfer' }, async () => {
let transferSize = 0
await Promise.all(
map(deltaExport.vdis, async (vdi, id) => {
const path = `${backupDir}/${vhds[id]}`
const path = `${this._vmBackupDir}/${vhds[id]}`
const isDelta = vdi.other_config['xo:base_delta'] !== undefined
let parentPath
@@ -203,21 +201,25 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
let nbdClient
if (!this._backup.config.useNbd) {
if (this._backup.config.useNbd) {
debug('useNbd is enabled', { vdi: id, path })
// get nbd if possible
try {
// this will always take the first host in the list
const [nbdInfo] = await vm.$xapi.call('VDI.get_nbd_info', vdiRef)
debug('got NBD info', { nbdInfo, vdi: id, path })
nbdClient = new NbdClient(nbdInfo)
await nbdClient.connect()
debug(`got nbd connection `, { vdi: vdi.uuid })
info('NBD client ready', { vdi: id, path })
} catch (error) {
nbdClient = undefined
debug(`can't connect to nbd server or no server available`, { error })
warn('error connecting to NBD server', { error, vdi: id, path })
}
} else {
debug('useNbd is disabled', { vdi: id, path })
}
await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
transferSize += await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
// no checksum for VHDs, because they will be invalidated by
// merges and chainings
checksum: false,
@@ -238,9 +240,7 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
})
})
)
return {
size: Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0),
}
return { size: transferSize }
})
metadataContent.size = size
this._metadataFileName = await adapter.writeVmBackupMetadata(vm.uuid, metadataContent)

View File

@@ -2,7 +2,6 @@
const { formatFilenameDate } = require('../_filenameDate.js')
const { getOldEntries } = require('../_getOldEntries.js')
const { getVmBackupDir } = require('../_getVmBackupDir.js')
const { Task } = require('../Task.js')
const { MixinBackupWriter } = require('./_MixinBackupWriter.js')
@@ -34,7 +33,6 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
const { job, scheduleId, vm } = backup
const adapter = this._adapter
const backupDir = getVmBackupDir(vm.uuid)
// TODO: clean VM backup directory
@@ -47,9 +45,8 @@ exports.FullBackupWriter = class FullBackupWriter extends MixinBackupWriter(Abst
const basename = formatFilenameDate(timestamp)
const dataBasename = basename + '.xva'
const dataFilename = backupDir + '/' + dataBasename
const dataFilename = this._vmBackupDir + '/' + dataBasename
const metadataFilename = `${backupDir}/${basename}.json`
const metadata = {
jobId: job.id,
mode: job.mode,

View File

@@ -16,7 +16,6 @@ const { info, warn } = createLogger('xo:backups:MixinBackupWriter')
exports.MixinBackupWriter = (BaseClass = Object) =>
class MixinBackupWriter extends BaseClass {
#lock
#vmBackupDir
constructor({ remoteId, ...rest }) {
super(rest)
@@ -24,13 +23,13 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
this._adapter = rest.backup.remoteAdapters[remoteId]
this._remoteId = remoteId
this.#vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
this._vmBackupDir = getVmBackupDir(this._backup.vm.uuid)
}
async _cleanVm(options) {
try {
return await Task.run({ name: 'clean-vm' }, () => {
return this._adapter.cleanVm(this.#vmBackupDir, {
return this._adapter.cleanVm(this._vmBackupDir, {
...options,
fixMetadata: true,
logInfo: info,
@@ -50,7 +49,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
async beforeBackup() {
const { handler } = this._adapter
const vmBackupDir = this.#vmBackupDir
const vmBackupDir = this._vmBackupDir
await handler.mktree(vmBackupDir)
this.#lock = await handler.lock(vmBackupDir)
}

View File

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

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "3.2.0",
"version": "3.3.1",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -30,11 +30,11 @@
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.4.0",
"@xen-orchestra/log": "^0.6.0",
"bind-property-descriptor": "^2.0.0",
"decorator-synchronized": "^0.6.0",
"execa": "^5.0.0",
"fs-extra": "^10.0.0",
"fs-extra": "^11.1.0",
"get-stream": "^6.0.0",
"limit-concurrency-decorator": "^0.5.0",
"lodash": "^4.17.4",
@@ -53,7 +53,7 @@
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^3.0.0",
"rimraf": "^4.1.1",
"tmp": "^0.2.1"
},
"scripts": {

View File

@@ -14,7 +14,7 @@ import { basename, dirname, normalize as normalizePath } from './path'
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
const { info, warn } = createLogger('@xen-orchestra:fs')
const { info, warn } = createLogger('xo:fs:abstract')
const checksumFile = file => file + '.checksum'
const computeRate = (hrtime, size) => {
@@ -91,7 +91,6 @@ export default class RemoteHandlerAbstract {
const sharedLimit = limitConcurrency(options.maxParallelOperations ?? DEFAULT_MAX_PARALLEL_OPERATIONS)
this.closeFile = sharedLimit(this.closeFile)
this.copy = sharedLimit(this.copy)
this.exists = sharedLimit(this.exists)
this.getInfo = sharedLimit(this.getInfo)
this.getSize = sharedLimit(this.getSize)
this.list = sharedLimit(this.list)
@@ -285,15 +284,25 @@ export default class RemoteHandlerAbstract {
return this._encryptor.decryptData(data)
}
async rename(oldPath, newPath, { checksum = false } = {}) {
oldPath = normalizePath(oldPath)
newPath = normalizePath(newPath)
let p = timeout.call(this._rename(oldPath, newPath), this._timeout)
if (checksum) {
p = Promise.all([p, this._rename(checksumFile(oldPath), checksumFile(newPath))])
async #rename(oldPath, newPath, { checksum }, createTree = true) {
try {
let p = timeout.call(this._rename(oldPath, newPath), this._timeout)
if (checksum) {
p = Promise.all([p, this._rename(checksumFile(oldPath), checksumFile(newPath))])
}
await p
} catch (error) {
// ENOENT can be a missing target directory OR a missing source
if (error.code === 'ENOENT' && createTree) {
await this._mktree(dirname(newPath))
return this.#rename(oldPath, newPath, { checksum }, false)
}
throw error
}
return p
}
rename(oldPath, newPath, { checksum = false } = {}) {
return this.#rename(normalizePath(oldPath), normalizePath(newPath), { checksum })
}
async copy(oldPath, newPath, { checksum = false } = {}) {
@@ -315,14 +324,6 @@ export default class RemoteHandlerAbstract {
await this._rmtree(normalizePath(dir))
}
async _exists(file){
throw new Error('not implemented')
}
async exists(file){
return this._exists(normalizePath(file))
}
// Asks the handler to sync the state of the effective remote with its'
// metadata
//

View File

@@ -116,7 +116,7 @@ describe('encryption', () => {
dir = await pFromCallback(cb => tmp.dir(cb))
})
afterAll(async () => {
await pFromCallback(cb => rimraf(dir, cb))
await rimraf(dir)
})
it('sync should NOT create metadata if missing (not encrypted)', async () => {

View File

@@ -228,6 +228,17 @@ handlers.forEach(url => {
expect(await handler.list('.')).toEqual(['file2'])
expect(await handler.readFile(`file2`)).toEqual(TEST_DATA)
})
it(`should rename the file and create dest directory`, async () => {
await handler.outputFile('file', TEST_DATA)
await handler.rename('file', `sub/file2`)
expect(await handler.list('sub')).toEqual(['file2'])
expect(await handler.readFile(`sub/file2`)).toEqual(TEST_DATA)
})
it(`should fail with enoent if source file is missing`, async () => {
const error = await rejectionOf(handler.rename('file', `sub/file2`))
expect(error.code).toBe('ENOENT')
})
})
describe('#rmdir()', () => {

View File

@@ -198,9 +198,4 @@ export default class LocalHandler extends RemoteHandlerAbstract {
_writeFile(file, data, { flags }) {
return this._addSyncStackTrace(fs.writeFile, this._getFilePath(file), data, { flag: flags })
}
async _exists(file){
const exists = await fs.pathExists(this._getFilePath(file))
return exists
}
}

View File

@@ -537,17 +537,4 @@ export default class S3Handler extends RemoteHandlerAbstract {
useVhdDirectory() {
return true
}
async _exists(file){
try{
await this._s3.send(new HeadObjectCommand(this._createParams(file)))
return true
}catch(error){
// normalize this error code
if (error.name === 'NoSuchKey') {
return false
}
throw error
}
}
}

View File

@@ -5,6 +5,12 @@
- Invalidate sessionId token after logout (PR [#6480](https://github.com/vatesfr/xen-orchestra/pull/6480))
- Settings page (PR [#6418](https://github.com/vatesfr/xen-orchestra/pull/6418))
- Uncollapse hosts in the tree by default (PR [#6428](https://github.com/vatesfr/xen-orchestra/pull/6428))
- Display RAM usage in pool dashboard (PR [#6419](https://github.com/vatesfr/xen-orchestra/pull/6419))
- Implement not found page (PR [#6410](https://github.com/vatesfr/xen-orchestra/pull/6410))
- Display CPU usage chart in pool dashboard (PR [#6577](https://github.com/vatesfr/xen-orchestra/pull/6577))
- Display network throughput chart in pool dashboard (PR [#6610](https://github.com/vatesfr/xen-orchestra/pull/6610))
- Display RAM usage chart in pool dashboard (PR [#6604](https://github.com/vatesfr/xen-orchestra/pull/6604))
- Ability to change the state of a VM (PRs [#6571](https://github.com/vatesfr/xen-orchestra/pull/6571) [#6608](https://github.com/vatesfr/xen-orchestra/pull/6608))
## **0.1.0**

View File

@@ -105,7 +105,7 @@ Use the `busy` prop to display a loader icon.
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/UiIcon.vue"
import UiIcon from "@/components/ui/UiIcon.vue";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
</script>
```

View File

@@ -7,8 +7,8 @@
"preview": "vite preview --port 4173",
"build-only": "GIT_HEAD=$(git rev-parse HEAD) vite build",
"deploy": "./scripts/deploy.sh",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
"test": "yarn run type-check",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
@@ -19,6 +19,7 @@
"@types/d3-time-format": "^4.0.0",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^9.5.0",
"@vueuse/math": "^9.5.0",
"complex-matcher": "^0.7.0",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
@@ -48,6 +49,7 @@
"eslint-plugin-vue": "^9.0.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.19",
"postcss-custom-media": "^9.0.1",
"postcss-nested": "^6.0.0",
"typescript": "^4.9.3",
"vite": "^3.2.4",

View File

@@ -1,5 +1,6 @@
module.exports = {
plugins: {
"postcss-nested": {},
"postcss-custom-media": {},
},
};

View File

@@ -13,6 +13,12 @@
<a :href="url.href" target="_blank" rel="noopener">{{ url.href }}</a>
</li>
</ul>
<template #buttons>
<UiButton color="success" @click="reload">{{
$t("unreachable-hosts-reload-page")
}}</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
</UiModal>
<div v-if="!xenApiStore.isConnected">
<AppLogin />
@@ -20,9 +26,9 @@
<div v-else>
<AppHeader />
<div style="display: flex">
<nav class="nav">
<InfraPoolList />
</nav>
<transition name="slide">
<AppNavigation />
</transition>
<main class="main">
<RouterView />
</main>
@@ -32,6 +38,10 @@
</template>
<script lang="ts" setup>
import AppNavigation from "@/components/AppNavigation.vue";
import { useUiStore } from "@/stores/ui.store";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
import { logicAnd } from "@vueuse/math";
import { difference } from "lodash";
import { computed, ref, watch, watchEffect } from "vue";
import favicon from "@/assets/favicon.svg";
@@ -39,7 +49,7 @@ import { faServer } from "@fortawesome/free-solid-svg-icons";
import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useHostStore } from "@/stores/host.store";
@@ -58,13 +68,28 @@ link.href = favicon;
document.title = "XO Lite";
if (window.localStorage?.getItem("colorMode") !== "light") {
document.documentElement.classList.add("dark");
}
const xenApiStore = useXenApiStore();
const hostStore = useHostStore();
useChartTheme();
const uiStore = useUiStore();
if (import.meta.env.DEV) {
const activeElement = useActiveElement();
const { D } = useMagicKeys();
const canToggleDarkMode = computed(() => {
if (activeElement.value == null) {
return true;
}
return !["INPUT", "TEXTAREA"].includes(activeElement.value.tagName);
});
whenever(
logicAnd(D, canToggleDarkMode),
() => (uiStore.colorMode = uiStore.colorMode === "dark" ? "light" : "dark")
);
}
watchEffect(() => {
if (xenApiStore.isConnected) {
@@ -87,19 +112,20 @@ watch(
);
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
const reload = () => window.location.reload();
</script>
<style lang="postcss">
@import "@/assets/base.css";
.nav {
overflow: auto;
width: 37rem;
max-width: 37rem;
height: calc(100vh - 9rem);
padding: 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(-37rem);
}
.main {

View File

@@ -0,0 +1,2 @@
@custom-media --mobile (max-width: 1023px);
@custom-media --desktop (min-width: 1024px);

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,5 +1,11 @@
<template>
<header class="app-header">
<UiIcon
v-if="isMobile"
ref="navigationTrigger"
:icon="faBars"
class="toggle-navigation"
/>
<RouterLink :to="{ name: 'home' }">
<img alt="XO Lite" src="../assets/logo.svg" />
</RouterLink>
@@ -11,7 +17,18 @@
</template>
<script lang="ts" setup>
import AccountButton from '@/components/AccountButton.vue'
import AccountButton from "@/components/AccountButton.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
import { faBars } from "@fortawesome/free-solid-svg-icons";
import { storeToRefs } from "pinia";
const uiStore = useUiStore();
const { isMobile } = storeToRefs(uiStore);
const navigationStore = useNavigationStore();
const { trigger: navigationTrigger } = storeToRefs(navigationStore);
</script>
<style lang="postcss" scoped>

View File

@@ -2,15 +2,24 @@
<div class="app-login form-container">
<form @submit.prevent="handleSubmit">
<img alt="XO Lite" src="../assets/logo-title.svg" />
<input v-model="login" name="login" readonly type="text" />
<input
v-model="password"
:readonly="isConnecting"
name="password"
:placeholder="$t('password')"
type="password"
/>
<UiButton :busy="isConnecting" type="submit">
<FormInputWrapper>
<FormInput v-model="login" name="login" readonly type="text" />
</FormInputWrapper>
<FormInputWrapper :error="error">
<FormInput
name="password"
ref="passwordRef"
type="password"
v-model="password"
:placeholder="$t('password')"
:readonly="isConnecting"
/>
</FormInputWrapper>
<UiButton
type="submit"
:busy="isConnecting"
:disabled="password.trim().length < 1"
>
{{ $t("login") }}
</UiButton>
</form>
@@ -19,21 +28,47 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { onMounted, ref } from "vue";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const { t } = useI18n();
const xenApiStore = useXenApiStore();
const { isConnecting } = storeToRefs(xenApiStore);
const login = ref("root");
const password = ref("");
const error = ref<string>();
const passwordRef = ref<InstanceType<typeof FormInput>>();
const isInvalidPassword = ref(false);
const focusPasswordInput = () => passwordRef.value?.focus();
onMounted(() => {
xenApiStore.reconnect();
focusPasswordInput();
});
watch(password, () => {
isInvalidPassword.value = false;
error.value = undefined;
});
async function handleSubmit() {
await xenApiStore.connect(login.value, password.value);
try {
await xenApiStore.connect(login.value, password.value);
} catch (err) {
if ((err as Error).message === "SESSION_AUTHENTICATION_FAILED") {
focusPasswordInput();
isInvalidPassword.value = true;
error.value = t("password-invalid");
} else {
error.value = t("error-occured");
console.error(err);
}
}
}
</script>
@@ -50,6 +85,7 @@ async function handleSubmit() {
form {
display: flex;
font-size: 2rem;
min-width: 30em;
max-width: 100%;
align-items: center;
@@ -72,12 +108,6 @@ img {
margin-bottom: 5rem;
}
label {
font-size: 120%;
font-weight: bold;
margin: 1.5rem 0 0.5rem 0;
}
input {
width: 45rem;
max-width: 100%;
@@ -89,6 +119,6 @@ input {
}
button {
margin-top: 3rem;
margin-top: 2rem;
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<nav
v-if="isDesktop || isOpen"
ref="navElement"
:class="{ collapsible: isMobile }"
class="app-navigation"
>
<InfraPoolList />
</nav>
</template>
<script lang="ts" setup>
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
import { onClickOutside, whenever } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { ref } from "vue";
const uiStore = useUiStore();
const { isMobile, isDesktop } = storeToRefs(uiStore);
const navigationStore = useNavigationStore();
const { isOpen, trigger } = storeToRefs(navigationStore);
const navElement = ref();
whenever(isOpen, () => {
const unregisterEvent = onClickOutside(
navElement,
() => {
isOpen.value = false;
unregisterEvent?.();
},
{
ignore: [trigger],
}
);
});
</script>
<style lang="postcss" scoped>
.app-navigation {
overflow: auto;
width: 37rem;
max-width: 37rem;
height: calc(100vh - 9rem);
padding: 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
&.collapsible {
position: fixed;
z-index: 1;
}
}
</style>

View File

@@ -43,14 +43,14 @@
<template #buttons>
<UiButton transparent @click="addNewFilter">
{{ $t("add-or") }}
</UiButton>
{{ $t("add-or") }}
</UiButton>
<UiButton :disabled="!isFilterValid" type="submit">
{{ $t(editedFilter ? "update" : "add") }}
</UiButton>
<UiButton outlined @click="handleCancel">
{{ $t("cancel") }}
</UiButton>
{{ $t("cancel") }}
</UiButton>
</template>
</UiModal>
</template>

View File

@@ -41,8 +41,8 @@
<template #buttons>
<UiButton type="submit">{{ $t("add") }}</UiButton>
<UiButton outlined @click="handleCancel">
{{ $t("cancel") }}
</UiButton>
{{ $t("cancel") }}
</UiButton>
</template>
</UiModal>
</template>
@@ -66,7 +66,7 @@ import useModal from "@/composables/modal.composable";
defineProps<{
availableSorts: Sorts;
activeSorts: ActiveSorts;
activeSorts: ActiveSorts<Record<string, any>>;
}>();
const emit = defineEmits<{

View File

@@ -66,9 +66,11 @@ const emit = defineEmits<{
const isSelectable = computed(() => props.modelValue !== undefined);
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter({
queryStringParam: "filter",
});
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } =
useCollectionSorter();
useCollectionSorter<Record<string, any>>({ queryStringParam: "sort" });
const filteredCollection = useFilteredCollection(
toRef(props, "collection"),

View File

@@ -0,0 +1,47 @@
<template>
<div class="wrapper-spinner" v-if="!store.isReady">
<UiSpinner class="spinner" />
</div>
<ObjectNotFoundView :id="id" v-else-if="isRecordNotFound" />
<slot v-else />
</template>
<script lang="ts" setup>
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { useRouter } from "vue-router";
const storeByType = {
vm: useVmStore,
host: useHostStore,
};
const props = defineProps<{ objectType: "vm" | "host"; id?: string }>();
const store = storeByType[props.objectType]();
const { currentRoute } = useRouter();
const id = computed(
() => props.id ?? (currentRoute.value.params.uuid as string)
);
const isRecordNotFound = computed(
() => store.isReady && !store.hasRecordByUuid(id.value)
);
</script>
<style scoped>
.wrapper-spinner {
display: flex;
height: 100%;
}
.spinner {
color: var(--color-extra-blue-base);
display: flex;
margin: auto;
width: 10rem;
height: 10rem;
}
</style>

View File

@@ -1,63 +0,0 @@
<template>
<div class="progress-bar-component">
<div class="progress-bar">
<div class="progress-bar-fill" />
</div>
<div class="badge" v-if="label !== undefined">
<span class="circle" />
{{ label }}
<UiBadge>{{ badgeLabel ?? progressWithUnit }}</UiBadge>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import UiBadge from "@/components/ui/UiBadge.vue";
interface Props {
value: number;
badgeLabel?: string | number;
label?: string;
maxValue?: number;
}
const props = withDefaults(defineProps<Props>(), {
maxValue: 100,
});
const progressWithUnit = computed(() => {
const progress = Math.round((props.value / props.maxValue) * 100);
return `${progress}%`;
});
</script>
<style lang="postcss" scoped>
.badge {
text-align: right;
margin: 1rem 0;
}
.circle {
display: inline-block;
height: 10px;
width: 10px;
background-color: #716ac6;
border-radius: 1rem;
}
.progress-bar {
overflow: hidden;
height: 1.2rem;
border-radius: 0.4rem;
background-color: var(--color-blue-scale-400);
margin: 1rem 0;
}
.progress-bar-fill {
transition: width 1s ease-in-out;
width: v-bind(progressWithUnit);
height: 1.2rem;
background-color: var(--color-extra-blue-d20);
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<span :title="date.toLocaleString()">{{ relativeTime }}</span>
</template>
<script lang="ts" setup>
import useRelativeTime from "@/composables/relative-time.composable";
import { useNow } from "@vueuse/core";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
date: Date | number | string;
interval?: number;
}>(),
{ interval: 1000 }
);
const date = computed(() => new Date(props.date));
const now = useNow({ interval: props.interval });
const relativeTime = useRelativeTime(date, now);
</script>
<style lang="postcss" scoped></style>

View File

@@ -11,5 +11,7 @@
height: 6.5rem;
background-color: var(--background-color-primary);
border-bottom: 1px solid var(--color-blue-scale-400);
max-width: 100%;
overflow: auto;
}
</style>

View File

@@ -19,6 +19,10 @@ defineProps<{
</script>
<style lang="postcss" scoped>
.actions {
margin-left: auto;
}
.title-bar {
display: flex;
align-items: center;

View File

@@ -1,27 +1,34 @@
<template>
<div>
<div class="header">
<slot name="header" />
</div>
<template v-if="data !== undefined">
<ProgressBar
<div
v-for="item in computedData.sortedArray"
:key="item.id"
:value="item.value"
:label="item.label"
:badge-label="item.badgeLabel"
/>
<div class="footer">
<slot name="footer" :total-percent="computedData.totalPercentUsage" />
class="progress-item"
:class="{
warning: item.value > MIN_WARNING_VALUE,
error: item.value > MIN_DANGEROUS_VALUE,
}"
>
<UiProgressBar :value="item.value" color="custom" />
<div class="legend">
<span class="circle" />
{{ item.label }}
<UiBadge class="badge">{{
item.badgeLabel ?? `${item.value}%`
}}</UiBadge>
</div>
</div>
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
</template>
<UiSpinner v-else class="spinner" />
</div>
</template>
<script lang="ts" setup>
import UiBadge from "@/components/ui/UiBadge.vue";
import UiProgressBar from "@/components/ui/UiProgressBar.vue";
import { computed } from "vue";
import ProgressBar from "@/components/ProgressBar.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
interface Data {
@@ -33,10 +40,13 @@ interface Data {
}
interface Props {
data?: Array<Data>;
data?: Data[];
nItems?: number;
}
const MIN_WARNING_VALUE = 80;
const MIN_DANGEROUS_VALUE = 90;
const props = defineProps<Props>();
const computedData = computed(() => {
@@ -59,24 +69,7 @@ const computedData = computed(() => {
});
</script>
<style scoped>
.header {
color: var(--color-extra-blue-base);
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--color-extra-blue-base);
margin-bottom: 2rem;
font-size: 16px;
font-weight: 700;
}
.footer {
display: flex;
justify-content: space-between;
font-weight: 700;
font-size: 14px;
color: var(--color-blue-scale-300);
}
<style lang="postcss" scoped>
.spinner {
color: var(--color-extra-blue-base);
display: flex;
@@ -84,23 +77,49 @@ const computedData = computed(() => {
width: 40px;
height: 40px;
}
</style>
<style>
.progress-bar-component:nth-of-type(2) .progress-bar-fill,
.progress-bar-component:nth-of-type(2) .circle {
background-color: var(--color-extra-blue-d60);
.legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin: 1.6em 0;
}
.progress-bar-component:nth-of-type(3) .progress-bar-fill,
.progress-bar-component:nth-of-type(3) .circle {
background-color: var(--color-extra-blue-d40);
.badge {
font-size: 0.9em;
font-weight: 700;
}
.progress-bar-component:nth-of-type(4) .progress-bar-fill,
.progress-bar-component:nth-of-type(4) .circle {
background-color: var(--color-extra-blue-d20);
.progress-item:nth-child(1) {
--progress-bar-color: var(--color-extra-blue-d60);
}
.progress-bar-component .progress-bar-fill,
.progress-bar-component .circle {
background-color: var(--color-extra-blue-l20);
.progress-item:nth-child(2) {
--progress-bar-color: var(--color-extra-blue-d40);
}
.progress-item:nth-child(3) {
--progress-bar-color: var(--color-extra-blue-d20);
}
.progress-item {
--progress-bar-height: 1.2rem;
--progress-bar-color: var(--color-extra-blue-l20);
--progress-bar-background-color: var(--color-blue-scale-400);
&.warning {
--progress-bar-color: var(--color-orange-world-base);
}
&.error {
--progress-bar-color: var(--color-red-vates-base);
}
}
.circle {
display: inline-block;
width: 1rem;
height: 1rem;
border-radius: 0.5rem;
background-color: var(--progress-bar-color);
}
</style>

View File

@@ -18,15 +18,15 @@ const data: LinearChartData = [
{
label: "First series",
data: [
{ date: "...", value: 1234 },
{ date: "...", value: 1234 },
{ timestamp: 1670478371123, value: 1234 },
{ timestamp: 1670478519751, value: 1234 },
],
},
{
label: "Second series",
data: [
{ date: "...", value: 1234 },
{ date: "...", value: 1234 },
{ timestamp: 1670478519751, value: 1234 },
{ timestamp: 167047555000, value: 1234 },
],
},
];

View File

@@ -6,6 +6,7 @@
</template>
<script lang="ts" setup>
import { utcFormat } from "d3-time-format";
import type { EChartsOption } from "echarts";
import { computed, provide } from "vue";
import VueCharts from "vue-echarts";
@@ -22,20 +23,18 @@ import { CanvasRenderer } from "echarts/renderers";
import type { OptionDataValue } from "echarts/types/src/util/types";
import UiCard from "@/components/ui/UiCard.vue";
const Y_AXIS_MAX_VALUE = 200;
const props = defineProps<{
title?: string;
subtitle?: string;
data: LinearChartData;
valueFormatter?: (value: number) => string;
maxValue?: number;
}>();
const valueFormatter = (value: OptionDataValue | OptionDataValue[]) => {
if (props.valueFormatter) {
return props.valueFormatter(value as number);
}
return value.toString();
};
const valueFormatter = (value: OptionDataValue | OptionDataValue[]) =>
props.valueFormatter?.(value as number) ?? `${value}`;
provide("valueFormatter", valueFormatter);
@@ -62,8 +61,10 @@ const option = computed<EChartsOption>(() => ({
xAxis: {
type: "time",
axisLabel: {
showMinLabel: true,
showMaxLabel: true,
formatter: (timestamp: number) =>
utcFormat("%a\n%I:%M\n%p")(new Date(timestamp)),
showMaxLabel: false,
showMinLabel: false,
},
},
yAxis: {
@@ -71,19 +72,20 @@ const option = computed<EChartsOption>(() => ({
axisLabel: {
formatter: valueFormatter,
},
max: () => props.maxValue ?? Y_AXIS_MAX_VALUE,
},
series: props.data.map((series, index) => ({
type: "line",
name: series.label,
zlevel: index + 1,
data: series.data.map((item) => [item.date, item.value]),
data: series.data.map((item) => [item.timestamp, item.value]),
})),
}));
</script>
<style lang="postcss" scoped>
.chart {
width: 50rem;
width: 100%;
height: 30rem;
}
</style>

View File

@@ -6,6 +6,7 @@
:class="inputClass"
:disabled="disabled || isLabelDisabled"
class="input"
ref="inputElement"
v-bind="$attrs"
/>
<template v-else>
@@ -14,6 +15,7 @@
:class="inputClass"
:disabled="disabled || isLabelDisabled"
class="select"
ref="inputElement"
v-bind="$attrs"
>
<slot />
@@ -70,6 +72,8 @@ interface Props extends Omit<InputHTMLAttributes, ""> {
const props = withDefaults(defineProps<Props>(), { color: "info" });
const inputElement = ref();
const emit = defineEmits<{
(event: "update:modelValue", value: any): void;
}>();
@@ -78,6 +82,10 @@ const value = useVModel(props, "modelValue", emit);
const empty = computed(() => isEmpty(props.modelValue));
const isSelect = inject("isSelect", false);
const isLabelDisabled = inject("isLabelDisabled", ref(false));
const color = inject(
"color",
computed(() => undefined)
);
const wrapperClass = computed(() => [
isSelect ? "form-select" : "form-input",
@@ -88,13 +96,19 @@ const wrapperClass = computed(() => [
]);
const inputClass = computed(() => [
props.color,
color.value ?? props.color,
{
right: props.right,
"has-before": props.before !== undefined,
"has-after": props.after !== undefined,
},
]);
const focus = () => inputElement.value.focus();
defineExpose({
focus,
});
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,96 @@
<template>
<div class="wrapper">
<label
v-if="$slots.label"
class="form-label"
:class="{ disabled, ...formInputWrapperClass }"
>
<slot />
</label>
<slot />
<p v-if="hasError || hasWarning" :class="formInputWrapperClass">
<UiIcon :icon="faCircleExclamation" v-if="hasError" />{{
error ?? warning
}}
</p>
</div>
</template>
<script lang="ts" setup>
import { computed, provide, useSlots } from "vue";
import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons";
import UiIcon from "@/components/ui/UiIcon.vue";
const slots = useSlots();
const props = defineProps<{
disabled?: boolean;
error?: string;
warning?: string;
}>();
provide("hasLabel", slots.label !== undefined);
provide(
"isLabelDisabled",
computed(() => props.disabled)
);
const hasError = computed(
() => props.error !== undefined && props.error.trim() !== ""
);
const hasWarning = computed(
() => props.warning !== undefined && props.warning.trim() !== ""
);
provide(
"color",
computed(() =>
hasError.value ? "error" : hasWarning.value ? "warning" : undefined
)
);
const formInputWrapperClass = computed(() => ({
error: hasError.value,
warning: !hasError.value && hasWarning.value,
}));
</script>
<style lang="postcss" scoped>
.wrapper {
display: flex;
flex-direction: column;
}
.wrapper :deep(.input) {
margin-bottom: 1rem;
}
.form-label {
font-size: 1.6rem;
display: inline-flex;
align-items: center;
gap: 0.625em;
&.disabled {
cursor: not-allowed;
color: var(--color-blue-scale-300);
}
}
p.error,
p.warning {
font-size: 0.65em;
margin-bottom: 1rem;
}
.error {
color: var(--color-red-vates-base);
}
.warning {
color: var(--color-orange-world-base);
}
p svg {
margin-right: 0.4em;
}
</style>

View File

@@ -1,33 +0,0 @@
<template>
<label :class="{ disabled }" class="form-label">
<slot />
</label>
</template>
<script lang="ts" setup>
import { computed, provide } from "vue";
const props = defineProps<{
disabled?: boolean;
}>();
provide("hasLabel", true);
provide(
"isLabelDisabled",
computed(() => props.disabled)
);
</script>
<style lang="postcss" scoped>
.form-label {
font-size: 1.6rem;
display: inline-flex;
align-items: center;
gap: 0.625em;
&.disabled {
cursor: not-allowed;
color: var(--color-blue-scale-300);
}
}
</style>

View File

@@ -12,7 +12,7 @@
:icon="faServer"
:route="{ name: 'host.dashboard', params: { uuid: host.uuid } }"
>
{{ host.name_label || '(Host)' }}
{{ host.name_label || "(Host)" }}
<template #actions>
<InfraAction
:icon="isExpanded ? faAngleDown : faAngleUp"

View File

@@ -12,7 +12,7 @@
:icon="faDisplay"
:route="{ name: 'vm.console', params: { uuid: vm.uuid } }"
>
{{ vm.name_label || '(VM)' }}
{{ vm.name_label || "(VM)" }}
<template #actions>
<InfraAction>
<PowerStateIcon :state="vm?.power_state" />

View File

@@ -12,7 +12,13 @@
</MenuTrigger>
<AppMenu v-else shadow :disabled="isDisabled">
<template #trigger="{ open, isOpen }">
<MenuTrigger :active="isOpen" :icon="icon" @click="open">
<MenuTrigger
:active="isOpen"
:busy="isBusy"
:disabled="isDisabled"
:icon="icon"
@click="open"
>
<slot />
<UiIcon
:fixed-width="false"

View File

@@ -1,6 +1,6 @@
<template>
<UiCard>
<UiTitle type="h4">{{ $t("cpu-usage") }}</UiTitle>
<UiCardTitle>{{ $t("cpu-usage") }}</UiCardTitle>
<HostsCpuUsage />
<VmsCpuUsage />
</UiCard>
@@ -9,5 +9,5 @@
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
</script>

View File

@@ -0,0 +1,93 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('network-throughput')"
:value-formatter="customValueFormatter"
/>
</template>
<script lang="ts" setup>
import { computed, inject } from "vue";
import { map } from "lodash-es";
import { useI18n } from "vue-i18n";
import LinearChart from "@/components/charts/LinearChart.vue";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import { formatSize } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import type { LinearChartData } from "@/types/chart";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const data = computed<LinearChartData>(() => {
const stats = hostLastWeekStats?.stats?.value;
const timestampStart = hostLastWeekStats?.timestampStart?.value;
if (timestampStart === undefined || stats === undefined) {
return [];
}
const results = {
tx: new Map<number, { timestamp: number; value: number }>(),
rx: new Map<number, { timestamp: number; value: number }>(),
};
const addResult = (stats: HostStats, type: "tx" | "rx") => {
const networkStats = Object.values(stats.pifs[type]);
for (let hourIndex = 0; hourIndex < networkStats[0].length; hourIndex++) {
const timestamp =
(timestampStart + hourIndex * RRD_STEP_FROM_STRING.hours) * 1000;
const networkThroughput = networkStats.reduce(
(total, throughput) => total + throughput[hourIndex],
0
);
results[type].set(timestamp, {
timestamp,
value: (results[type].get(timestamp)?.value ?? 0) + networkThroughput,
});
}
};
stats.forEach((host) => {
if (!host.stats) {
return;
}
addResult(host.stats, "rx");
addResult(host.stats, "tx");
});
return [
{
label: t("network-upload"),
data: Array.from(results["tx"].values()),
},
{
label: t("network-download"),
data: Array.from(results["rx"].values()),
},
];
});
// 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(
() =>
Math.max(
...map(data.value[0].data, "value"),
...map(data.value[1].data, "value")
) * 1.5
);
const customValueFormatter = (value: number) => String(formatSize(value));
</script>

View File

@@ -0,0 +1,14 @@
<template>
<UiCard>
<UiCardTitle>{{ $t("ram-usage") }}</UiCardTitle>
<HostsRamUsage />
<VmsRamUsage />
</UiCard>
</template>
<script setup lang="ts">
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
</script>

View File

@@ -1,17 +1,17 @@
<template>
<UiCard>
<UiTitle type="h4">{{ $t("status") }}</UiTitle>
<UiCardTitle>{{ $t("status") }}</UiCardTitle>
<template v-if="isReady">
<PoolDashboardStatusItem
:active="activeHostsCount"
:total="totalHostsCount"
:label="$t('hosts')"
:total="totalHostsCount"
/>
<UiSeparator />
<PoolDashboardStatusItem
:active="activeVmsCount"
:total="totalVmsCount"
:label="$t('vms')"
:total="totalVmsCount"
/>
</template>
<UiSpinner v-else class="spinner" />
@@ -19,14 +19,14 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import PoolDashboardStatusItem from "@/components/pool/dashboard/PoolDashboardStatusItem.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiSeparator from "@/components/ui/UiSeparator.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
const vmStore = useVmStore();
const hostMetricsStore = useHostMetricsStore();

View File

@@ -1,55 +1,31 @@
<template>
<UiCard>
<UiTitle type="h4">{{ $t("storage-usage") }}</UiTitle>
<UsageBar :data="srStore.isReady ? data.result : undefined" :nItems="5">
<template #header>
<span>{{ $t("storage") }}</span>
<span>{{ $t("top-#", { n: 5 }) }}</span>
</template>
<template #footer v-if="showFooter">
<div class="footer-card">
<p>{{ $t("total-used") }}:</p>
<div class="footer-value">
<p>{{ percentUsed }}%</p>
<p>
{{ formatSize(data.usedSize) }}
</p>
</div>
</div>
<div class="footer-card">
<p>{{ $t("total-free") }}:</p>
<div class="footer-value">
<p>{{ percentFree }}%</p>
<p>
{{ formatSize(data.maxSize) }}
</p>
</div>
</div>
<UiCardTitle
:left="$t('storage-usage')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar
:data="srStore.isReady ? data.result : undefined"
:nItems="N_ITEMS"
>
<template #footer>
<SizeStatsSummary :size="data.maxSize" :usage="data.usedSize" />
</template>
</UsageBar>
</UiCard>
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { computed } from "vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import UsageBar from "@/components/UsageBar.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { formatSize, percent } from "@/libs/utils";
import { useSrStore } from "@/stores/storage.store";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
const srStore = useSrStore();
const percentUsed = computed(() =>
percent(data.value.usedSize, data.value.maxSize, 1)
);
const percentFree = computed(() =>
percent(data.value.maxSize - data.value.usedSize, data.value.maxSize, 1)
);
const showFooter = computed(() => !isNaN(percentUsed.value));
const data = computed<{
result: { id: string; label: string; value: number }[];
maxSize: number;
@@ -84,21 +60,3 @@ const data = computed<{
return { result, maxSize, usedSize };
});
</script>
<style lang="postcss" scoped>
.footer-card {
color: var(--color-blue-scale-200);
display: flex;
text-transform: uppercase;
}
.footer-card p {
font-weight: 700;
}
.footer-value {
display: flex;
flex-direction: column;
text-align: right;
}
</style>

View File

@@ -1,18 +1,20 @@
<template>
<UsageBar :data="statFetched ? data : undefined" :n-items="5">
<template #header>
<span>{{ $t("hosts") }}</span>
<span>{{ $t("top-#", { n: 5 }) }}</span>
</template>
</UsageBar>
<UiCardTitle
subtitle
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
</template>
<script lang="ts" setup>
import { type ComputedRef, computed, inject } from "vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import { getAvgCpuUsage } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",

View File

@@ -0,0 +1,82 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<LinearChart
:data="data"
:max-value="customMaxValue"
:subtitle="$t('last-week')"
:title="$t('pool-cpu-usage')"
:value-formatter="customValueFormatter"
/>
</template>
<script lang="ts" setup>
import LinearChart from "@/components/charts/LinearChart.vue";
import type { HostStats } from "@/libs/xapi-stats";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData } from "@/types/chart";
import { sumBy } from "lodash-es";
import { storeToRefs } from "pinia";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const { allRecords: hosts } = storeToRefs(useHostStore());
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
);
const data = computed<LinearChartData>(() => {
const timestampStart = hostLastWeekStats?.timestampStart?.value;
const stats = hostLastWeekStats?.stats?.value;
if (timestampStart === undefined || stats === undefined) {
return [];
}
const result = new Map<number, { timestamp: number; value: number }>();
const addResult = (stats: HostStats) => {
const cpus = Object.values(stats.cpus);
for (let hourIndex = 0; hourIndex < cpus[0].length; hourIndex++) {
const timestamp =
(timestampStart + hourIndex * RRD_STEP_FROM_STRING.hours) * 1000;
const cpuUsageSum = cpus.reduce(
(total, cpu) => total + cpu[hourIndex],
0
);
result.set(timestamp, {
timestamp: timestamp,
value: Math.round((result.get(timestamp)?.value ?? 0) + cpuUsageSum),
});
}
};
stats.forEach((host) => {
if (!host.stats) {
return;
}
addResult(host.stats);
});
return [
{
label: t("stacked-cpu-usage"),
data: Array.from(result.values()),
},
];
});
const customValueFormatter = (value: number) => `${value}%`;
</script>

View File

@@ -1,18 +1,20 @@
<template>
<UsageBar :data="statFetched ? data : undefined" :n-items="5">
<template #header>
<span>{{ $t("vms") }}</span>
<span>{{ $t("top-#", { n: 5 }) }}</span>
</template>
</UsageBar>
<UiCardTitle
subtitle
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import { getAvgCpuUsage } from "@/libs/utils";
import type { VmStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",

View File

@@ -0,0 +1,53 @@
<template>
<UiCardTitle
subtitle
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import { formatSize, parseRamUsage } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",
computed(() => [])
);
const data = computed(() => {
const result: {
id: string;
label: string;
value: number;
badgeLabel: string;
}[] = [];
stats.value.forEach((stat) => {
if (stat.stats === undefined) {
return;
}
const { percentUsed, total, used } = parseRamUsage(stat.stats);
result.push({
id: stat.id,
label: stat.name,
value: percentUsed,
badgeLabel: `${formatSize(used)}/${formatSize(total)}`,
});
});
return result;
});
const statFetched: ComputedRef<boolean> = computed(
() =>
statFetched.value ||
(stats.value.length > 0 && stats.value.length === data.value.length)
);
</script>

View File

@@ -0,0 +1,93 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<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>
</template>
<script lang="ts" setup>
import LinearChart from "@/components/charts/LinearChart.vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData } from "@/types/chart";
import { sumBy } from "lodash-es";
import { storeToRefs } from "pinia";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
import { formatSize, getHostMemory, isHostRunning } from "@/libs/utils";
import type { XenApiHost } from "@/libs/xen-api";
const { allRecords: hosts } = storeToRefs(useHostStore());
const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const runningHosts = computed(() => hosts.value.filter(isHostRunning));
const customMaxValue = computed(() =>
sumBy(runningHosts.value, (host) => getHostMemory(host)?.size ?? 0)
);
const currentData = computed(() => {
let size = 0,
usage = 0;
runningHosts.value.forEach((host) => {
const hostMemory = getHostMemory(host);
size += hostMemory?.size ?? 0;
usage += hostMemory?.usage ?? 0;
});
return { size, usage };
});
const data = computed<LinearChartData>(() => {
const timestampStart = hostLastWeekStats?.timestampStart?.value;
const stats = hostLastWeekStats?.stats?.value;
if (timestampStart === undefined || stats === undefined) {
return [];
}
const result = new Map<number, { timestamp: number; value: number }>();
stats.forEach(({ stats }) => {
if (stats?.memory === undefined) {
return;
}
const memoryFree = stats.memoryFree;
const memoryUsage = stats.memory.map(
(memory, index) => memory - memoryFree[index]
);
memoryUsage.forEach((value, hourIndex) => {
const timestamp =
(timestampStart + hourIndex * RRD_STEP_FROM_STRING.hours) * 1000;
result.set(timestamp, {
timestamp,
value: (result.get(timestamp)?.value ?? 0) + memoryUsage[hourIndex],
});
});
});
return [
{
label: t("stacked-ram-usage"),
data: Array.from(result.values()),
},
];
});
const customValueFormatter = (value: number) => String(formatSize(value));
</script>

View File

@@ -0,0 +1,53 @@
<template>
<UiCardTitle
subtitle
:left="$t('vms')"
:right="$t('top-#', { n: N_ITEMS })"
/>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
</template>
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import { formatSize, parseRamUsage } from "@/libs/utils";
import type { VmStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",
computed(() => [])
);
const data = computed(() => {
const result: {
id: string;
label: string;
value: number;
badgeLabel: string;
}[] = [];
stats.value.forEach((stat) => {
if (stat.stats === undefined) {
return;
}
const { percentUsed, total, used } = parseRamUsage(stat.stats);
result.push({
id: stat.id,
label: stat.name,
value: percentUsed,
badgeLabel: `${formatSize(used)}/${formatSize(total)}`,
});
});
return result;
});
const statFetched: ComputedRef<boolean> = computed(
() =>
statFetched.value ||
(stats.value.length > 0 && stats.value.length === data.value.length)
);
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="summary" v-if="isDisplayed">
<div class="summary-card">
<p>{{ $t("total-used") }}:</p>
<div class="summary-value">
<p>{{ percentUsed }}%</p>
<p>
{{ formatSize(usage) }}
</p>
</div>
</div>
<div class="summary-card">
<p>{{ $t("total-free") }}:</p>
<div class="summary-value">
<p>{{ percentFree }}%</p>
<p>
{{ formatSize(free) }}
</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { formatSize, percent } from "@/libs/utils";
import { computed } from "vue";
const props = defineProps<{
size: number;
usage: number;
}>();
const free = computed(() => props.size - props.usage);
const percentFree = computed(() => percent(free.value, props.size));
const percentUsed = computed(() => percent(props.usage, props.size));
const isDisplayed = computed(
() => !isNaN(percentUsed.value) && !isNaN(percentFree.value)
);
</script>
<style lang="postcss" scoped>
.summary {
display: flex;
justify-content: space-between;
font-weight: 700;
font-size: 14px;
color: var(--color-blue-scale-300);
}
.summary-card {
color: var(--color-blue-scale-200);
display: flex;
text-transform: uppercase;
}
.summary-card p {
font-weight: 700;
}
.summary-value {
display: flex;
flex-direction: column;
text-align: right;
}
</style>

View File

@@ -22,7 +22,7 @@ defineProps<{
font-size: 1.4rem;
font-weight: 500;
padding: 0 0.8rem;
height: 2.4rem;
height: 1.8em;
color: var(--color-blue-scale-500);
border-radius: 9.6rem;
background-color: var(--color-blue-scale-300);

View File

@@ -0,0 +1,65 @@
<template>
<div :class="{ subtitle }" class="ui-section-title">
<component
:is="subtitle ? 'h5' : 'h4'"
v-if="$slots.default || left"
class="left"
>
<slot>{{ left }}</slot>
</component>
<component
:is="subtitle ? 'h6' : 'h5'"
v-if="$slots.right || right"
class="right"
>
<slot name="right">{{ right }}</slot>
</component>
</div>
</template>
<script lang="ts" setup>
defineProps<{
subtitle?: boolean;
left?: string;
right?: string;
}>();
</script>
<style lang="postcss" scoped>
.ui-section-title {
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);
--section-title-left-weight: 500;
--section-title-right-size: 1.6rem;
--section-title-right-color: var(--color-extra-blue-base);
--section-title-right-weight: 700;
&.subtitle {
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;
--section-title-right-size: 1.4rem;
--section-title-right-color: var(--color-extra-blue-base);
--section-title-right-weight: 400;
}
}
.left {
font-size: var(--section-title-left-size);
font-weight: var(--section-title-left-weight);
color: var(--section-title-left-color);
}
.right {
font-size: var(--section-title-right-size);
font-weight: var(--section-title-right-weight);
color: var(--section-title-right-color);
}
</style>

View File

@@ -1,14 +1,15 @@
<template>
<div class="ui-key-value-list"><slot /></div>
<table class="ui-key-value-list">
<tbody>
<slot />
</tbody>
</table>
</template>
<script lang="ts" setup></script>
<style lang="postcss" scoped>
.ui-key-value-list {
margin-top: 2rem;
font-size: 1.4rem;
/* UiKeyValueRow: 15em (key) + 15em (value) + 1rem (gap) */
min-width: calc(30em + 1rem);
border-spacing: 0;
}
</style>

View File

@@ -1,37 +1,33 @@
<template>
<div class="ui-key-value-row">
<span class="key" v-if="$slots.key">
<tr class="ui-key-value-row">
<th v-if="$slots.key" class="key">
<slot name="key" />
</span>
<span class="value">
</th>
<td :colspan="$slots.key ? 1 : 2" class="value">
<slot name="value" />
</span>
</div>
</td>
</tr>
</template>
<script lang="ts" setup></script>
<style lang="postcss" scoped>
.ui-key-value-row {
display: flex;
gap: 1rem;
align-items: baseline;
margin: 0.5em 0;
}
.key {
color: var(--color-blue-scale-300);
width: 15%;
max-width: 15em;
max-width: 30em;
}
.value {
flex-grow: 1;
}
@import "@/assets/_responsive.pcss";
.key,
.value {
text-overflow: ellipsis;
width: 100%;
min-width: 15em;
max-width: 30em;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
font-weight: 400;
}
.key {
padding-right: 2rem;
text-align: left;
color: var(--color-blue-scale-300);
@media (--desktop) {
min-width: 20rem;
}
}
</style>

View File

@@ -33,12 +33,12 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMagicKeys, whenever } from "@vueuse/core";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
@@ -64,11 +64,13 @@ const className = computed(() => {
<style lang="postcss" scoped>
.ui-modal {
position: fixed;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
overflow: auto;
align-items: center;
justify-content: center;
background-color: #00000080;
@@ -142,6 +144,9 @@ const className = computed(() => {
}
.content {
overflow: auto;
min-height: 23rem;
max-height: calc(100vh - 40rem);
margin-top: 2rem;
}

View File

@@ -0,0 +1,60 @@
<template>
<div class="ui-progress-bar" :class="`color-${color}`">
<div class="fill" />
</div>
</template>
<script lang="ts" setup>
import type { Color } from "@/types";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
value: number;
color?: Color | "custom";
maxValue?: number;
}>(),
{ color: "info", maxValue: 100 }
);
const progressWithUnit = computed(() => {
const progress = (props.value / props.maxValue) * 100;
return `${progress}%`;
});
</script>
<style lang="postcss" scoped>
.ui-progress-bar {
overflow: hidden;
height: var(--progress-bar-height, 0.4rem);
margin: 1rem 0;
border-radius: 0.4rem;
background-color: var(
--progress-bar-background-color,
var(--background-color-extra-blue)
);
&.color-info {
--progress-bar-color: var(--color-extra-blue-base);
}
&.color-success {
--progress-bar-color: var(--color-green-infra-base);
}
&.color-warning {
--progress-bar-color: var(--color-orange-world-base);
}
&.color-error {
--progress-bar-color: var(--color-red-vates-base);
}
}
.fill {
width: v-bind(progressWithUnit);
height: var(--progress-bar-height, 0.4rem);
transition: width 1s ease-in-out;
background-color: var(--progress-bar-color);
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<TitleBar :icon="faDisplay">
{{ name }}
<template #actions>
<AppMenu shadow placement="bottom-end">
<template #trigger="{ open, isOpen }">
<UiButton :active="isOpen" :icon="faPowerOff" @click="open">
{{ $t("change-state") }}
<UiIcon :icon="faAngleDown" />
</UiButton>
</template>
<MenuItem
@click="xenApi.vm.start({ vmRef: vm.$ref })"
:busy="isOperationsPending('start')"
:disabled="!isHalted"
:icon="faPlay"
>
{{ $t("start") }}
</MenuItem>
<MenuItem
:busy="isOperationsPending('start_on')"
:disabled="!isHalted"
:icon="faServer"
>
{{ $t("start-on-host") }}
<template #submenu>
<MenuItem
v-for="host in hostStore.allRecords"
@click="xenApi.vm.startOn({ vmRef: vm.$ref, hostRef: host.$ref })"
v-bind:key="host.$ref"
:icon="faServer"
>
<div class="wrapper">
{{ host.name_label }}
<div>
<UiIcon
:icon="
host.$ref === poolStore.pool?.master ? faStar : undefined
"
class="star"
/>
<PowerStateIcon
:state="isHostRunning(host) ? 'Running' : 'Halted'"
/>
</div>
</div>
</MenuItem>
</template>
</MenuItem>
<MenuItem
@click="xenApi.vm.pause({ vmRef: vm.$ref })"
:busy="isOperationsPending('pause')"
:disabled="!isRunning"
:icon="faPause"
>
{{ $t("pause") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.suspend({ vmRef: vm.$ref })"
:busy="isOperationsPending('suspend')"
:disabled="!isRunning"
:icon="faMoon"
>
{{ $t("suspend") }}
</MenuItem>
<!-- TODO: update the icon once Clémence has integrated the action into figma -->
<MenuItem
@click="
xenApi.vm.resume({
vmRef: vm.$ref,
})
"
:busy="isOperationsPending(['unpause', 'resume'])"
:disabled="!isSuspended && !isPaused"
:icon="faCirclePlay"
>
{{ $t("resume") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref })"
:busy="isOperationsPending('clean_reboot')"
:disabled="!isRunning"
:icon="faRotateLeft"
>
{{ $t("reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending('hard_reboot')"
:disabled="!isRunning && !isPaused"
:icon="faRepeat"
>
{{ $t("force-reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref })"
:busy="isOperationsPending('clean_shutdown')"
:disabled="!isRunning"
:icon="faPowerOff"
>
{{ $t("shutdown") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending('hard_shutdown')"
:disabled="!isRunning && !isSuspended && !isPaused"
:icon="faPlug"
>
{{ $t("force-shutdown") }}
</MenuItem>
</AppMenu>
</template>
</TitleBar>
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import TitleBar from "@/components/TitleBar.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
import { isHostRunning } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useVmStore } from "@/stores/vm.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import {
faAngleDown,
faCirclePlay,
faDisplay,
faMoon,
faPause,
faPlay,
faPlug,
faPowerOff,
faRepeat,
faRotateLeft,
faServer,
faStar,
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { computedAsync } from "@vueuse/core";
import { difference } from "lodash";
const vmStore = useVmStore();
const hostStore = useHostStore();
const poolStore = usePoolStore();
const { currentRoute } = useRouter();
const isOperationsPending = (operations: string[] | string) => {
const _operations = Array.isArray(operations) ? operations : [operations];
return (
difference(_operations, vmOperations.value).length < _operations.length
);
};
const vmOperations = computed(() => Object.values(vm.value.current_operations));
const vm = computed(
() => vmStore.getRecordByUuid(currentRoute.value.params.uuid as string)!
);
const xenApi = computedAsync(() => useXenApiStore().getXapi());
const name = computed(() => vm.value.name_label);
const isRunning = computed(() => vm.value.power_state === "Running");
const isHalted = computed(() => vm.value.power_state === "Halted");
const isSuspended = computed(() => vm.value.power_state === "Suspended");
const isPaused = computed(() => vm.value.power_state === "Paused");
</script>
<style lang="postcss" scoped>
.star {
margin: 0 1rem;
color: var(--color-orange-world-base);
}
.wrapper {
display: flex;
justify-content: space-between;
width: 100%;
}
</style>

View File

@@ -1,9 +1,15 @@
<template>
<AppMenu
:disabled="selectedRefs.length === 0"
:horizontal="!isMobile"
:shadow="isMobile"
class="vms-actions-bar"
horizontal
placement="bottom-end"
>
<template v-if="isMobile" #trigger="{ isOpen, open }">
<UiButton :active="isOpen" :icon="faEllipsis" transparent @click="open" />
</template>
<MenuItem :icon="faPowerOff">{{ $t("change-power-state") }}</MenuItem>
<MenuItem :icon="faRoute">{{ $t("migrate") }}</MenuItem>
<MenuItem :icon="faCopy">{{ $t("copy") }}</MenuItem>
@@ -27,6 +33,10 @@
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useUiStore } from "@/stores/ui.store";
import {
faBox,
faCamera,
@@ -34,19 +44,21 @@ import {
faCopy,
faDisplay,
faEdit,
faEllipsis,
faFileCsv,
faFileExport,
faPowerOff,
faRoute,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import { storeToRefs } from "pinia";
defineProps<{
disabled?: boolean;
selectedRefs: string[];
}>();
const { isMobile } = storeToRefs(useUiStore());
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,41 @@
# useArrayRemovedItemsHistory composable
This composable allows you to keep a history of each removed item of an array.
## Usage
```typescript
const myArray = ref([]);
const history = useArrayRemovedItemsHistory(myArray)
myArray.push('A'); // myArray = ['A']; history = []
myArray.push('B'); // myArray = ['A', 'B']; history = []
myArray.shift(); // myArray = ['B']; history = ['A']
```
You can limit the number of items to keep in history:
```typescript
const myArray = ref([]);
const history = useArrayRemovedItemsHistory(myArray, 30);
```
Be careful when using an array of objects which is likely to be replaced (instead of being altered):
```typescript
const myArray = ref([]);
const history = useArrayRemovedItemsHistory(myArray);
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }, { id: 'bar' }]
```
In this case, `{ id: 'bar' }` is detected as removed since in JavaScript `{ id: 'bar' } !== { id: 'bar' }`.
You must therefore use an identity function as third parameter to return the value to be used to detect deletion:
```typescript
const myArray = ref<{ id: string }[]>([]);
const history = useArrayRemovedItemsHistory(myArray, undefined, (item) => item.id);
myArray.value = [{ id: 'foo' }, { id: 'bar' }];
myArray.value = [{ id: 'bar' }, { id: 'baz' }]; // history = [{ id: 'foo' }]
```

View File

@@ -0,0 +1,30 @@
import { differenceBy } from "lodash-es";
import { type Ref, ref, unref, watch } from "vue";
export default function useArrayRemovedItemsHistory<T>(
list: Ref<T[]>,
limit = Infinity,
iteratee: (item: T) => unknown = (item) => item
) {
const currentList: Ref<T[]> = ref([]);
const history: Ref<T[]> = ref([]);
watch(
list,
(updatedList) => {
currentList.value = [...updatedList];
},
{ deep: true }
);
watch(currentList, (nextList, previousList) => {
const removedItems = differenceBy(previousList, nextList, iteratee);
history.value.push(...removedItems);
const currentLimit = unref(limit);
if (history.value.length > currentLimit) {
history.value.slice(-currentLimit);
}
});
return history;
}

View File

@@ -1,23 +1,22 @@
# useBusy composable
```vue
<template>
<span class="error" v-if="error">{{ error }}</span>
<button @click="run" :disabled="isBusy">Do something</button>
</template>
<script lang="ts" setup>
import useBusy from '@/composables/busy.composable';
import useBusy from "@/composables/busy.composable";
async function doSomething() {
try {
// Doing some async work
} catch (e) {
throw "Something bad happened";
}
async function doSomething() {
try {
// Doing some async work
} catch (e) {
throw "Something bad happened";
}
}
const { isBusy, error, run } = useBusy(doSomething)
const { isBusy, error, run } = useBusy(doSomething);
</script>
```

View File

@@ -1,382 +1,411 @@
import { provide } from "vue";
import { useUiStore } from "@/stores/ui.store";
import { storeToRefs } from "pinia";
import { computed, provide, ref, watch } from "vue";
import { THEME_KEY } from "vue-echarts";
export const useChartTheme = () => {
provide(THEME_KEY, {
color: ["#8F84FF", "#EF7F18"],
backgroundColor: "#ffffff",
textStyle: {},
grid: {
top: 80,
left: 80,
right: 20,
},
title: {
textStyle: {
color: "#1A1B38",
fontFamily: "Poppins, sans-serif",
fontWeight: 500,
fontSize: 20,
const { colorMode } = storeToRefs(useUiStore());
const style = window.getComputedStyle(window.document.documentElement);
const getColors = () => ({
background: style.getPropertyValue("--background-color-primary"),
title: style.getPropertyValue("--color-blue-scale-100"),
subtitle: 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"),
});
const colors = ref(getColors());
watch(colorMode, () => (colors.value = getColors()), { flush: "post" });
provide(
THEME_KEY,
computed(() => ({
color: [colors.value.primary, colors.value.secondary],
backgroundColor: colors.value.background,
textStyle: {},
grid: {
top: 80,
left: 80,
right: 20,
},
subtextStyle: {
color: "#9899A5",
fontFamily: "Poppins, sans-serif",
fontWeight: 400,
fontSize: 14,
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,
},
lineStyle: {
width: 2,
},
showSymbol: false,
symbolSize: 10,
symbol: "circle",
smooth: false,
},
radar: {
itemStyle: {
borderWidth: 2,
},
lineStyle: {
width: 2,
},
symbolSize: 10,
symbol: "circle",
smooth: false,
},
bar: {
itemStyle: {
barBorderWidth: 0,
barBorderColor: "#cccccc",
},
},
pie: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
scatter: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
boxplot: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
parallel: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
sankey: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
funnel: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
gauge: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
candlestick: {
itemStyle: {
color: "#eb8146",
color0: "transparent",
borderColor: "#d95850",
borderColor0: "#58c470",
borderWidth: "2",
},
},
graph: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
lineStyle: {
width: 1,
color: "#aaaaaa",
},
symbolSize: "10",
symbol: "emptyArrow",
smooth: true,
color: ["#893448", "#d95850", "#eb8146", "#ffb248", "#f2d643", "#ebdba4"],
label: {
color: "#ffffff",
},
},
map: {
itemStyle: {
areaColor: "#f3f3f3",
borderColor: "#999999",
borderWidth: 0.5,
},
label: {
color: "#893448",
},
emphasis: {
line: {
itemStyle: {
areaColor: "#ffb248",
borderColor: "#eb8146",
borderWidth: 1,
borderWidth: 2,
},
lineStyle: {
width: 2,
},
showSymbol: false,
symbolSize: 10,
symbol: "circle",
smooth: false,
},
radar: {
itemStyle: {
borderWidth: 2,
},
lineStyle: {
width: 2,
},
symbolSize: 10,
symbol: "circle",
smooth: false,
},
bar: {
itemStyle: {
barBorderWidth: 0,
barBorderColor: "#cccccc",
},
},
pie: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
scatter: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
boxplot: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
parallel: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
sankey: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
funnel: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
gauge: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
},
candlestick: {
itemStyle: {
color: "#eb8146",
color0: "transparent",
borderColor: "#d95850",
borderColor0: "#58c470",
borderWidth: "2",
},
},
graph: {
itemStyle: {
borderWidth: 0,
borderColor: "#cccccc",
},
lineStyle: {
width: 1,
color: "#aaaaaa",
},
symbolSize: "10",
symbol: "emptyArrow",
smooth: true,
color: [
"#893448",
"#d95850",
"#eb8146",
"#ffb248",
"#f2d643",
"#ebdba4",
],
label: {
color: "#ffffff",
},
},
map: {
itemStyle: {
areaColor: "#f3f3f3",
borderColor: "#999999",
borderWidth: 0.5,
},
label: {
color: "#893448",
},
emphasis: {
itemStyle: {
areaColor: "#ffb248",
borderColor: "#eb8146",
borderWidth: 1,
},
label: {
color: "#893448",
},
},
},
},
geo: {
itemStyle: {
areaColor: "#f3f3f3",
borderColor: "#999999",
borderWidth: 0.5,
},
label: {
color: "#893448",
},
emphasis: {
geo: {
itemStyle: {
areaColor: "#ffb248",
borderColor: "#eb8146",
borderWidth: 1,
areaColor: "#f3f3f3",
borderColor: "#999999",
borderWidth: 0.5,
},
label: {
color: "#893448",
},
},
},
categoryAxis: {
axisLine: {
show: true,
lineStyle: {
color: "#aaaaaa",
emphasis: {
itemStyle: {
areaColor: "#ffb248",
borderColor: "#eb8146",
borderWidth: 1,
},
label: {
color: "#893448",
},
},
},
axisTick: {
show: false,
lineStyle: {
color: "#333",
categoryAxis: {
axisLine: {
show: true,
lineStyle: {
color: "#aaaaaa",
},
},
axisTick: {
show: false,
lineStyle: {
color: "#333",
},
},
axisLabel: {
show: true,
color: "#999999",
},
splitLine: {
show: true,
lineStyle: {
color: [colors.value.splitLine],
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
},
},
},
axisLabel: {
show: true,
color: "#999999",
},
splitLine: {
show: true,
lineStyle: {
color: ["#e6e6e6"],
valueAxis: {
axisLine: {
show: false,
// lineStyle: {
// color: "#aaaaaa",
// },
},
axisTick: {
show: false,
// lineStyle: {
// color: "#333",
// },
},
axisLabel: {
show: true,
color: colors.value.subtitle,
},
splitLine: {
show: true,
lineStyle: {
color: [colors.value.splitLine],
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
},
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
logAxis: {
axisLine: {
show: true,
lineStyle: {
color: "#aaaaaa",
},
},
axisTick: {
show: false,
lineStyle: {
color: "#333",
},
},
axisLabel: {
show: true,
color: "#999999",
},
splitLine: {
show: true,
lineStyle: {
color: [colors.value.splitLine],
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
},
},
},
},
valueAxis: {
axisLine: {
show: false,
// lineStyle: {
// color: "#aaaaaa",
// },
},
axisTick: {
show: false,
// lineStyle: {
// color: "#333",
// },
},
axisLabel: {
show: true,
color: "#9899A5",
},
splitLine: {
show: true,
lineStyle: {
color: ["#E5E5E7"],
timeAxis: {
axisLine: {
show: false,
// lineStyle: {
// color: "#aaaaaa",
// },
},
axisTick: {
show: false,
// lineStyle: {
// color: "#333",
// },
},
axisLabel: {
show: true,
color: colors.value.subtitle,
},
splitLine: {
show: true,
lineStyle: {
type: "dashed",
color: [colors.value.splitLine],
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
},
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
},
},
},
logAxis: {
axisLine: {
show: true,
lineStyle: {
color: "#aaaaaa",
},
},
axisTick: {
show: false,
lineStyle: {
color: "#333",
},
},
axisLabel: {
show: true,
color: "#999999",
},
splitLine: {
show: true,
lineStyle: {
color: ["#e6e6e6"],
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
},
},
},
timeAxis: {
axisLine: {
show: false,
// lineStyle: {
// color: "#aaaaaa",
// },
},
axisTick: {
show: false,
// lineStyle: {
// color: "#333",
// },
},
axisLabel: {
show: true,
color: "#9899A5",
},
splitLine: {
show: true,
lineStyle: {
type: "dashed",
color: ["#E5E5E7"],
},
},
splitArea: {
show: false,
areaStyle: {
color: ["rgba(250,250,250,0.05)", "rgba(200,200,200,0.02)"],
},
},
},
toolbox: {
iconStyle: {
borderColor: "#999999",
},
emphasis: {
toolbox: {
iconStyle: {
borderColor: "#666666",
borderColor: "#999999",
},
emphasis: {
iconStyle: {
borderColor: "#666666",
},
},
},
},
legend: {
left: "right",
top: "bottom",
textStyle: {
color: "#9899A5",
legend: {
left: "right",
top: "bottom",
textStyle: {
color: colors.value.subtitle,
},
},
},
tooltip: {
trigger: "axis",
axisPointer: {
tooltip: {
trigger: "axis",
axisPointer: {
lineStyle: {
color: "#8F84FF",
width: 1,
},
crossStyle: {
color: "#8F84FF",
width: 1,
},
},
},
timeline: {
lineStyle: {
color: "#8F84FF",
color: "#893448",
width: 1,
},
crossStyle: {
color: "#8F84FF",
width: 1,
},
},
},
timeline: {
lineStyle: {
color: "#893448",
width: 1,
},
itemStyle: {
color: "#893448",
borderWidth: 1,
},
controlStyle: {
color: "#893448",
borderColor: "#893448",
borderWidth: 0.5,
},
checkpointStyle: {
color: "#eb8146",
borderColor: "#ffb248",
},
label: {
color: "#893448",
},
emphasis: {
itemStyle: {
color: "#ffb248",
color: "#893448",
borderWidth: 1,
},
controlStyle: {
color: "#893448",
borderColor: "#893448",
borderWidth: 0.5,
},
checkpointStyle: {
color: "#eb8146",
borderColor: "#ffb248",
},
label: {
color: "#893448",
},
emphasis: {
itemStyle: {
color: "#ffb248",
},
controlStyle: {
color: "#893448",
borderColor: "#893448",
borderWidth: 0.5,
},
label: {
color: "#893448",
},
},
},
},
visualMap: {
color: [
"#893448",
"#d95850",
"#eb8146",
"#ffb248",
"#f2d643",
"rgb(247,238,173)",
],
},
dataZoom: {
backgroundColor: "rgba(255,255,255,0)",
dataBackgroundColor: "rgba(255,178,72,0.5)",
fillerColor: "rgba(255,178,72,0.15)",
handleColor: "#ffb248",
handleSize: "100%",
textStyle: {
color: "#333",
visualMap: {
color: [
"#893448",
"#d95850",
"#eb8146",
"#ffb248",
"#f2d643",
"rgb(247,238,173)",
],
},
},
markPoint: {
label: {
color: "#ffffff",
dataZoom: {
backgroundColor: "rgba(255,255,255,0)",
dataBackgroundColor: "rgba(255,178,72,0.5)",
fillerColor: "rgba(255,178,72,0.15)",
handleColor: "#ffb248",
handleSize: "100%",
textStyle: {
color: "#333",
},
},
emphasis: {
markPoint: {
label: {
color: "#ffffff",
},
emphasis: {
label: {
color: "#ffffff",
},
},
},
},
});
}))
);
};

View File

@@ -3,30 +3,37 @@
## Usage
```typescript
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
const { filters, addFilter, removeFilter, predicate } =
useCollectionFilter(options);
const filteredCollection = myCollection.filter(predicate);
const filteredCollection = computed(() => myCollection.filter(predicate));
addFilter("name:/^Foo/");
addFilter("count:>3");
```
## URL Query String
## Options
By default, when adding/removing filters, the URL will update automatically.
### `queryStringParam`
This option allows to activate the URL Query String support.
```typescript
addFilter('name:/^foo/i'); // Will update the URL with ?filter=name:/^foo/i
const { addFilter } = useCollectionFilter({ queryStringParam: "filter" });
addFilter("name:/^foo/i"); // Will update the URL with ?filter=name:/^foo/i
```
### Change the URL query string parameter name
### Initial filters
This option allows to set some initial filters.
```typescript
const { /* ... */ } = useCollectionFilter({ queryStringParam: 'f' }); // ?f=name:/^foo/i
const {
/* ... */
} = useCollectionFilter({ initialFilters: ["!name_label:foobar"] });
```
### Disable the usage of URL query string
```typescript
const { /* ... */ } = useCollectionFilter({ queryStringParam: undefined });
```
When using the `initialFilters` option with the `queryStringParam` option,
`initialFilters` will only be applied if no query string parameter is defined in the URL.
## Example of using the composable with the `CollectionFilter` component
@@ -38,32 +45,32 @@ const { /* ... */ } = useCollectionFilter({ queryStringParam: undefined });
@add-filter="addFilter"
@remove-filter="removeFilter"
/>
<div v-for="item in filteredCollection">...</div>
</template>
<script lang="ts" setup>
import CollectionFilter from "@/components/CollectionFilter.vue";
import useCollectionFilter from "@/composables/collection-filter.composable";
import { computed } from "vue";
import CollectionFilter from "@/components/CollectionFilter.vue";
import useCollectionFilter from "@/composables/collection-filter.composable";
import { computed } from "vue";
const collection = [
{ name: "Foo", age: 5, registered: true },
{ name: "Bar", age: 12, registered: false },
{ name: "Foo Bar", age: 2, registered: true },
{ name: "Bar Baz", age: 45, registered: false },
{ name: "Foo Baz", age: 32, registered: false },
{ name: "Foo Bar Baz", age: 32, registered: true },
];
const collection = [
{ name: "Foo", age: 5, registered: true },
{ name: "Bar", age: 12, registered: false },
{ name: "Foo Bar", age: 2, registered: true },
{ name: "Bar Baz", age: 45, registered: false },
{ name: "Foo Baz", age: 32, registered: false },
{ name: "Foo Bar Baz", age: 32, registered: true },
];
const availableFilters: AvailableFilter[] = [
{ property: "name", label: "Name", type: "string" },
{ property: "age", label: "Age", type: "number" },
{ property: "registered", label: "Registered", type: "boolean", icon: faKey },
];
const availableFilters: AvailableFilter[] = [
{ property: "name", label: "Name", type: "string" },
{ property: "age", label: "Age", type: "number" },
{ property: "registered", label: "Registered", type: "boolean", icon: faKey },
];
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
const filteredCollection = computed(() => collection.filter(predicate));
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
const filteredCollection = computed(() => collection.filter(predicate));
</script>
```

View File

@@ -1,25 +1,27 @@
import { getFirst } from "@/libs/utils";
import * as CM from "complex-matcher";
import { computed, ref, watch } from "vue";
import { type LocationQueryValue, useRoute, useRouter } from "vue-router";
interface Config {
queryStringParam?: string;
initialFilters?: string[];
}
export default function useCollectionFilter(
config: Config = { queryStringParam: "filter" }
) {
export default function useCollectionFilter<T>(config: Config = {}) {
const route = useRoute();
const router = useRouter();
const filtersSet = ref(
config.queryStringParam
? queryToSet(route.query[config.queryStringParam] as LocationQueryValue)
: new Set<string>()
);
const { queryStringParam, initialFilters = [] } = config;
const filtersSet = ref<Set<string>>(new Set(initialFilters));
const filters = computed(() => Array.from(filtersSet.value.values()));
if (config.queryStringParam) {
const queryStringParam = config.queryStringParam;
if (queryStringParam !== undefined) {
const queryString = route.query[queryStringParam];
if (queryString !== undefined) {
filtersSet.value = queryToSet(getFirst(queryString));
}
watch(filters, (value) =>
router.replace({
query: { ...route.query, [queryStringParam]: value.join(" ") },
@@ -35,7 +37,7 @@ export default function useCollectionFilter(
filtersSet.value.delete(filter);
};
const predicate = computed(() => {
const predicate = computed<(value: T) => boolean>(() => {
return CM.parse(
Array.from(filters.value.values()).join(" ")
).createPredicate();
@@ -49,7 +51,7 @@ export default function useCollectionFilter(
};
}
function queryToSet(query: LocationQueryValue): Set<string> {
function queryToSet(query?: LocationQueryValue): Set<string> {
if (!query) {
return new Set();
}

View File

@@ -0,0 +1,40 @@
# useCollectionSorter composable
## Usage
```typescript
const { sorts, addSort, removeSort, compareFn, toggleSortDirection } =
useCollectionSorter(options);
const sortedCollection = computed(() => myCollection.sort(compareFn));
addSort("name", true);
addSort("age", false);
```
## Options
### `queryStringParam`
This option allows to activate the URL Query String support.
```typescript
const { addSort } = useCollectionSorter({ queryStringParam: "sort" });
addSort("name", true); // Will update the URL with ?sort=name:1
```
### Initial sorts
This option allows to set some initial sorts.
Use `key` for ascending sort and `-key` for descending sort.
```typescript
const {
/* ... */
} = useCollectionSorter({
initialSorts: ["name", "-age"],
});
```
When using the `initialSorts` option with the `queryStringParam` option,
`initialSorts` will only be applied if no query string parameter is defined in the URL.

View File

@@ -1,52 +1,43 @@
import { getFirst } from "@/libs/utils";
import type { ActiveSorts, InitialSorts, SortConfig } from "@/types/sort";
import { computed, ref, watch } from "vue";
import { type LocationQueryValue, useRoute, useRouter } from "vue-router";
import type { ActiveSorts } from "@/types/sort";
interface Config {
queryStringParam?: string;
}
export default function useCollectionSorter(
config: Config = { queryStringParam: "sort" }
) {
export default function useCollectionSorter<T>(config: SortConfig<T> = {}) {
const route = useRoute();
const router = useRouter();
const { queryStringParam, initialSorts = [] } = config;
const sorts = ref<ActiveSorts<T>>(parseInitialSorts(initialSorts));
const sorts = ref<ActiveSorts>(
config.queryStringParam
? queryToMap(route.query[config.queryStringParam] as LocationQueryValue)
: new Map()
const sortsAsString = computed(() =>
Array.from(sorts.value)
.map(([property, isAsc]) => `${String(property)}:${isAsc ? "1" : "0"}`)
.join(",")
);
if (config.queryStringParam) {
const queryStringParam = config.queryStringParam;
watch(
sorts,
(value) =>
router.replace({
query: {
...route.query,
[queryStringParam]: Array.from(value)
.map(
([property, isAscending]) =>
`${property}:${isAscending ? "1" : "0"}`
)
.join(","),
},
}),
{ deep: true }
if (queryStringParam !== undefined) {
const queryString = route.query[queryStringParam];
if (queryString !== undefined) {
sorts.value = queryToMap(getFirst(queryString));
}
watch(sortsAsString, (value) =>
router.replace({
query: { ...route.query, [queryStringParam]: value },
})
);
}
const addSort = (property: string, isAscending: boolean) => {
const addSort = (property: keyof T, isAscending: boolean) => {
sorts.value.set(property, isAscending);
};
const removeSort = (property: string) => {
const removeSort = (property: keyof T) => {
sorts.value.delete(property);
};
const toggleSortDirection = (property: string) => {
const toggleSortDirection = (property: keyof T) => {
if (!sorts.value.has(property)) {
return;
}
@@ -55,7 +46,7 @@ export default function useCollectionSorter(
};
const compareFn = computed(() => {
return (record1: any, record2: any) => {
return (record1: T, record2: T) => {
for (const [property, isAscending] of sorts.value) {
const value1 = record1[property];
const value2 = record2[property];
@@ -82,7 +73,7 @@ export default function useCollectionSorter(
};
}
function queryToMap(query: LocationQueryValue) {
function queryToMap(query?: LocationQueryValue) {
if (!query) {
return new Map();
}
@@ -94,3 +85,14 @@ function queryToMap(query: LocationQueryValue) {
})
);
}
function parseInitialSorts<T>(sorts: InitialSorts<T>): ActiveSorts<T> {
return new Map(
sorts.map((sort) => {
const isDescending = sort.startsWith("-");
const property = (isDescending ? sort.substring(1) : sort) as keyof T;
return [property, !isDescending];
})
);
}

View File

@@ -1,6 +1,12 @@
import { computed, onUnmounted, ref } from "vue";
import { computed, onUnmounted, ref, type ComputedRef } from "vue";
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
import type { GRANULARITY, XapiStatsResponse } from "@/libs/xapi-stats";
import {
type GRANULARITY,
type HostStats,
RRD_STEP_FROM_STRING,
type VmStats,
type XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
@@ -17,21 +23,34 @@ export type Stat<T> = {
pausable: Pausable;
};
export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
type: "host" | "vm",
granularity: GRANULARITY
) {
export type FetchedStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
> = {
register: (object: T) => void;
unregister: (object: T) => void;
stats?: ComputedRef<Stat<S>[]>;
timestampStart?: ComputedRef<number>;
timestampEnd?: ComputedRef<number>;
};
export default function useFetchStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
>(type: "host" | "vm", granularity: GRANULARITY) {
const stats = ref<Map<string, Stat<S>>>(new Map());
const timestamp = ref<number[]>([0, 0]);
const register = (object: T) => {
if (stats.value.has(object.uuid)) {
stats.value.get(object.uuid)!.pausable.resume();
const mapKey = `${object.uuid}-${granularity}`;
if (stats.value.has(mapKey)) {
stats.value.get(mapKey)!.pausable.resume();
return;
}
const pausable = useTimeoutPoll(
async () => {
if (!stats.value.has(object.uuid)) {
if (!stats.value.has(mapKey)) {
return;
}
@@ -40,15 +59,21 @@ export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
granularity
)) as XapiStatsResponse<S>;
stats.value.get(object.uuid)!.stats = newStats.stats;
timestamp.value = [
newStats.endTimestamp -
RRD_STEP_FROM_STRING[granularity] *
(newStats.stats.memory.length - 1),
newStats.endTimestamp,
];
stats.value.get(mapKey)!.stats = newStats.stats;
await promiseTimeout(newStats.interval * 1000);
},
0,
{ immediate: true }
);
stats.value.set(object.uuid, {
stats.value.set(mapKey, {
id: object.uuid,
name: object.name_label,
stats: undefined,
@@ -57,8 +82,9 @@ export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
};
const unregister = (object: T) => {
stats.value.get(object.uuid)?.pausable.pause();
stats.value.delete(object.uuid);
const mapKey = `${object.uuid}-${granularity}`;
stats.value.get(mapKey)?.pausable.pause();
stats.value.delete(mapKey);
};
onUnmounted(() => {
@@ -69,5 +95,7 @@ export default function useFetchStats<T extends XenApiHost | XenApiVm, S>(
register,
unregister,
stats: computed<Stat<S>[]>(() => Array.from(stats.value.values())),
timestampStart: computed(() => timestamp.value[0]),
timestampEnd: computed(() => timestamp.value[1]),
};
}

View File

@@ -2,14 +2,17 @@
```vue
<script lang="ts" setup>
import useFilteredCollection from './filtered-collection.composable';
import useFilteredCollection from "./filtered-collection.composable";
const players = [
{ name: "Foo", team: "Blue" },
{ name: "Bar", team: "Red" },
{ name: "Baz", team: "Blue" },
]
const bluePlayers = useFilteredCollection(players, (player) => player.team === "Blue");
const players = [
{ name: "Foo", team: "Blue" },
{ name: "Bar", team: "Red" },
{ name: "Baz", team: "Blue" },
];
const bluePlayers = useFilteredCollection(
players,
(player) => player.team === "Blue"
);
</script>
```

View File

@@ -5,27 +5,28 @@
<div v-for="item in items">
{{ item.name }} <button @click="openRemoveModal(item)">Delete</button>
</div>
<UiModal v-if="isRemoveModalOpen">
Are you sure you want to delete {{ removeModalPayload.name }}
<button @click="handleRemove">Yes</button> <button @click="closeRemoveModal">No</button>
<button @click="handleRemove">Yes</button>
<button @click="closeRemoveModal">No</button>
</UiModal>
</template>
<script lang="ts" setup>
import useModal from '@/composables/modal.composable';
import useModal from "@/composables/modal.composable";
const {
payload: removeModalPayload,
isOpen: isRemoveModalOpen,
open: openRemoveModal,
close: closeRemoveModal,
} = useModal()
async function handleRemove() {
await removeItem(removeModalPayload.id);
closeRemoveModal()
}
const {
payload: removeModalPayload,
isOpen: isRemoveModalOpen,
open: openRemoveModal,
close: closeRemoveModal,
} = useModal();
async function handleRemove() {
await removeItem(removeModalPayload.id);
closeRemoveModal();
}
</script>
```

View File

@@ -4,34 +4,30 @@
<template>
<table>
<thead>
<tr>
<th>
<input type="checkbox" v-model="areAllSelected">
</th>
<th>Name</th>
</tr>
<tr>
<th>
<input type="checkbox" v-model="areAllSelected" />
</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items">
<td>
<input type="checkbox" :value="item.id" v-model="selected" />
</td>
<td>{{ item.name }}</td>
</tr>
<tr v-for="item in items">
<td>
<input type="checkbox" :value="item.id" v-model="selected" />
</td>
<td>{{ item.name }}</td>
</tr>
</tbody>
</table>
<!-- You can use something else than a "Select All" checkbox -->
<button @click="areAllSelected = !areAllSelected">Toggle all selected</button>
</template>
<script lang="ts" setup>
import useMultiSelect from './multi-select.composable';
import useMultiSelect from "./multi-select.composable";
const {
selected,
areAllSelected,
} = useMultiSelect()
const { selected, areAllSelected } = useMultiSelect();
</script>
```

View File

@@ -0,0 +1,18 @@
# useRelativeTime composable
## Usage
```ts
const relativeTime = useRelativeTime(fromDate, toDate);
console.log(relativeTime.value); // 3 days 27 minutes 10 seconds ago
```
# Reactivity
Both arguments can be `Ref`
```ts
const now = useNow();
const relativeTime = useRelativeTime(fromDate, now); // Value will be updated each time `now` changes
```

View File

@@ -0,0 +1,66 @@
import type { MaybeRef } from "@vueuse/core";
import { computed, unref } from "vue";
import { useI18n } from "vue-i18n";
export default function useRelativeTime(
fromDate: MaybeRef<Date>,
toDate: MaybeRef<Date>
) {
const { t } = useI18n();
const fromTime = computed(() => unref(fromDate).getTime());
const toTime = computed(() => unref(toDate).getTime());
const isPast = computed(() => toTime.value > fromTime.value);
const diff = computed(() => Math.abs(toTime.value - fromTime.value));
return computed(() => {
if (diff.value < 10000) {
return t("relative-time.now");
}
const years = Math.floor(diff.value / 31556952000);
let timeLeft = diff.value % 31556952000;
const months = Math.floor(timeLeft / 2629746000);
timeLeft = timeLeft % 2629746000;
const days = Math.floor(timeLeft / 86400000);
timeLeft = timeLeft % 86400000;
const hours = Math.floor(timeLeft / 3600000);
timeLeft = timeLeft % 3600000;
const minutes = Math.floor(timeLeft / 60000);
timeLeft = timeLeft % 60000;
const seconds = Math.floor(timeLeft / 1000);
const parts = [];
if (years > 0) {
parts.push(t("relative-time.year", { n: years }));
}
if (months > 0) {
parts.push(t("relative-time.month", { n: months }));
}
if (days > 0) {
parts.push(t("relative-time.day", { n: days }));
}
if (years === 0 && months === 0 && days <= 1 && hours > 0) {
parts.push(t("relative-time.hour", { n: hours }));
}
if (years === 0 && months === 0 && days === 0 && minutes > 0) {
parts.push(t("relative-time.minute", { n: minutes }));
}
if (years === 0 && months === 0 && days === 0 && seconds > 0) {
parts.push(t("relative-time.second", { n: seconds }));
}
return t(isPast.value ? "relative-time.past" : "relative-time.future", {
str: parts.join(" "),
});
});
}

View File

@@ -116,6 +116,20 @@ export function isHostRunning(host: XenApiHost) {
}
}
export function getHostMemory(host: XenApiHost) {
try {
const metrics = useHostMetricsStore().getRecord(host.metrics);
const total = +metrics.memory_total;
return {
usage: total - +metrics.memory_free,
size: total,
};
} catch (error) {
console.error("getHostMemory function:", error);
return undefined;
}
}
export const buildXoObject = (
record: RawXenApiRecord<XenApiRecord>,
params: { opaqueRef: string }
@@ -123,3 +137,40 @@ export const buildXoObject = (
...record,
$ref: params.opaqueRef,
});
export function parseRamUsage(
{
memory,
memoryFree,
}: {
memory: number[];
memoryFree?: number[];
},
{ nSequence = 4 } = {}
) {
const _nSequence = Math.min(memory.length, nSequence);
let total = 0;
let used = 0;
memory = memory.slice(memory.length - _nSequence);
memoryFree = memoryFree?.slice(memoryFree.length - _nSequence);
memory.forEach((ram, key) => {
total += ram;
used += ram - (memoryFree?.[key] ?? 0);
});
const percentUsed = percent(used, total);
return {
// In case `memoryFree` is not given by the xapi,
// we won't be able to calculate the percentage of used memory properly.
percentUsed:
memoryFree === undefined || isNaN(percentUsed) ? 0 : percentUsed,
total: total / _nSequence,
used: memoryFree === undefined ? 0 : used / _nSequence,
};
}
export const getFirst = <T>(value: T | T[]): T | undefined =>
Array.isArray(value) ? value[0] : value;

View File

@@ -30,7 +30,7 @@ export enum GRANULARITY {
Days = "days",
}
const RRD_STEP_FROM_STRING: { [key in GRANULARITY]: RRD_STEP } = {
export const RRD_STEP_FROM_STRING: { [key in GRANULARITY]: RRD_STEP } = {
[GRANULARITY.Seconds]: RRD_STEP.Seconds,
[GRANULARITY.Minutes]: RRD_STEP.Minutes,
[GRANULARITY.Hours]: RRD_STEP.Hours,
@@ -259,7 +259,7 @@ export type VmStats = {
w: Record<string, number[]>;
};
memory: number[];
memoryFree: number[];
memoryFree?: number[];
vifs: {
rx: Record<string, number[]>;
tx: Record<string, number[]>;
@@ -365,7 +365,7 @@ export default class XapiStats {
`Unknown granularity: '${granularity}'. Use 'seconds', 'minutes', 'hours', or 'days'.`
);
}
const currentTimeStamp = await this.#xapi.getHostServertime(host);
const currentTimeStamp = Math.floor(new Date().getTime() / 1000);
const stats = this.#getCachedStats(uuid, step, currentTimeStamp);
if (stats !== undefined) {

View File

@@ -1,5 +1,6 @@
import { JSONRPCClient } from "json-rpc-2.0";
import { buildXoObject, parseDateTime } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
export type RawObjectType =
| "Bond"
@@ -69,6 +70,7 @@ export interface XenApiRecord {
export type RawXenApiRecord<T extends XenApiRecord> = Omit<T, "$ref">;
export interface XenApiPool extends XenApiRecord {
master: string;
name_label: string;
}
@@ -77,6 +79,7 @@ export interface XenApiHost extends XenApiRecord {
name_label: string;
metrics: string;
resident_VMs: string[];
cpu_info: { cpu_count: string };
}
export interface XenApiSr extends XenApiRecord {
@@ -86,6 +89,7 @@ export interface XenApiSr extends XenApiRecord {
}
export interface XenApiVm extends XenApiRecord {
current_operations: Record<string, string>;
name_label: string;
name_description: string;
power_state: PowerState;
@@ -199,6 +203,9 @@ export default class XenApi {
return this.#client.request(method, args);
}
_call = (method: string, args: any[] = []) =>
this.#call(method, [this.sessionId, ...args]);
async getHostServertime(host: XenApiHost) {
const serverLocaltime = (await this.#call("host.get_servertime", [
this.sessionId,
@@ -281,4 +288,83 @@ export default class XenApi {
poolRef,
]);
}
get vm() {
type VmsRef =
| {
vmRef: XenApiVm["$ref"];
vmsRef?: undefined;
}
| {
vmRef?: undefined;
vmsRef: XenApiVm["$ref"][];
};
return {
start: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.start", [vmRef, false, false]))
);
},
startOn: ({ vmRef, vmsRef, hostRef }: VmsRef & { hostRef: string }) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) =>
this._call("VM.start_on", [vmRef, hostRef, false, false])
)
);
},
pause: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.pause", [vmRef]))
);
},
suspend: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.suspend", [vmRef]))
);
},
resume: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
const vmStore = useVmStore();
return Promise.all(
_vmsRef.map((ref) => {
const isSuspended =
vmStore.getRecord(ref).power_state === "Suspended";
return this._call(
`VM.${isSuspended ? "resume" : "unpause"}`,
isSuspended ? [ref, false, false] : [ref]
);
})
);
},
reboot: ({
vmRef,
vmsRef,
force = false,
}: VmsRef & { force?: boolean }) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) =>
this._call(`VM.${force ? "hard" : "clean"}_reboot`, [vmRef])
)
);
},
shutdown: ({
vmRef,
vmsRef,
force = false,
}: VmsRef & { force?: boolean }) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) =>
this._call(`VM.${force ? "hard" : "clean"}_shutdown`, [vmRef])
)
);
},
};
}
}

View File

@@ -4,54 +4,92 @@
"add-or": "+OR",
"add-sort": "Add sort",
"alarms": "Alarms",
"allow-self-signed-ssl":"You may need to allow self-signed SSL certificates in your browser",
"allow-self-signed-ssl": "You may need to allow self-signed SSL certificates in your browser",
"appearance": "Appearance",
"ascending": "ascending",
"available-properties-for-advanced-filter": "Available properties for advanced filter:",
"back-pool-dashboard": "Go back to your Pool dashboard",
"backup": "Backup",
"cancel": "Cancel",
"change-power-state": "Change power state",
"change-state": "Change state",
"community": "Community",
"community-name": "{name} community",
"copy": "Copy",
"cpu-usage":"CPU usage",
"dark-mode": "Dark mode",
"cpu-usage": "CPU usage",
"dashboard": "Dashboard",
"delete": "Delete",
"descending": "descending",
"display": "Display",
"edit-config": "Edit config",
"error-occured": "An error has occurred",
"export": "Export",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
"following-hosts-unreachable": "The following hosts are unreachable",
"force-reboot": "Force reboot",
"force-shutdown": "Force shutdown",
"hosts": "Hosts",
"language": "Language",
"following-hosts-unreachable":"The following hosts are unreachable",
"last-week": "Last week",
"loading-hosts": "Loading hosts…",
"log-out": "Log out",
"login": "Login",
"migrate": "Migrate",
"network": "Network",
"network-download": "Download",
"network-throughput": "Network throughput",
"network-upload": "Upload",
"news": "News",
"news-name": "{name} news",
"object-not-found": "Object {id} can't be found…",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
"password-invalid": "Password invalid",
"pause": "Pause",
"pool-cpu-usage": "Pool CPU Usage",
"pool-ram-usage": "Pool RAM Usage",
"property": "Property",
"ram-usage": "RAM usage",
"reboot": "Reboot",
"relative-time": {
"day": "1 day | {n} days",
"future": "In {str}",
"hour": "1 hour | {n} hours",
"minute": "1 minute | {n} minutes",
"month": "1 month | {n} months",
"now": "Just now",
"past": "{str} ago",
"second": "1 second | {n} seconds",
"year": "1 year | {n} years"
},
"resume": "Resume",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
"shutdown": "Shutdown",
"snapshot": "Snapshot",
"sort-by": "Sort by",
"stacked-cpu-usage": "Stacked CPU usage",
"stacked-ram-usage": "Stacked memory usage",
"start": "Start",
"start-on-host": "Start on specific host",
"stats": "Stats",
"status": "Status",
"storage": "Storage",
"storage-usage": "Storage usage",
"suspend": "Suspend",
"switch-theme": "Switch theme",
"system": "System",
"tasks": "Tasks",
"theme-auto": "Auto",
"theme-dark": "Dark",
"theme-light": "Light",
"top-#": "Top {n}",
"total-free": "Total free",
"total-used": "Total used",
"unreachable-hosts": "Unreachable hosts",
"unreachable-hosts-reload-page": "Done, reload the page",
"version": "Version",
"vms": "VMs"
}

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