Compare commits

...

128 Commits

Author SHA1 Message Date
Julien Fontanet
d6895e7288 WiP 2023-02-09 10:31:14 +01:00
Julien Fontanet
e279ea01a2 WiP 2023-02-09 10:30:41 +01:00
Julien Fontanet
8df308aa01 feat(xen-api/{get,put}Resource): add 24h timeout 2023-02-09 10:30:01 +01:00
Julien Fontanet
a3e37eca62 fix(xo-server): disable broken requestTimeout
Fixes https://xcp-ng.org/forum/post/58146

Caused by nodejs/node#46574

It caused requests to timeout after 0-30 seconds, which broke all uploads.
2023-02-09 10:25:45 +01:00
Julien Fontanet
817911a41e fix(xen-api): fix task watchers when initially not watching events (2)
Introduced by 9f4fce9da
2023-02-08 17:11:27 +01:00
Julien Fontanet
9f4fce9daa fix(xen-api): fix task watchers when initially not watching events
Introduced by bc61dd85c
2023-02-08 12:21:56 +01:00
Julien Fontanet
9ff305d5db fix(xo-server/rest-api): fix VDI import
Introduced by ab0e411ac
2023-02-07 19:20:59 +01:00
Julien Fontanet
055c3e098f fix(xen-api/putResource): better error handling
Tested all combinaisons with the following conditions:

- success, cancelation and connection loss (ECONNRESET)
- with and without tasks watching
- with and without known length (i.e. content-length hack)
2023-02-07 17:38:01 +01:00
Julien Fontanet
bc61dd85c6 fix(xen-api): correctly handle not watching tasks 2023-02-07 17:23:57 +01:00
Julien Fontanet
db6f1405e9 chore(xo-server/rest-api): match export routes first 2023-02-07 16:36:05 +01:00
Gabriel Gunullu
3dc3376aec chore(test): replace vhd-util check (#6651) 2023-02-07 13:33:25 +01:00
Julien Fontanet
55920a58a3 feat(xo-server/recover-account): -s flag for xoa-support
Simpler process for xoa-support.

```console
$ xo-server-recover-account -s
The generated password is lXJMtCzWDGPOIg
user xoa-support has been successfully updated
```
2023-02-06 15:25:04 +01:00
Julien Fontanet
2a70ebf667 docs: uniformize code blocks
- add missing syntaxes
- don't put prompt if no command outputs to ease copy/paste and use `sh` syntax
- always use `$` as prompt and use `console` syntax
2023-02-06 11:25:12 +01:00
Julien Fontanet
2f65a86aa0 fix(xen-api/putResource): fix a number of issues
- hide `VDI_IO_ERROR` when using content-length hack
- avoid unhandled rejection in case upload fails
2023-02-06 10:40:42 +01:00
Julien Fontanet
4bf81ac33b docs(xapi): fix typo 2023-02-04 11:14:02 +01:00
Julien Fontanet
263c23ae8f docs(xapi): describe syncHookTimeout 2023-02-04 11:11:41 +01:00
Julien Fontanet
bf51b945c5 chore(vmware-explorer): fix lint issues
Introduced by 9fa15d9c8
2023-02-03 16:36:55 +01:00
Julien Fontanet
9d7a461550 feat(turbo): add dev and test tasks 2023-02-03 16:17:52 +01:00
Julien Fontanet
bbf60818eb chore: update dev deps 2023-02-03 16:17:31 +01:00
Julien Fontanet
103b22ebb2 fix(backups/importDeltaVm): resize cloned VDI if necessary
Fixes zammad#10996
2023-02-03 15:49:08 +01:00
Mathieu
cf4a1d7d40 fix(lite): update stacked ram usage message (#6650) 2023-02-02 11:50:10 +01:00
Julien Fontanet
e94f036aca chore(vmware-explorer): lower requirement to Node 14 2023-02-02 09:43:03 +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
290 changed files with 7971 additions and 3618 deletions

1
.gitignore vendored
View File

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

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/async-each):
```
> npm install --save @vates/async-each
```sh
npm install --save @vates/async-each
```
## Usage

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

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/cached-dns.lookup):
```
> npm install --save @vates/cached-dns.lookup
```sh
npm install --save @vates/cached-dns.lookup
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/coalesce-calls):
```
> npm install --save @vates/coalesce-calls
```sh
npm install --save @vates/coalesce-calls
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/compose):
```
> npm install --save @vates/compose
```sh
npm install --save @vates/compose
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/decorate-with):
```
> npm install --save @vates/decorate-with
```sh
npm install --save @vates/decorate-with
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/disposable):
```
> npm install --save @vates/disposable
```sh
npm install --save @vates/disposable
```
## Usage

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.3",
"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.5.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

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/event-listeners-manager):
```
> npm install --save @vates/event-listeners-manager
```sh
npm install --save @vates/event-listeners-manager
```
## Usage

View File

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

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/multi-key-map):
```
> npm install --save @vates/multi-key-map
```sh
npm install --save @vates/multi-key-map
```
## Usage

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/nbd-client):
```
> npm install --save @vates/nbd-client
```sh
npm install --save @vates/nbd-client
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/otp):
```
> npm install --save @vates/otp
```sh
npm install --save @vates/otp
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/parse-duration):
```
> npm install --save @vates/parse-duration
```sh
npm install --save @vates/parse-duration
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/predicates):
```
> npm install --save @vates/predicates
```sh
npm install --save @vates/predicates
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/read-chunk):
```
> npm install --save @vates/read-chunk
```sh
npm install --save @vates/read-chunk
```
## Usage

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):
```sh
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

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@vates/toggle-scripts):
```
> npm install --save @vates/toggle-scripts
```sh
npm install --save @vates/toggle-scripts
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/async-map):
```
> npm install --save @xen-orchestra/async-map
```sh
npm install --save @xen-orchestra/async-map
```
## Usage

View File

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

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/audit-core):
```
> npm install --save @xen-orchestra/audit-core
```sh
npm install --save @xen-orchestra/audit-core
```
## Contributions

View File

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

View File

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/backups-cli):
```
> npm install --global @xen-orchestra/backups-cli
```sh
npm install --global @xen-orchestra/backups-cli
```
## Usage

View File

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

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

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/backups):
```
> npm install --save @xen-orchestra/backups
```sh
npm install --save @xen-orchestra/backups
```
## Contributions

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) {
@@ -641,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,
@@ -659,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() {
@@ -669,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,
@@ -683,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

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

@@ -258,6 +258,9 @@ exports.importDeltaVm = defer(async function importDeltaVm(
$defer.onFailure(() => newVdi.$destroy())
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
if (vdi.virtual_size > newVdi.virtual_size) {
await newVdi.$callAsync('resize', vdi.virtual_size)
}
} else if (vdiRef === vmRecord.suspend_VDI) {
// suspendVDI has already created
newVdi = suspendVdi

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.2",
"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.3",
"@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.3.0",
"@xen-orchestra/log": "^0.5.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.2.0",
"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.3"
"@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,7 +45,7 @@ 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 metadata = {
jobId: job.id,

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

@@ -8,8 +8,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cr-seed-cli):
```
> npm install --global @xen-orchestra/cr-seed-cli
```sh
npm install --global @xen-orchestra/cr-seed-cli
```
## Contributions

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cron):
```
> npm install --save @xen-orchestra/cron
```sh
npm install --save @xen-orchestra/cron
```
## Usage

View File

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

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/defined):
```
> npm install --save @xen-orchestra/defined
```sh
npm install --save @xen-orchestra/defined
```
## Contributions

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/emit-async):
```
> npm install --save @xen-orchestra/emit-async
```sh
npm install --save @xen-orchestra/emit-async
```
## Usage

View File

@@ -10,8 +10,8 @@
Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/fs):
```
> npm install --global @xen-orchestra/fs
```sh
npm install --global @xen-orchestra/fs
```
## Contributions

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "3.3.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.5.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) => {

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

@@ -6,6 +6,11 @@
- 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

@@ -49,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,7 @@
</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";
@@ -42,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";
@@ -105,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>
@@ -12,6 +18,17 @@
<script lang="ts" setup>
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

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

@@ -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,13 +1,14 @@
<template>
<div>
<div class="header">
<slot name="header" />
</div>
<div v-if="data !== undefined">
<template v-if="data !== undefined">
<div
v-for="item in computedData.sortedArray"
:key="item.id"
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">
@@ -18,10 +19,8 @@
}}</UiBadge>
</div>
</div>
<div class="footer">
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
</div>
</div>
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
</template>
<UiSpinner v-else class="spinner" />
</div>
</template>
@@ -45,6 +44,9 @@ interface Props {
nItems?: number;
}
const MIN_WARNING_VALUE = 80;
const MIN_DANGEROUS_VALUE = 90;
const props = defineProps<Props>();
const computedData = computed(() => {
@@ -67,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;
@@ -106,12 +91,6 @@ const computedData = computed(() => {
font-weight: 700;
}
.progress-item {
--progress-bar-height: 1.2rem;
--progress-bar-color: var(--color-extra-blue-l20);
--progress-bar-background-color: var(--color-blue-scale-400);
}
.progress-item:nth-child(1) {
--progress-bar-color: var(--color-extra-blue-d60);
}
@@ -124,6 +103,18 @@ const computedData = computed(() => {
--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;

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

@@ -1,6 +1,6 @@
<template>
<UiCard>
<UiTitle type="h4">{{ $t("ram-usage") }}</UiTitle>
<UiCardTitle>{{ $t("ram-usage") }}</UiCardTitle>
<HostsRamUsage />
<VmsRamUsage />
</UiCard>
@@ -10,5 +10,5 @@
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 UiTitle from "@/components/ui/UiTitle.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,56 +1,31 @@
<template>
<UiCard>
<UiTitle type="h4">{{ $t("storage-usage") }}</UiTitle>
<UsageBar :data="srStore.isReady ? data.result : undefined" :nItems="N_ITEMS">
<template #header>
<span>{{ $t("storage") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</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;
@@ -85,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,19 +1,20 @@
<template>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("hosts") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</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,13 +1,14 @@
<template>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("vms") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</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";

View File

@@ -1,13 +1,14 @@
<template>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("hosts") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</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 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";

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

@@ -1,13 +1,14 @@
<template>
<UsageBar :data="statFetched ? data : undefined" :n-items="N_ITEMS">
<template #header>
<span>{{ $t("vms") }}</span>
<span>{{ $t("top-#", { n: N_ITEMS }) }}</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";

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

@@ -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,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,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,34 +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
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
} = 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

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

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

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