Compare commits

...

136 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
24f4e15cc6 feat(@xen-orchestra/backups): use parallel reading whith NBD + blocks to only one remote 2023-05-17 14:35:04 +02:00
Julien Fontanet
769e27e2cb feat: technical release 2023-05-16 16:32:33 +02:00
Julien Fontanet
8ec5461338 feat(xo-server): 5.114.2 2023-05-16 16:31:54 +02:00
Julien Fontanet
4a2843cb67 feat(@xen-orchestra/proxy): 0.26.23 2023-05-16 16:31:33 +02:00
Julien Fontanet
a0e69a79ab feat(xen-api): 1.3.1 2023-05-16 16:30:54 +02:00
Roni Väyrynen
3da94f18df docs(installation): add findmnt command to sudoers config example (#6835) 2023-05-16 15:20:47 +02:00
Mathieu
17cb59b898 feat(xo-web/host-item): display warning for when HVM disabled (#6834) 2023-05-16 14:58:14 +02:00
Mathieu
315e5c9289 feat(xo-web/proxy): make proxy address editable (#6816) 2023-05-16 12:12:31 +02:00
Julien Fontanet
01ba10fedb fix(xen-api/putResource): really fix (302) redirection with non-stream body
Replaces the incorrect fix in 87e6f7fde

Introduced by ab96c549a

Fixes zammad#13375
Fixes zammad#13952
Fixes zammad#14001
2023-05-15 16:23:18 +02:00
Mathieu
13e7594560 fix(xo-web/SortedTable): handle pending state for collapsed actions (#6831) 2023-05-15 15:27:17 +02:00
Thierry Goettelmann
f9ac2ac84d feat(lite/tooltips): enhance and simplify tooltips (#6760)
- Removed the `disabled` option.
- The tooltip is now disabled when content is an empty string or `false`.
- If content is `true` or `undefined`, it will be extracted from element's `innerText`.
- Moved `v-tooltip` from `InfraHostItem` and `InfraVmItem` to `InfraItemLabel`.
2023-05-15 11:55:43 +02:00
Thierry Goettelmann
09cfac1111 feat(lite): enhance Component Story skeleton generator (#6753)
- Updated form to use our own components
- Added a warning for props whose type cannot be extracted
- Fixed setting name for scopes containing a dash
- Handled cases when a prop can be multiple types
- Better guess of prop type
- Remove `.widget()` for `.model()`
- Remove `.event('update:modelValue')` for `.model()`
2023-05-15 11:23:42 +02:00
Thierry Goettelmann
008f7a30fd feat(lite): add VM tab bar (#6766) 2023-05-15 11:15:52 +02:00
Thierry Goettelmann
ff65dbcba7 feat(lite): extract and update "unreachable hosts modal" (#6745)
Extraction of unreachable host modal to its own component + Move the subtitle to the description.

Refer to #6744 for final design.
2023-05-15 11:11:19 +02:00
ggunullu
264a0d1678 fix(@vates/nbd-client): add custom coverage threshold to tap test
By default, Tap require 100 % coverage of all lines, branches, functions and statements.
We enforce a custom threshold to match the current state of the state and avoid regression.

See https://github.com/vatesfr/xen-orchestra/actions/runs/4956232764/jobs/8866437368
2023-05-15 10:18:02 +02:00
ggunullu
7dcaf454ed fix(eslint): treat *.integ.js as test files
Introduced by 3f73138fc3
2023-05-15 10:18:02 +02:00
Julien Fontanet
17b2756291 feat: release 5.82.1 2023-05-12 16:47:21 +02:00
Julien Fontanet
57e48b5d34 feat: technical release 2023-05-12 15:40:38 +02:00
Julien Fontanet
57ed984e5a feat(xo-web): 5.117.1 2023-05-12 15:40:16 +02:00
Julien Fontanet
100122f388 feat(xo-server): 5.114.1 2023-05-12 15:39:36 +02:00
Julien Fontanet
12d4b3396e feat(@xen-orchestra/proxy): 0.26.22 2023-05-12 15:39:16 +02:00
Julien Fontanet
ab35c710cb feat(@xen-orchestra/backups): 0.36.1 2023-05-12 15:38:46 +02:00
Florent BEAUCHAMP
4bd5b38aeb fix(backups): fix health check task during CR (#6830)
Fixes https://xcp-ng.org/forum/post/62073

`healthCheck` is launched after `cleanVm`, therefore it should be closing the parent task, not `cleanVm`.
2023-05-12 10:45:32 +02:00
Julien Fontanet
836db1b807 fix(xo-web/new/network): correct type for vlan (#6829)
BREAKING CHANGE: API method `network.create` no longer accepts a `string` for `vlan` param.

Fixes https://xcp-ng.org/forum/post/62090

Either `number` or `undefined`, not an empty string.
2023-05-12 10:36:59 +02:00
Julien Fontanet
73d88cc5f1 fix(xo-server/vm.convertToTemplate): handle VBD_IS_EMPTY (#6808)
Fixes https://xcp-ng.org/forum/post/61653
2023-05-12 09:12:41 +02:00
Julien Fontanet
3def66d968 chore(xo-vmdk-to-vhd): move notes.md to docs/
So that it will be correctly ignored when publishing the package.
2023-05-12 09:10:00 +02:00
Gabriel Gunullu
3f73138fc3 fix(test-integration): run integration tests only in ci (#6826)
Fixes issues introduced by

- be6233f
- adc5e7d

After the switching from Jest to Tap/Test, those tests were no longer executed during the test-integration script.
2023-05-11 17:47:48 +02:00
Julien Fontanet
bfe621a21d feat: technical release 2023-05-11 14:35:15 +02:00
Julien Fontanet
32fa792eeb feat(xo-web): 5.117.0 2023-05-11 14:23:02 +02:00
Julien Fontanet
a833050fc2 feat(xo-server): 5.114.0 2023-05-11 14:17:40 +02:00
Julien Fontanet
e7e6294bc3 feat(xo-vmdk-to-vhd): 2.5.4 2023-05-11 14:09:23 +02:00
Julien Fontanet
7c71884e27 feat(@vates/task): 0.1.2 2023-05-11 14:03:57 +02:00
Florent BEAUCHAMP
3e822044f2 fix(xo-vmdk-to-vhd): wait for OVA stream to be written before reading more data (#6800) 2023-05-11 12:23:06 +02:00
Julien Fontanet
d457f5fca4 chore(xo-server): use Task.run() helper 2023-05-11 11:10:00 +02:00
Julien Fontanet
1837e01719 fix(xo-server): new Task() now expects data instead of name option
Introduced by 036f3f6bd
2023-05-11 11:08:31 +02:00
Julien Fontanet
f17f5abf0f fix(xo-server/pif.reconfigureIp): accepts empty strings for dns, gateway, ip and netmask params 2023-05-11 09:08:05 +02:00
Florent BEAUCHAMP
82c229c755 fix(xo-server): better handling of importing running VM from ESXi (#6825)
Fixes https://xcp-ng.org/forum/post/59879

Fixes `Cannot read properties of undefined (reading 'stream')` error message
2023-05-10 18:25:37 +02:00
Julien Fontanet
c7e3ba3184 feat(xo-web/plugins): names can be clicked to filter out other plugins 2023-05-10 17:40:11 +02:00
Thierry Goettelmann
470c9bb6c8 fix(lite): handle escape key on CollectionFilter and CollectionSorter modals (#6822)
UiModal `@close` event was not defined on `CollectionFilter` and `CollectionSorter` modals.
2023-05-10 14:44:30 +02:00
Thierry Goettelmann
bb3ab20b2a fix(lite): typo in component name (#6821) 2023-05-10 10:11:06 +02:00
Julien Fontanet
90ce1c4d1e test(task/combineEvents): initial unit tests 2023-05-09 15:16:41 +02:00
Julien Fontanet
5c436f3870 fix(task/combineEvents): defineProperty → defineProperties
Fixes zammad#14566
2023-05-09 15:12:12 +02:00
Mathieu
159339625d feat(xo-server/vm.create): add resourceSet tags to created VM (#6812) 2023-05-09 14:33:59 +02:00
Julien Fontanet
87e6f7fded fix(xen-api/putResource): fix (302) redirection with non-stream body
Fixes zammad#13375
Fixes zammad#13952
Fixes zammad#14001
2023-05-09 14:09:33 +02:00
Pierre Donias
fd2c7c2fc3 fix(CHANGELOG): fix version number (#6805) 2023-04-28 14:52:44 +02:00
Mathieu
7fc76c1df4 feat: release 5.82 (#6804) 2023-04-28 14:32:01 +02:00
Mathieu
f2758d036d feat: technical release (#6803) 2023-04-28 13:28:15 +02:00
Pierre Donias
ac670da793 fix(xo-web/host/smart reboot): XOA Premium only (#6801)
See #6795
2023-04-28 11:15:28 +02:00
Mathieu
c0465eb4d9 feat: technical release (#6799) 2023-04-27 15:12:42 +02:00
Gabriel Gunullu
cea55b03e5 feat(xo-web/kubernetes): add high availability option (#6794)
See xoa#117
2023-04-27 13:54:58 +02:00
Julien Fontanet
d78d802066 fix(xo-server/rest-api): list tasks in the root collection
Introduced by 9e60c5375
2023-04-27 09:20:12 +02:00
Florent BEAUCHAMP
a562c74492 feat(backups/health check): support custom checks via XenStore (#6784) 2023-04-27 09:02:00 +02:00
Julien Fontanet
d1f2e0a84b fix(task): fix start event and add unit tests
Introduced by 6ea671a43
2023-04-26 17:29:35 +02:00
Mathieu
49e2d128ad feat: technical release (#6796) 2023-04-26 15:45:14 +02:00
Pierre Donias
f587798fb0 feat(xo-web/host): Smart Reboot (#6795)
Fixes #6750
See https://xcp-ng.org/forum/topic/7136
See #6791

Suspend resident VMs, restart host and resume VMs
2023-04-26 11:37:24 +02:00
Florent BEAUCHAMP
3430ee743b feat(xapi/VDI_exportContent) : implement NBD block level retry (#6763) 2023-04-26 11:32:38 +02:00
Thierry Goettelmann
83299587b0 feat(lite): subscriptions to XAPI collections (#6697) 2023-04-25 17:11:57 +02:00
Julien Fontanet
7c0ecf9b06 feat(xo-server/rest-api): stream objects
- less memory usage on big collections
- starts streaming as soon as possible instead of waiting for all the objects
2023-04-25 16:46:02 +02:00
Gabriel Gunullu
abfd84d32c chore(xo-web/kubernetes): rename 'master' to 'control plane' (#6789)
See xoa#116
2023-04-25 16:26:58 +02:00
Mathieu
0583a978be feat(xo-web/VM): display creator email and template name (#6771)
See xoa-support #13064 & #13094
2023-04-25 16:19:07 +02:00
Florent BEAUCHAMP
75989cf92d fix(backups/restore): fix boolean/int cast for backups created on XS<7.1 (#6772) 2023-04-25 10:26:06 +02:00
Julien Fontanet
f1cc284b6f feat(xo-server/host.restart): suspendResidentVms param (#6791)
Related to #6750
2023-04-25 10:22:40 +02:00
Julien Fontanet
0444cf0b3b fix(backups): disable VHD stream validation by default
Introduced by 68b2c287e

Fixes https://xcp-ng.org/forum/post/61118

Current code is too heavy on the main thread, disable by default until a better solution is fixed.
2023-04-24 16:53:04 +02:00
Julien Fontanet
226f9ad964 feat(PULL_REQUEST_TEMPLATE): simpler and easier to use
Co-authored-by: Pierre Donias <pierre.donias@gmail.com>
2023-04-24 10:53:28 +02:00
Julien Fontanet
a956cb2ac9 feat(xo-server/xo.exportConfig): new compress=true param 2023-04-21 17:12:13 +02:00
Julien Fontanet
76a91cc5e9 feat(xo-cli): @=- can now read from stdin 2023-04-21 16:56:36 +02:00
Julien Fontanet
f012d126b9 fix(xo-cli --list-commands): params with default values are optional 2023-04-21 10:27:05 +02:00
Julien Fontanet
bae0b52893 fix(CHANGELOG.unreleased): release xen-api
Introduced by a24512cea
2023-04-21 10:19:17 +02:00
Julien Fontanet
a24512cea9 feat(xen-api): configurable transport
Related to zammad#14008

Possible value for the `transport` setting: `auto`, `json-rpc`, `xml-rpc`.
2023-04-20 17:36:32 +02:00
Gabriel Gunullu
84b75e8a58 fix(xo-web/kubernetes-recipe): add DNS config (#6678)
See xoa#114
2023-04-20 09:43:32 +02:00
Julien Fontanet
6e25b7a83a chore(backups/importDeltaVm): add suspend_VDI to warning when missing 2023-04-19 17:38:20 +02:00
Julien Fontanet
136718df7e fix(backups/importDeltaVm): handle suspend_VDI === 'OpaqueRef:NULL'
Fixes https://xcp-ng.org/forum/post/61169

Introduced by 4d55c5ae4
2023-04-19 17:38:20 +02:00
Julien Fontanet
d48ef1f810 feat(xo-cli): subcommands to interact with the REST API 2023-04-19 16:23:53 +02:00
Julien Fontanet
9e60c53750 feat(xo-server/rest-api): tasks integration 2023-04-19 16:23:53 +02:00
Julien Fontanet
f3c5e817a3 feat(mixins/Tasks): db, log consolidation, abortion & watch 2023-04-19 16:23:53 +02:00
Julien Fontanet
60f6e54da1 feat(task): combineEvents 2023-04-19 16:23:53 +02:00
Julien Fontanet
f5a59caca2 docs(task): README improvements 2023-04-19 16:23:53 +02:00
Julien Fontanet
6ea671a434 feat(task): remove aborting status
It's unnecessary and it complicates tests.
2023-04-19 16:23:53 +02:00
Julien Fontanet
036f3f6bd0 feat(task): any data can be attached to the task
Previously, only `name` was accepted.
2023-04-19 16:23:53 +02:00
Julien Fontanet
12552a1391 feat(task): put async storage in the global scope 2023-04-19 16:23:53 +02:00
Julien Fontanet
e9b658b60d chore(task): remove unnecessary prop 2023-04-19 16:23:53 +02:00
Florent BEAUCHAMP
15f69a19f5 fix(xo-server/OVA import): revert to tar-stream@2 (#6779)
Fixes https://xcp-ng.org/forum/post/60648

Introduced by 656d13d

tar-stream@3 uses different kind of streams (`streamx`) which breaks `stream.read(size)`.
2023-04-18 14:38:18 +02:00
Julien Fontanet
54d885fa9c Revert "feat(backups/writeVhd): check file has expected size (#6703)" (#6773)
This reverts commit 77b1adae37.
2023-04-17 13:53:54 +02:00
Mathieu
11cc299940 feat(xo-web/dashboard/health): add free space column for storage state (#6778)
See xoa-support#13538
2023-04-14 11:10:38 +02:00
Thierry Goettelmann
091b0a3ef3 fix(lite/component-stories): replace markdown-it with marked + ComponentStory.vue fix (#6733) 2023-04-13 09:02:22 +02:00
Julien Fontanet
87874a4b81 chore(xo-server/addApiMethod(s)): compatible signature with proxy.api.addMethod(s)
First step toward mutualization, it allows shared mixins to easily add API methods in both apps.
2023-04-12 16:10:12 +02:00
Julien Fontanet
86aaa50946 feat(mixins/Hooks): start* listeners can return teardown functions 2023-04-12 15:59:38 +02:00
Julien Fontanet
68b2c287eb feat(backups): validate VHD streams (#6770) 2023-04-12 12:05:55 +02:00
Thierry Goettelmann
61f1316c42 feat(lite): revamp UiTable (#6742) 2023-04-12 10:19:43 +02:00
Julien Fontanet
afadc8f95a fix(read-chunk): better not enough data error message 2023-04-08 10:53:05 +02:00
Julien Fontanet
955ef6806c fix(read-chunk): handle already errored streams 2023-04-08 10:52:28 +02:00
Julien Fontanet
4d55c5ae48 fix(backups): restore VM with memory as suspended (#6774)
Fixes #5061
2023-04-07 16:02:52 +02:00
Julien Fontanet
5c6ae1912b feat(xo-server/vm.convertToTemplate): eject removable medias (#6769)
Fixes #6752
2023-04-07 10:12:02 +02:00
Gabriel Gunullu
083483645e fix(xo-server-usage-report): change dataset size (#6723)
Fixes zammad#12215
2023-04-06 17:33:24 +02:00
Thierry Goettelmann
c077e9a699 fix(lite): loading status (#6767) 2023-04-06 10:10:58 +02:00
Mathieu
280b60808f fix(xo-web/backup): maxExportRate invalid parameters (#6768)
Introduced by dc6a13962f
2023-04-05 15:53:40 +02:00
aknisly
eb9608b893 docs(backups): clarify retention strategies (#6743) 2023-04-05 12:32:57 +02:00
Julien Fontanet
a29f3d67ea chore: update dev deps 2023-04-05 11:40:25 +02:00
Julien Fontanet
6b150dc8a8 docs: fix custom container syntax 2023-04-05 11:24:07 +02:00
Julien Fontanet
8f55884602 chore: format with Prettier 2023-04-05 11:22:10 +02:00
Alex
2fdba2eb0f docs(users): clarify GitHub setup (#6751) 2023-04-05 11:12:53 +02:00
Mathieu
7e4bd30f04 fix(lite/treeview): fix host highlighting (#6747)
From a VM view, after selecting an host, the previously selected host remains highlighted
2023-04-04 12:16:52 +02:00
Julien Fontanet
eb8f098aaf feat(xo-server/vm.create): store creation date/template/user (#6731) 2023-04-04 09:34:04 +02:00
Mathieu
5237fdd387 feat(lite): handle involuntary console disconnection (#6706) 2023-04-03 10:42:56 +02:00
Julien Fontanet
8a07b7a3db fix(CHANGELOG): REST API changes are features, not fixes 2023-03-31 17:06:12 +02:00
Gabriel Gunullu
a41037833c feat: release 5.81 (#6765) 2023-03-31 16:36:18 +02:00
Gabriel Gunullu
6a780d94a3 feat: technical release (#6764) 2023-03-31 13:35:31 +02:00
Julien Fontanet
506ef0b44f fix(vhd-lib/createVhdStreamWithLength): fix empty VHD handling
Follow-up of 4e9477f34

Introduced by c26a7a3e5
2023-03-31 10:02:44 +02:00
Florent BEAUCHAMP
a4d1d41b6a refactor(xapi/VDI_exportContent): can export from NBD (#6716) 2023-03-30 18:21:39 +02:00
Julien Fontanet
4e9477f34a fix(vhd-lib/createVhdStreamWithLength): don't call readChunkStrict with 0
Introduced by c26a7a3e5
2023-03-30 14:34:10 +02:00
Julien Fontanet
43b6285437 feat(read-chunk): add skip() and skipStrict() 2023-03-30 12:14:59 +02:00
Julien Fontanet
c26a7a3e51 chore(read-chunk): assert size >= 0 2023-03-30 12:14:59 +02:00
Julien Fontanet
93eb42785d chore(read-chunk): assert size <= 1GiB 2023-03-30 12:14:59 +02:00
Julien Fontanet
02bb622e92 chore(read-chunk): improve JSDoc 2023-03-30 12:14:59 +02:00
Thierry Goettelmann
b873c147a6 chore(lite): upgrade human-format and remove its TS declaration file (#6762) 2023-03-30 10:36:39 +02:00
Gabriel Gunullu
5e7fb7a881 feat: technical release (#6759) 2023-03-29 16:51:38 +02:00
Mathieu
97790313eb feat(xo-web/host): pro support icon at host level (#6633)
- Host is not XCP-ng: no icon
- Host doesn't have license or is part of a pool that contains at least one host that doesn't have a license: orange life buoy
- Host has license and all hosts in its pool has license (=supported host): green life buoy
- Host doesn't have license and at least one host in its pool has a license: red triangle
2023-03-29 15:54:15 +02:00
Pierre Donias
954b29cb61 fix(xo-web/New VM): do not send empty MAC addresses to API (#6758)
See https://github.com/vatesfr/xen-orchestra/issues/6748#issuecomment-1484699540
2023-03-29 15:36:50 +02:00
Mathieu
dc6a13962f feat(xo-web/backup): expose maxExportRate from UI (#6737) 2023-03-29 14:48:53 +02:00
Florent BEAUCHAMP
23da202790 fix(xo-web): relax pattern for bucket (#6757)
bucket from AWS follow some naming convention, but other providers are more relaxed.
removing the regex check will allow the user to tests connecting and see the back-end specific error message
2023-03-29 14:45:26 +02:00
rajaa-b
f237101b4a feat(xo-web/import): ability to import multiple VMs from VMware (#6718)
See #6708
2023-03-29 14:35:45 +02:00
Julien Fontanet
8a99326a76 feat(xo-cli): 0.17.0 2023-03-29 12:02:18 +02:00
Gabriel Gunullu
8c95974e65 fix(xo-server-perf-alert): skip special SRs which are always full (#6755) 2023-03-29 11:18:52 +02:00
Pierre Donias
3f7454efad feat(lite/settings): add XCP-ng info (#6715) 2023-03-29 10:35:37 +02:00
Thierry Goettelmann
e5c890e29b feat(lite/stories): first stories for components (#6616) 2023-03-29 10:34:26 +02:00
Florent BEAUCHAMP
53e0f17c55 feat(xo-server): import multiple from ESXi (#6708) 2023-03-28 19:29:34 +02:00
Julien Fontanet
34f6be868e feat(backups,xo-web): store SR name label in logs
Similar to bbf92be65 & 92a00465e
2023-03-28 18:30:47 +02:00
Julien Fontanet
c84b899276 feat(xo-web/backup/log): display VM name if available
Fixes part of zammad#13551
2023-03-28 18:30:44 +02:00
Pierre Donias
266a26fa31 feat(xo-web): show Suse icon when distro name is opensuse-microos (#6746)
See https://xcp-ng.org/forum/topic/6965
2023-03-28 15:19:19 +02:00
Julien Fontanet
bbf92be652 feat(backups/Backup): add VM name label to log 2023-03-28 10:52:13 +02:00
Florent BEAUCHAMP
e19c7b949d feat(vmware-explorer): improve error handling (#6734) 2023-03-27 17:11:41 +02:00
Julien Fontanet
5ce6f1fe4d chore(CHANGELOG.unreleased): explicit restore logs REST API path
Introduced by 35f6476d0
2023-03-27 15:53:26 +02:00
Julien Fontanet
9c36520c79 feat(xo-server/rest-api): expose backup jobs 2023-03-27 15:53:26 +02:00
Thierry Goettelmann
a85a8ea208 feat(lite): enhance UiModal (#6744)
Increased `font-size` and removed ugly `min-height`.
2023-03-27 15:43:41 +02:00
Florent Beauchamp
c2e0c97d94 refactor(@xen-orchestra/fs): remove unused code 2023-03-24 18:03:04 +01:00
Florent Beauchamp
a5447fda3c fix(@xen-orchestra/fs): reduce memory usage during outputstream (full VM backup) 2023-03-24 18:03:04 +01:00
272 changed files with 7814 additions and 3617 deletions

View File

@@ -28,7 +28,7 @@ module.exports = {
},
},
{
files: ['*.{spec,test}.{,c,m}js'],
files: ['*.{integ,spec,test}.{,c,m}js'],
rules: {
'n/no-unpublished-require': 'off',
'n/no-unpublished-import': 'off',

32
@vates/diff/.USAGE.md Normal file
View File

@@ -0,0 +1,32 @@
```js
import diff from '@vates/diff'
diff('foo bar baz', 'Foo qux')
// → [ 0, 'F', 4, 'qux', 7, '' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains `F`
// - at position 4, it contains `qux`
// - at position 7, it ends
diff('Foo qux', 'foo bar baz')
// → [ 0, 'f', 4, 'bar', 7, ' baz' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains f`
// - at position 4, it contains `bar`
// - at position 7, it contains `baz`
// works with all collections that supports
// - `.length`
// - `collection[index]`
// - `.slice(start, end)`
//
// which includes:
// - arrays
// - strings
// - `Buffer`
// - `TypedArray`
diff([0, 1, 2], [3, 4])
// → [ 0, [ 3, 4 ], 2, [] ]
```

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

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

65
@vates/diff/README.md Normal file
View File

@@ -0,0 +1,65 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/diff
[![Package Version](https://badgen.net/npm/v/@vates/diff)](https://npmjs.org/package/@vates/diff) ![License](https://badgen.net/npm/license/@vates/diff) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/diff)](https://bundlephobia.com/result?p=@vates/diff) [![Node compatibility](https://badgen.net/npm/node/@vates/diff)](https://npmjs.org/package/@vates/diff)
> Computes differences between two arrays, buffers or strings
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/diff):
```sh
npm install --save @vates/diff
```
## Usage
```js
import diff from '@vates/diff'
diff('foo bar baz', 'Foo qux')
// → [ 0, 'F', 4, 'qux', 7, '' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains `F`
// - at position 4, it contains `qux`
// - at position 7, it ends
diff('Foo qux', 'foo bar baz')
// → [ 0, 'f', 4, 'bar', 7, ' baz' ]
//
// Differences of the second string from the first one:
// - at position 0, it contains f`
// - at position 4, it contains `bar`
// - at position 7, it contains `baz`
// works with all collections that supports
// - `.length`
// - `collection[index]`
// - `.slice(start, end)`
//
// which includes:
// - arrays
// - strings
// - `Buffer`
// - `TypedArray`
diff([0, 1, 2], [3, 4])
// → [ 0, [ 3, 4 ], 2, [] ]
```
## 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)

37
@vates/diff/index.js Normal file
View File

@@ -0,0 +1,37 @@
'use strict'
/**
* Compare two data arrays, buffers or strings and invoke the provided callback function for each difference.
*
* @template {Array|Buffer|string} T
* @param {Array|Buffer|string} data1 - The first data array or buffer to compare.
* @param {T} data2 - The second data array or buffer to compare.
* @param {(index: number, diff: T) => void} [cb] - The callback function to invoke for each difference. If not provided, an array of differences will be returned.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided, otherwise undefined.
*/
module.exports = function diff(data1, data2, cb) {
let result
if (cb === undefined) {
result = []
cb = result.push.bind(result)
}
const n1 = data1.length
const n2 = data2.length
const n = Math.min(n1, n2)
for (let i = 0; i < n; ++i) {
if (data1[i] !== data2[i]) {
let j = i + 1
while (j < n && data1[j] !== data2[j]) {
++j
}
cb(i, data2.slice(i, j))
i = j
}
}
if (n1 !== n2) {
cb(n, n1 < n2 ? data2.slice(n) : data2.slice(0, 0))
}
return result
}

51
@vates/diff/index.test.js Normal file
View File

@@ -0,0 +1,51 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('test')
const diff = require('./index.js')
test('data of equal length', function () {
const data1 = 'foo bar baz'
const data2 = 'baz bar foo'
assert.deepEqual(diff(data1, data2), [0, 'baz', 8, 'foo'])
})
test('data1 is longer', function () {
const data1 = 'foo bar'
const data2 = 'foo'
assert.deepEqual(diff(data1, data2), [3, ''])
})
test('data2 is longer', function () {
const data1 = 'foo'
const data2 = 'foo bar'
assert.deepEqual(diff(data1, data2), [3, ' bar'])
})
test('with arrays', function () {
const data1 = 'foo bar baz'.split('')
const data2 = 'baz bar foo'.split('')
assert.deepEqual(diff(data1, data2), [0, 'baz'.split(''), 8, 'foo'.split('')])
})
test('with buffers', function () {
const data1 = Buffer.from('foo bar baz')
const data2 = Buffer.from('baz bar foo')
assert.deepEqual(diff(data1, data2), [0, Buffer.from('baz'), 8, Buffer.from('foo')])
})
test('cb param', function () {
const data1 = 'foo bar baz'
const data2 = 'baz bar foo'
const calls = []
const cb = (...args) => calls.push(args)
diff(data1, data2, cb)
assert.deepEqual(calls, [
[0, 'baz'],
[8, 'foo'],
])
})

36
@vates/diff/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"private": false,
"name": "@vates/diff",
"description": "Computes differences between two arrays, buffers or strings",
"keywords": [
"array",
"binary",
"buffer",
"diff",
"differences",
"string"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/diff",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/diff",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.3.0"
}
}

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.1"
"vhd-lib": "^4.4.0"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -18,8 +18,11 @@ const {
OPTS_MAGIC,
NBD_CMD_DISC,
} = require('./constants.js')
const { fromCallback } = require('promise-toolbox')
const { fromCallback, pRetry, pDelay, pTimeout } = require('promise-toolbox')
const { readChunkStrict } = require('@vates/read-chunk')
const { createLogger } = require('@xen-orchestra/log')
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
@@ -32,18 +35,34 @@ module.exports = class NbdClient {
#exportName
#exportSize
#waitBeforeReconnect
#readAhead
#readBlockRetries
#reconnectRetry
#connectTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listenner waiting for a response
#nextCommandQueryId = BigInt(0)
#commandQueryBacklog // map of command waiting for an response queryId => { size/*in byte*/, resolve, reject}
#connected = false
constructor({ address, port = NBD_DEFAULT_PORT, exportname, cert }) {
#reconnectingPromise
constructor(
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
{ connectTimeout = 6e4, waitBeforeReconnect = 1e3, readAhead = 10, readBlockRetries = 5, reconnectRetry = 5 } = {}
) {
this.#serverAddress = address
this.#serverPort = port
this.#exportName = exportname
this.#serverCert = cert
this.#waitBeforeReconnect = waitBeforeReconnect
this.#readAhead = readAhead
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
}
get exportSize() {
@@ -78,24 +97,55 @@ module.exports = class NbdClient {
})
}
async connect() {
// first we connect to the serve without tls, and then we upgrade the connection
async #connect() {
// first we connect to the server without tls, and then we upgrade the connection
// to tls during the handshake
await this.#unsecureConnect()
await this.#handshake()
this.#connected = true
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async connect() {
return pTimeout.call(this.#connect(), this.#connectTimeout)
}
async disconnect() {
if (!this.#connected) {
return
}
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
await this.#write(buffer)
await this.#serverSocket.destroy()
this.#serverSocket = undefined
this.#connected = false
}
#clearReconnectPromise = () => {
this.#reconnectingPromise = undefined
}
async #reconnect() {
await this.disconnect().catch(() => {})
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
await this.connect()
}
async reconnect() {
// we need to ensure reconnections do not occur in parallel
if (this.#reconnectingPromise === undefined) {
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
tries: this.#reconnectRetry,
})
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
}
return this.#reconnectingPromise
}
// we can use individual read/write from the socket here since there is no concurrency
@@ -173,7 +223,6 @@ module.exports = class NbdClient {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
await this.disconnect()
}
async #readBlockResponse() {
@@ -181,7 +230,6 @@ module.exports = class NbdClient {
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const magic = await this.#readInt32()
@@ -206,7 +254,8 @@ module.exports = class NbdClient {
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
await this.#readBlockResponse()
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
@@ -217,6 +266,11 @@ module.exports = class NbdClient {
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
@@ -231,19 +285,67 @@ module.exports = class NbdClient {
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.index = index
error.size = size
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
size,
resolve,
reject,
reject: decoratedReject,
})
// really send the command to the server
this.#write(buffer).catch(reject)
this.#write(buffer).catch(decoratedReject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
async *readBlocks(indexGenerator) {
// default : read all blocks
if (indexGenerator === undefined) {
const exportSize = this.#exportSize
const chunkSize = 2 * 1024 * 1024
indexGenerator = function* () {
const nbBlocks = Math.ceil(exportSize / chunkSize)
for (let index = 0; index < nbBlocks; index++) {
yield { index, size: chunkSize }
}
}
}
const readAhead = []
const readAheadMaxLength = this.#readAhead
const makeReadBlockPromise = (index, size) => {
const promise = pRetry(() => this.readBlock(index, size), {
tries: this.#readBlockRetries,
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
},
})
// error is handled during unshift
promise.catch(() => {})
return promise
}
// read all blocks, but try to keep readAheadMaxLength promise waiting ahead
for (const { index, size } of indexGenerator()) {
// stack readAheadMaxLength promises before starting to handle the results
if (readAhead.length === readAheadMaxLength) {
// any error will stop reading blocks
yield readAhead.shift()
}
readAhead.push(makeReadBlockPromise(index, size))
}
while (readAhead.length > 0) {
yield readAhead.shift()
}
}
}

View File

@@ -13,16 +13,17 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.0.1",
"version": "1.2.0",
"engines": {
"node": ">=14.0"
},
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.0.1",
"@vates/read-chunk": "^1.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.2.7"
"xen-api": "^1.3.1"
},
"devDependencies": {
"tap": "^16.3.0",
@@ -30,6 +31,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap *.spec.js"
"test-integration": "tap --lines 70 --functions 36 --branches 54 --statements 69 *.integ.js"
}
}

View File

@@ -24,3 +24,25 @@ import { readChunkStrict } from '@vates/read-chunk'
const chunk = await readChunkStrict(stream, 1024)
```
### `skip(stream, size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
import { skip } from '@vates/read-chunk'
const bytesSkipped = await skip(stream, 2 * 1024 * 1024 * 1024)
```
### `skipStrict(stream, size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
import { skipStrict } from '@vates/read-chunk'
await skipStrict(stream, 2 * 1024 * 1024 * 1024)
```

View File

@@ -43,6 +43,28 @@ import { readChunkStrict } from '@vates/read-chunk'
const chunk = await readChunkStrict(stream, 1024)
```
### `skip(stream, size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
import { skip } from '@vates/read-chunk'
const bytesSkipped = await skip(stream, 2 * 1024 * 1024 * 1024)
```
### `skipStrict(stream, size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
import { skipStrict } from '@vates/read-chunk'
await skipStrict(stream, 2 * 1024 * 1024 * 1024)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -1,18 +1,36 @@
'use strict'
const assert = require('assert')
/**
* Read a chunk of data from a stream.
*
* The returned promise is rejected if there is an error while reading the stream.
*
* For streams in object mode, the returned promise resolves to a single object read from the stream.
*
* For streams in binary mode, the returned promise resolves to a Buffer or a string if an encoding has been specified using the `stream.setEncoding()` method.
*
* If `size` bytes are not available to be read, `null` will be returned *unless* the stream has ended, in which case all of the data remaining will be returned.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer|null>} - A Promise that resolves to a Buffer of up to size bytes if available, or null if end of stream is reached. The Promise is rejected if there is an error while reading from the stream.
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
* @returns {Promise<Buffer|string|unknown|null>} - A Promise that resolves to the read chunk if available, or null if end of stream is reached.
*/
const readChunk = (stream, size) =>
stream.closed || stream.readableEnded
stream.errored != null
? Promise.reject(stream.errored)
: stream.closed || stream.readableEnded
? Promise.resolve(null)
: size === 0
? Promise.resolve(Buffer.alloc(0))
: new Promise((resolve, reject) => {
if (size !== undefined) {
assert(size > 0)
// per Node documentation:
// > The size argument must be less than or equal to 1 GiB.
assert(size < 1073741824)
}
function onEnd() {
resolve(null)
removeListeners()
@@ -43,9 +61,17 @@ exports.readChunk = readChunk
/**
* Read a chunk of data from a stream.
*
* The returned promise is rejected if there is an error while reading the stream.
*
* For streams in object mode, the returned promise resolves to a single object read from the stream.
*
* For streams in binary mode, the returned promise resolves to a Buffer or a string if an encoding has been specified using the `stream.setEncoding()` method.
*
* If `size` bytes are not available to be read, the returned promise is rejected.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer>} - A Promise that resolves to a Buffer of size bytes. The Promise is rejected if there is an error while reading from the stream.
* @param {number} [size] - The number of bytes to read for binary streams (ignored for object streams).
* @returns {Promise<Buffer|string|unknown>} - A Promise that resolves to the read chunk.
*/
exports.readChunkStrict = async function readChunkStrict(stream, size) {
const chunk = await readChunk(stream, size)
@@ -54,7 +80,7 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
}
if (size !== undefined && chunk.length !== size) {
const error = new Error('stream has ended with not enough data')
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
Object.defineProperties(error, {
chunk: {
value: chunk,
@@ -65,3 +91,69 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
return chunk
}
/**
* Skips a given number of bytes from a readable stream.
*
* @param {Readable} stream - A readable stream to skip bytes from.
* @param {number} size - The number of bytes to skip.
* @returns {Promise<number>} A Promise that resolves to the number of bytes actually skipped. If the end of the stream is reached before all bytes are skipped, the Promise resolves to the number of bytes that were skipped before the end of the stream was reached. The Promise is rejected if there is an error while reading from the stream.
*/
async function skip(stream, size) {
return stream.errored != null
? Promise.reject(stream.errored)
: size === 0 || stream.closed || stream.readableEnded
? Promise.resolve(0)
: new Promise((resolve, reject) => {
let left = size
function onEnd() {
resolve(size - left)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read()
left -= data === null ? 0 : data.length
if (left > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (left < 0) {
stream.unshift(data.slice(left))
}
resolve(size)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
}
exports.skip = skip
/**
* Skips a given number of bytes from a stream.
*
* @param {Readable} stream - A readable stream to skip bytes from.
* @param {number} size - The number of bytes to skip.
* @returns {Promise<void>} - A Promise that resolves when the exact number of bytes have been skipped. The Promise is rejected if there is an error while reading from the stream or the stream ends before the exact number of bytes have been skipped.
*/
exports.skipStrict = async function skipStrict(stream, size) {
const bytesSkipped = await skip(stream, size)
if (bytesSkipped !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${bytesSkipped}, expected: ${size})`)
error.bytesSkipped = bytesSkipped
throw error
}
}

View File

@@ -5,12 +5,58 @@ const assert = require('node:assert').strict
const { Readable } = require('stream')
const { readChunk, readChunkStrict } = require('./')
const { readChunk, readChunkStrict, skip, skipStrict } = require('./')
const makeStream = it => Readable.from(it, { objectMode: false })
makeStream.obj = Readable.from
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
const makeErrorTests = fn => {
it('rejects if the stream errors', async () => {
const error = new Error()
const stream = makeStream([])
const pError = rejectionOf(fn(stream, 10))
stream.destroy(error)
assert.strict(await pError, error)
})
// only supported for Node >= 18
if (process.versions.node.split('.')[0] >= 18) {
it('rejects if the stream has already errored', async () => {
const error = new Error()
const stream = makeStream([])
await new Promise(resolve => {
stream.once('error', resolve).destroy(error)
})
assert.strict(await rejectionOf(fn(stream, 10)), error)
})
}
}
describe('readChunk', () => {
it('rejects if size is less than or equal to 0', async () => {
const error = await rejectionOf(readChunk(makeStream([]), 0))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
it('rejects if size is greater than or equal to 1 GiB', async () => {
const error = await rejectionOf(readChunk(makeStream([]), 1024 * 1024 * 1024))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
makeErrorTests(readChunk)
it('returns null if stream is empty', async () => {
assert.strictEqual(await readChunk(makeStream([])), null)
})
@@ -38,10 +84,6 @@ describe('readChunk', () => {
it('returns less data if stream ends', async () => {
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 10), Buffer.from('foobar'))
})
it('returns an empty buffer if the specified size is 0', async () => {
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 0), Buffer.alloc(0))
})
})
describe('with object stream', () => {
@@ -52,14 +94,6 @@ describe('readChunk', () => {
})
})
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
describe('readChunkStrict', function () {
it('throws if stream is empty', async () => {
const error = await rejectionOf(readChunkStrict(makeStream([])))
@@ -71,7 +105,43 @@ describe('readChunkStrict', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data')
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
})
describe('skip', function () {
makeErrorTests(skip)
it('returns 0 if size is 0', async () => {
assert.strictEqual(await skip(makeStream(['foo']), 0), 0)
})
it('returns 0 if the stream is already ended', async () => {
const stream = await makeStream([])
await readChunk(stream)
assert.strictEqual(await skip(stream, 10), 0)
})
it('skips a number of bytes', async () => {
const stream = makeStream('foo bar')
assert.strictEqual(await skip(stream, 4), 4)
assert.deepEqual(await readChunk(stream, 4), Buffer.from('bar'))
})
it('returns less size if stream ends', async () => {
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
})
})
describe('skipStrict', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(skipStrict(makeStream('foo bar'), 10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
})

View File

@@ -19,7 +19,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "1.0.1",
"version": "1.1.1",
"engines": {
"node": ">=8.10"
},

View File

@@ -0,0 +1,42 @@
```js
import StreamReader from '@vates/stream-reader'
const reader = new StreamReader(stream)
```
### `.read([size])`
- returns the next available chunk of data
- like `stream.read()`, a number of bytes can be specified
- returns with less data than expected if stream has ended
- returns `null` if the stream has ended and no data has been read
```js
const chunk = await reader.read(512)
```
### `.readStrict([size])`
Similar behavior to `readChunk` but throws if the stream ended before the requested data could be read.
```js
const chunk = await reader.readStrict(512)
```
### `.skip(size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
const bytesSkipped = await reader.skip(2 * 1024 * 1024 * 1024)
```
### `.skipStrict(size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
await reader.skipStrict(2 * 1024 * 1024 * 1024)
```

View File

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

View File

@@ -0,0 +1,75 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/stream-reader
[![Package Version](https://badgen.net/npm/v/@vates/stream-reader)](https://npmjs.org/package/@vates/stream-reader) ![License](https://badgen.net/npm/license/@vates/stream-reader) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/stream-reader)](https://bundlephobia.com/result?p=@vates/stream-reader) [![Node compatibility](https://badgen.net/npm/node/@vates/stream-reader)](https://npmjs.org/package/@vates/stream-reader)
> Efficiently reads and skips chunks of a given size in a stream
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/stream-reader):
```sh
npm install --save @vates/stream-reader
```
## Usage
```js
import StreamReader from '@vates/stream-reader'
const reader = new StreamReader(stream)
```
### `.read([size])`
- returns the next available chunk of data
- like `stream.read()`, a number of bytes can be specified
- returns with less data than expected if stream has ended
- returns `null` if the stream has ended and no data has been read
```js
const chunk = await reader.read(512)
```
### `.readStrict([size])`
Similar behavior to `readChunk` but throws if the stream ended before the requested data could be read.
```js
const chunk = await reader.readStrict(512)
```
### `.skip(size)`
Skips a given number of bytes from a stream.
Returns the number of bytes actually skipped, which may be less than the requested size if the stream has ended.
```js
const bytesSkipped = await reader.skip(2 * 1024 * 1024 * 1024)
```
### `.skipStrict(size)`
Skips a given number of bytes from a stream and throws if the stream ended before enough stream has been skipped.
```js
await reader.skipStrict(2 * 1024 * 1024 * 1024)
```
## 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)

View File

@@ -0,0 +1,123 @@
'use strict'
const assert = require('node:assert')
const { finished, Readable } = require('node:stream')
const noop = Function.prototype
// Inspired by https://github.com/nodejs/node/blob/85705a47958c9ae5dbaa1f57456db19bdefdc494/lib/internal/streams/readable.js#L1107
class StreamReader {
#ended = false
#error
#executor = resolve => {
this.#resolve = resolve
}
#stream
#resolve = noop
constructor(stream) {
stream = typeof stream.pipe === 'function' ? stream : Readable.from(stream)
this.#stream = stream
stream.on('readable', () => this.#resolve())
finished(stream, { writable: false }, error => {
this.#error = error
this.#ended = true
this.#resolve()
})
}
async read(size) {
if (size !== undefined) {
assert(size > 0)
}
do {
if (this.#ended) {
if (this.#error) {
throw this.#error
}
return null
}
const value = this.#stream.read(size)
if (value !== null) {
return value
}
await new Promise(this.#executor)
} while (true)
}
async readStrict(size) {
const chunk = await this.read(size)
if (chunk === null) {
throw new Error('stream has ended without data')
}
if (size !== undefined && chunk.length !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
Object.defineProperties(error, {
chunk: {
value: chunk,
},
})
throw error
}
return chunk
}
async skip(size) {
if (size === 0) {
return size
}
let toSkip = size
do {
if (this.#ended) {
if (this.#error) {
throw this.#error
}
return size - toSkip
}
const data = this.#stream.read()
if (data !== null) {
toSkip -= data === null ? 0 : data.length
if (toSkip > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (toSkip < 0) {
this.#stream.unshift(data.slice(toSkip))
}
return size
}
}
await new Promise(this.#executor)
} while (true)
}
async skipStrict(size) {
const bytesSkipped = await this.skip(size)
if (bytesSkipped !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${bytesSkipped}, expected: ${size})`)
error.bytesSkipped = bytesSkipped
throw error
}
}
}
StreamReader.prototype[Symbol.asyncIterator] = async function* asyncIterator() {
let chunk
while ((chunk = await this.read()) !== null) {
yield chunk
}
}
module.exports = StreamReader

View File

@@ -0,0 +1,141 @@
'use strict'
const { describe, it } = require('test')
const assert = require('node:assert').strict
const { Readable } = require('stream')
const StreamReader = require('./index.js')
const makeStream = it => Readable.from(it, { objectMode: false })
makeStream.obj = Readable.from
const rejectionOf = promise =>
promise.then(
value => {
throw value
},
error => error
)
const makeErrorTests = method => {
it('rejects if the stream errors', async () => {
const error = new Error()
const stream = makeStream([])
const pError = rejectionOf(new StreamReader(stream)[method](10))
stream.destroy(error)
assert.strict(await pError, error)
})
it('rejects if the stream has already errored', async () => {
const error = new Error()
const stream = makeStream([])
await new Promise(resolve => {
stream.once('error', resolve).destroy(error)
})
assert.strict(await rejectionOf(new StreamReader(stream)[method](10)), error)
})
}
describe('read()', () => {
it('rejects if size is less than or equal to 0', async () => {
const error = await rejectionOf(new StreamReader(makeStream([])).read(0))
assert.strictEqual(error.code, 'ERR_ASSERTION')
})
it('returns null if stream is empty', async () => {
assert.strictEqual(await new StreamReader(makeStream([])).read(), null)
})
makeErrorTests('read')
it('returns null if the stream is already ended', async () => {
const reader = new StreamReader(makeStream([]))
await reader.read()
assert.strictEqual(await reader.read(), null)
})
describe('with binary stream', () => {
it('returns the first chunk of data', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(), Buffer.from('foo'))
})
it('returns a chunk of the specified size (smaller than first)', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(2), Buffer.from('fo'))
})
it('returns a chunk of the specified size (larger than first)', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(4), Buffer.from('foob'))
})
it('returns less data if stream ends', async () => {
assert.deepEqual(await new StreamReader(makeStream(['foo', 'bar'])).read(10), Buffer.from('foobar'))
})
})
describe('with object stream', () => {
it('returns the first chunk of data verbatim', async () => {
const chunks = [{}, {}]
assert.strictEqual(await new StreamReader(makeStream.obj(chunks)).read(), chunks[0])
})
})
})
describe('readStrict()', function () {
it('throws if stream is empty', async () => {
const error = await rejectionOf(new StreamReader(makeStream([])).readStrict())
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended without data')
assert.strictEqual(error.chunk, undefined)
})
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(new StreamReader(makeStream(['foo', 'bar'])).readStrict(10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
})
describe('skip()', function () {
makeErrorTests('skip')
it('returns 0 if size is 0', async () => {
assert.strictEqual(await new StreamReader(makeStream(['foo'])).skip(0), 0)
})
it('returns 0 if the stream is already ended', async () => {
const reader = new StreamReader(makeStream([]))
await reader.read()
assert.strictEqual(await reader.skip(10), 0)
})
it('skips a number of bytes', async () => {
const reader = new StreamReader(makeStream('foo bar'))
assert.strictEqual(await reader.skip(4), 4)
assert.deepEqual(await reader.read(4), Buffer.from('bar'))
})
it('returns less size if stream ends', async () => {
assert.deepEqual(await new StreamReader(makeStream('foo bar')).skip(10), 7)
})
})
describe('skipStrict()', function () {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(new StreamReader(makeStream('foo bar')).skipStrict(10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
})

View File

@@ -0,0 +1,39 @@
{
"private": false,
"name": "@vates/stream-reader",
"description": "Efficiently reads and skips chunks of a given size in a stream",
"keywords": [
"async",
"chunk",
"data",
"node",
"promise",
"read",
"reader",
"skip",
"stream"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/stream-reader",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/stream-reader",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.0",
"engines": {
"node": ">=10"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.3.0"
}
}

View File

@@ -2,7 +2,12 @@
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// data in this object will be sent along the *start* event
//
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task',
},
// if defined, a new detached task is created
//
@@ -25,8 +30,19 @@ const task = new Task({
// this field is settable once before being observed
task.id
// contains the current status of the task
//
// possible statuses are:
// - pending
// - success
// - failure
// - aborted
task.status
await task.abort()
// Triggers the abort signal associated to the task.
//
// This simply requests the task to abort, it will be up to the task to handle or not this signal.
task.abort(reason)
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
@@ -34,7 +50,11 @@ 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)
```
Inside a task:
```js
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
@@ -52,3 +72,43 @@ Task.warning(message, data)
// - progress
Task.set(property, value)
```
### `combineEvents`
Create a consolidated log from individual events.
It can be used directly as an `onProgress` callback:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({
// This function is called each time a root task starts.
//
// It will be called for as many times as there are tasks created with this `onProgress` function.
onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks,
// and will be mutated in real-time to reflect the changes of the task.
},
// This function is called each time a root task ends.
onRootTaskEnd(taskLog) {},
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})
Task.run({ data: { name: 'my task' }, onProgress }, asyncFn)
```
It can also be fed event logs directly:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({ onRootTaskStart, onRootTaskEnd, onTaskUpdate })
eventLogs.forEach(onProgress)
```

View File

@@ -18,7 +18,12 @@ npm install --save @vates/task
import { Task } from '@vates/task'
const task = new Task({
name: 'my task',
// data in this object will be sent along the *start* event
//
// property names should be chosen as not to clash with properties used by `Task` or `combineEvents`
data: {
name: 'my task',
},
// if defined, a new detached task is created
//
@@ -41,8 +46,19 @@ const task = new Task({
// this field is settable once before being observed
task.id
// contains the current status of the task
//
// possible statuses are:
// - pending
// - success
// - failure
// - aborted
task.status
await task.abort()
// Triggers the abort signal associated to the task.
//
// This simply requests the task to abort, it will be up to the task to handle or not this signal.
task.abort(reason)
// if fn rejects, the task will be marked as failed
const result = await task.runInside(fn)
@@ -50,7 +66,11 @@ 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)
```
Inside a task:
```js
// the abort signal of the current task if any, otherwise is `undefined`
Task.abortSignal
@@ -69,6 +89,46 @@ Task.warning(message, data)
Task.set(property, value)
```
### `combineEvents`
Create a consolidated log from individual events.
It can be used directly as an `onProgress` callback:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({
// This function is called each time a root task starts.
//
// It will be called for as many times as there are tasks created with this `onProgress` function.
onRootTaskStart(taskLog) {
// `taskLog` is an object reflecting the state of this task and all its subtasks,
// and will be mutated in real-time to reflect the changes of the task.
},
// This function is called each time a root task ends.
onRootTaskEnd(taskLog) {},
// This function is called each time a root task or a subtask is updated.
//
// `taskLog.$root` can be used to uncondionally access the root task.
onTaskUpdate(taskLog) {},
})
Task.run({ data: { name: 'my task' }, onProgress }, asyncFn)
```
It can also be fed event logs directly:
```js
import { makeOnProgress } from '@vates/task/combineEvents'
const onProgress = makeOnProgress({ onRootTaskStart, onRootTaskEnd, onTaskUpdate })
eventLogs.forEach(onProgress)
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -0,0 +1,77 @@
'use strict'
const assert = require('node:assert').strict
const noop = Function.prototype
function omit(source, keys, target = { __proto__: null }) {
for (const key of Object.keys(source)) {
if (!keys.has(key)) {
target[key] = source[key]
}
}
return target
}
const IGNORED_START_PROPS = new Set([
'end',
'infos',
'properties',
'result',
'status',
'tasks',
'timestamp',
'type',
'warnings',
])
exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noop, onTaskUpdate = noop }) {
const taskLogs = new Map()
return function onProgress(event) {
const { id, type } = event
let taskLog
if (type === 'start') {
taskLog = omit(event, IGNORED_START_PROPS)
taskLog.start = event.timestamp
taskLog.status = 'pending'
taskLogs.set(id, taskLog)
const { parentId } = event
if (parentId === undefined) {
Object.defineProperty(taskLog, '$root', { value: taskLog })
// start of a root task
onRootTaskStart(taskLog)
} else {
// start of a subtask
const parent = taskLogs.get(parentId)
assert.notEqual(parent, undefined)
// inject a (non-enumerable) reference to the parent and the root task
Object.defineProperties(taskLog, { $parent: { value: parent }, $root: { value: parent.$root } })
;(parent.tasks ?? (parent.tasks = [])).push(taskLog)
}
} else {
taskLog = taskLogs.get(id)
assert.notEqual(taskLog, undefined)
if (type === 'info' || type === 'warning') {
const key = type + 's'
const { data, message } = event
;(taskLog[key] ?? (taskLog[key] = [])).push({ data, message })
} else if (type === 'property') {
;(taskLog.properties ?? (taskLog.properties = { __proto__: null }))[event.name] = event.value
} else if (type === 'end') {
taskLog.end = event.timestamp
taskLog.result = event.result
taskLog.status = event.status
}
if (type === 'end' && taskLog.$root === taskLog) {
onRootTaskEnd(taskLog)
}
}
onTaskUpdate(taskLog)
}
}

View File

@@ -0,0 +1,67 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { makeOnProgress } = require('./combineEvents.js')
const { Task } = require('./index.js')
describe('makeOnProgress()', function () {
it('works', async function () {
const events = []
let log
const task = new Task({
data: { name: 'task' },
onProgress: makeOnProgress({
onRootTaskStart(log_) {
assert.equal(log, undefined)
log = log_
events.push('onRootTaskStart')
},
onRootTaskEnd(log_) {
assert.equal(log_, log)
events.push('onRootTaskEnd')
},
onTaskUpdate(log_) {
assert.equal(log_.$root, log)
events.push('onTaskUpdate')
},
}),
})
assert.equal(events.length, 0)
await task.run(async () => {
assert.equal(events[0], 'onRootTaskStart')
assert.equal(events[1], 'onTaskUpdate')
assert.equal(log.name, 'task')
Task.set('progress', 0)
assert.equal(events[2], 'onTaskUpdate')
assert.equal(log.properties.progress, 0)
Task.info('foo', {})
assert.equal(events[3], 'onTaskUpdate')
assert.deepEqual(log.infos, [{ data: {}, message: 'foo' }])
await Task.run({ data: { name: 'subtask' } }, () => {
assert.equal(events[4], 'onTaskUpdate')
assert.equal(log.tasks[0].name, 'subtask')
Task.warning('bar', {})
assert.equal(events[5], 'onTaskUpdate')
assert.deepEqual(log.tasks[0].warnings, [{ data: {}, message: 'bar' }])
})
assert.equal(events[6], 'onTaskUpdate')
assert.equal(log.tasks[0].status, 'success')
Task.set('progress', 100)
assert.equal(events[7], 'onTaskUpdate')
assert.equal(log.properties.progress, 100)
})
assert.equal(events[8], 'onRootTaskEnd')
assert.equal(events[9], 'onTaskUpdate')
assert.equal(log.status, 'success')
})
})

View File

@@ -11,13 +11,15 @@ function define(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 }
exports.STATUS = { ABORTED, FAILURE, PENDING, SUCCESS }
// stored in the global context so that various versions of the library can interact.
const asyncStorageKey = '@vates/task@0'
const asyncStorage = global[asyncStorageKey] ?? (global[asyncStorageKey] = new AsyncLocalStorage())
const asyncStorage = new AsyncLocalStorage()
const getTask = () => asyncStorage.getStore()
exports.Task = class Task {
@@ -66,7 +68,6 @@ exports.Task = class Task {
#abortController = new AbortController()
#onProgress
#parent
get id() {
return (this.id = Math.random().toString(36).slice(2))
@@ -82,16 +83,14 @@ exports.Task = class Task {
return this.#status
}
constructor({ name, onProgress }) {
this.#startData = { name }
constructor({ data = {}, onProgress } = {}) {
this.#startData = data
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)
@@ -106,8 +105,12 @@ exports.Task = class Task {
const { signal } = this.#abortController
signal.addEventListener('abort', () => {
if (this.status === PENDING) {
this.#status = this.#running ? ABORTING : ABORTED
if (this.status === PENDING && !this.#running) {
this.#maybeStart()
const status = ABORTED
this.#status = status
this.#emit('end', { result: signal.reason, status })
}
})
}
@@ -123,14 +126,12 @@ exports.Task = class Task {
this.#onProgress(data)
}
#handleMaybeAbortion(result) {
if (this.status === ABORTING) {
this.#status = ABORTED
this.#emit('end', { status: ABORTED, result })
return true
#maybeStart() {
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
}
return false;
}
async run(fn) {
@@ -148,22 +149,19 @@ exports.Task = class Task {
assert.equal(this.#running, false)
this.#running = true
const startData = this.#startData
if (startData !== undefined) {
this.#startData = undefined
this.#emit('start', startData)
}
this.#maybeStart()
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 })
}
const { signal } = this.#abortController
const aborted = signal.aborted && result === signal.reason
const status = aborted ? ABORTED : FAILURE
this.#status = status
this.#emit('end', { status, result })
throw result
}
}

341
@vates/task/index.test.js Normal file
View File

@@ -0,0 +1,341 @@
'use strict'
const assert = require('node:assert').strict
const { describe, it } = require('test')
const { Task } = require('./index.js')
const noop = Function.prototype
function assertEvent(task, expected, eventIndex = -1) {
const logs = task.$events
const actual = logs[eventIndex < 0 ? logs.length + eventIndex : eventIndex]
assert.equal(typeof actual, 'object')
assert.equal(typeof actual.id, 'string')
assert.equal(typeof actual.timestamp, 'number')
for (const keys of Object.keys(expected)) {
assert.equal(actual[keys], expected[keys])
}
}
// like new Task() but with a custom onProgress which adds event to task.$events
function createTask(opts) {
const events = []
const task = new Task({ ...opts, onProgress: events.push.bind(events) })
task.$events = events
return task
}
describe('Task', function () {
describe('contructor', function () {
it('data properties are passed to the start event', async function () {
const data = { foo: 0, bar: 1 }
const task = createTask({ data })
await task.run(noop)
assertEvent(task, { ...data, type: 'start' }, 0)
})
})
it('subtasks events are passed to root task', async function () {
const task = createTask()
const result = {}
await task.run(async () => {
await new Task().run(() => result)
})
assert.equal(task.$events.length, 4)
assertEvent(task, { type: 'start', parentId: task.id }, 1)
assertEvent(task, { type: 'end', status: 'success', result }, 2)
})
describe('.abortSignal', function () {
it('is undefined when run outside a task', function () {
assert.equal(Task.abortSignal, undefined)
})
it('is the current abort signal when run inside a task', async function () {
const task = createTask()
await task.run(() => {
const { abortSignal } = Task
assert.equal(abortSignal.aborted, false)
task.abort()
assert.equal(abortSignal.aborted, true)
})
})
})
describe('.abort()', function () {
it('aborts if the task throws fails with the abort reason', async function () {
const task = createTask()
const reason = {}
await task
.run(() => {
task.abort(reason)
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
it('does not abort if the task fails without the abort reason', async function () {
const task = createTask()
const result = new Error()
await task
.run(() => {
task.abort({})
throw result
})
.catch(noop)
assert.equal(task.status, 'failure')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'failure', result }, 1)
})
it('does not abort if the task succeed', async function () {
const task = createTask()
const result = {}
await task
.run(() => {
task.abort({})
return result
})
.catch(noop)
assert.equal(task.status, 'success')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'success', result }, 1)
})
it('aborts before task is running', function () {
const task = createTask()
const reason = {}
task.abort(reason)
assert.equal(task.status, 'aborted')
assert.equal(task.$events.length, 2)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'end', status: 'aborted', result: reason }, 1)
})
})
describe('.info()', function () {
it('does nothing when run outside a task', function () {
Task.info('foo')
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.info('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'info',
})
})
})
})
describe('.set()', function () {
it('does nothing when run outside a task', function () {
Task.set('progress', 10)
})
it('emits an info message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.set('progress', 10)
assertEvent(task, {
name: 'progress',
type: 'property',
value: 10,
})
})
})
})
describe('.warning()', function () {
it('does nothing when run outside a task', function () {
Task.warning('foo')
})
it('emits an warning message when run inside a task', async function () {
const task = createTask()
await task.run(() => {
Task.warning('foo')
assertEvent(task, {
data: undefined,
message: 'foo',
type: 'warning',
})
})
})
})
describe('#id', function () {
it('can be set', function () {
const task = createTask()
task.id = 'foo'
assert.equal(task.id, 'foo')
})
it('cannot be set more than once', function () {
const task = createTask()
task.id = 'foo'
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
it('is randomly generated if not set', function () {
assert.notEqual(createTask().id, createTask().id)
})
it('cannot be set after being observed', function () {
const task = createTask()
noop(task.id)
assert.throws(() => {
task.id = 'bar'
}, TypeError)
})
})
describe('#status', function () {
it('starts as pending', function () {
assert.equal(createTask().status, 'pending')
})
it('changes to success when finish without error', async function () {
const task = createTask()
await task.run(noop)
assert.equal(task.status, 'success')
})
it('changes to failure when finish with error', async function () {
const task = createTask()
await task
.run(() => {
throw Error()
})
.catch(noop)
assert.equal(task.status, 'failure')
})
it('changes to aborted after run is complete', async function () {
const task = createTask()
await task
.run(() => {
task.abort()
assert.equal(task.status, 'pending')
Task.abortSignal.throwIfAborted()
})
.catch(noop)
assert.equal(task.status, 'aborted')
})
it('changes to aborted if aborted when not running', async function () {
const task = createTask()
task.abort()
assert.equal(task.status, 'aborted')
})
})
function makeRunTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('finishes the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'success')
assertEvent(task, {
status: 'success',
result: 'foo',
type: 'end',
})
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.run', function () {
makeRunTests((task, fn) => task.run(fn))
})
describe('.wrap', function () {
makeRunTests((task, fn) => task.wrap(fn)())
})
function makeRunInsideTests(run) {
it('starts the task', async function () {
const task = createTask()
await run(task, () => {
assertEvent(task, { type: 'start' })
})
})
it('does not finish the task on success', async function () {
const task = createTask()
await run(task, () => 'foo')
assert.equal(task.status, 'pending')
})
it('fails the task on error', async function () {
const task = createTask()
const e = new Error()
await run(task, () => {
throw e
}).catch(noop)
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: e,
type: 'end',
})
})
}
describe('.runInside', function () {
makeRunInsideTests((task, fn) => task.runInside(fn))
})
describe('.wrapInside', function () {
makeRunInsideTests((task, fn) => task.wrapInside(fn)())
})
})

View File

@@ -13,11 +13,19 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.0.1",
"version": "0.1.2",
"engines": {
"node": ">=14"
},
"devDependencies": {
"test": "^3.3.0"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"exports": {
".": "./index.js",
"./combineEvents": "./combineEvents.js"
}
}

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.32.0",
"@xen-orchestra/fs": "^3.3.2",
"@xen-orchestra/backups": "^0.36.1",
"@xen-orchestra/fs": "^3.3.4",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.2",
"version": "1.0.6",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -49,6 +49,7 @@ const DEFAULT_VM_SETTINGS = {
timeout: 0,
useNbd: false,
unconditionalSnapshot: false,
validateVhdStreams: false,
vmTimeout: 0,
}
@@ -269,24 +270,35 @@ exports.Backup = class Backup {
const allSettings = this._job.settings
const baseSettings = this._baseSettings
const handleVm = vmUuid =>
runTask({ name: 'backup VM', data: { type: 'VM', id: vmUuid } }, () =>
Disposable.use(this._getRecord('VM', vmUuid), vm =>
new VmBackup({
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}).run()
)
const handleVm = vmUuid => {
const taskStart = { name: 'backup VM', data: { type: 'VM', id: vmUuid } }
return this._getRecord('VM', vmUuid).then(
disposableVm =>
Disposable.use(disposableVm, vm => {
taskStart.data.name_label = vm.name_label
return runTask(taskStart, () =>
new VmBackup({
baseSettings,
config,
getSnapshotNameLabel,
healthCheckSr,
job,
remoteAdapters,
schedule,
settings: { ...settings, ...allSettings[vm.uuid] },
srs,
throttleStream,
vm,
}).run()
)
}),
error =>
runTask(taskStart, () => {
throw error
})
)
}
const { concurrency } = settings
await asyncMapSettled(vmIds, concurrency === 0 ? handleVm : limitConcurrency(concurrency)(handleVm))
}

View File

@@ -3,12 +3,14 @@
const { Task } = require('./Task')
exports.HealthCheckVmBackup = class HealthCheckVmBackup {
#xapi
#restoredVm
#timeout
#xapi
constructor({ restoredVm, xapi }) {
constructor({ restoredVm, timeout = 10 * 60 * 1000, xapi }) {
this.#restoredVm = restoredVm
this.#xapi = xapi
this.#timeout = timeout
}
async run() {
@@ -23,7 +25,12 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
// remove vifs
await Promise.all(restoredVm.$VIFs.map(vif => xapi.callAsync('VIF.destroy', vif.$ref)))
const waitForScript = restoredVm.tags.includes('xo-backup-health-check-xenstore')
if (waitForScript) {
await restoredVm.set_xenstore_data({
'vm-data/xo-backup-health-check': 'planned',
})
}
const start = new Date()
// start Vm
@@ -34,7 +41,7 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
false // Skip pre-boot checks?
)
const started = new Date()
const timeout = 10 * 60 * 1000
const timeout = this.#timeout
const startDuration = started - start
let remainingTimeout = timeout - startDuration
@@ -52,12 +59,52 @@ exports.HealthCheckVmBackup = class HealthCheckVmBackup {
remainingTimeout -= running - started
if (remainingTimeout < 0) {
throw new Error(`local xapi did not get Runnig state for VM ${restoredId} after ${timeout / 1000} second`)
throw new Error(`local xapi did not get Running state for VM ${restoredId} after ${timeout / 1000} second`)
}
// wait for the guest tool version to be defined
await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, {
timeout: remainingTimeout,
})
const guestToolsReady = new Date()
remainingTimeout -= guestToolsReady - running
if (remainingTimeout < 0) {
throw new Error(`local xapi did not get he guest tools check ${restoredId} after ${timeout / 1000} second`)
}
if (waitForScript) {
const startedRestoredVm = await xapi.waitObjectState(
restoredVm.$ref,
vm =>
vm?.xenstore_data !== undefined &&
(vm.xenstore_data['vm-data/xo-backup-health-check'] === 'success' ||
vm.xenstore_data['vm-data/xo-backup-health-check'] === 'failure'),
{
timeout: remainingTimeout,
}
)
const scriptOk = new Date()
remainingTimeout -= scriptOk - guestToolsReady
if (remainingTimeout < 0) {
throw new Error(
`Backup health check script did not update vm-data/xo-backup-health-check of ${restoredId} after ${
timeout / 1000
} second, got ${
startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check']
} instead of 'success' or 'failure'`
)
}
if (startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check'] !== 'success') {
const message = startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check-error']
if (message) {
throw new Error(`Backup health check script failed with message ${message} for VM ${restoredId} `)
} else {
throw new Error(`Backup health check script failed for VM ${restoredId} `)
}
}
Task.info('Backup health check script successfully executed')
}
}
)
}

View File

@@ -10,14 +10,7 @@ const groupBy = require('lodash/groupBy.js')
const pickBy = require('lodash/pickBy.js')
const { dirname, join, normalize, resolve } = require('path')
const { createLogger } = require('@xen-orchestra/log')
const {
createVhdDirectoryFromStream,
createVhdStreamWithLength,
openVhd,
VhdAbstract,
VhdDirectory,
VhdSynthetic,
} = require('vhd-lib')
const { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } = require('vhd-lib')
const { deduped } = require('@vates/disposable/deduped.js')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { compose } = require('@vates/compose')
@@ -39,7 +32,6 @@ 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')
const { strictEqual } = require('assert')
const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
exports.DIR_XO_CONFIG_BACKUPS = DIR_XO_CONFIG_BACKUPS
@@ -666,7 +658,7 @@ class RemoteAdapter {
return path
}
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency, nbdClient } = {}) {
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
const handler = this._handler
if (this.useVhdDirectory()) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
@@ -677,42 +669,21 @@ class RemoteAdapter {
await input.task
return validator.apply(this, arguments)
},
nbdClient,
})
await VhdAbstract.createAlias(handler, path, dataPath)
return size
} else {
const inputWithSize = await createVhdStreamWithLength(input)
return this.outputStream(path, inputWithSize, { checksum, validator, expectedSize: inputWithSize.length })
return this.outputStream(path, input, { checksum, validator })
}
}
async outputStream(path, input, { checksum = true, validator = noop, expectedSize } = {}) {
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
async validator() {
await input.task
if (expectedSize !== undefined) {
// check that we read all the stream
strictEqual(
container.size,
expectedSize,
`transferred size ${container.size}, expected file size : ${expectedSize}`
)
}
let size
try {
size = await this._handler.getSize(path)
} catch (err) {
// can fail is the remote is encrypted
}
if (size !== undefined) {
// check that everything is written to disk
strictEqual(size, container.size, `written size ${size}, transfered size : ${container.size}`)
}
return validator.apply(this, arguments)
},
})
@@ -748,7 +719,7 @@ class RemoteAdapter {
async readDeltaVmBackup(metadata, ignoredVdis) {
const handler = this._handler
const { vbds, vhds, vifs, vm } = metadata
const { vbds, vhds, vifs, vm, vmSnapshot } = metadata
const dir = dirname(metadata._filename)
const vdis = ignoredVdis === undefined ? metadata.vdis : pickBy(metadata.vdis, vdi => !ignoredVdis.has(vdi.uuid))
@@ -763,7 +734,7 @@ class RemoteAdapter {
vdis,
version: '1.0.0',
vifs,
vm,
vm: { ...vm, suspend_VDI: vmSnapshot.suspend_VDI },
}
}
@@ -775,7 +746,49 @@ class RemoteAdapter {
// _filename is a private field used to compute the backup id
//
// it's enumerable to make it cacheable
return { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
const metadata = { ...JSON.parse(await this._handler.readFile(path)), _filename: path }
// backups created on XenServer < 7.1 via JSON in XML-RPC transports have boolean values encoded as integers, which make them unusable with more recent XAPIs
if (typeof metadata.vm.is_a_template === 'number') {
const properties = {
vbds: ['bootable', 'unpluggable', 'storage_lock', 'empty', 'currently_attached'],
vdis: [
'sharable',
'read_only',
'storage_lock',
'managed',
'missing',
'is_a_snapshot',
'allow_caching',
'metadata_latest',
],
vifs: ['currently_attached', 'MAC_autogenerated'],
vm: ['is_a_template', 'is_control_domain', 'ha_always_run', 'is_a_snapshot', 'is_snapshot_from_vmpp'],
vmSnapshot: ['is_a_template', 'is_control_domain', 'ha_always_run', 'is_snapshot_from_vmpp'],
}
function fixBooleans(obj, properties) {
properties.forEach(property => {
if (typeof obj[property] === 'number') {
obj[property] = obj[property] === 1
}
})
}
for (const [key, propertiesInKey] of Object.entries(properties)) {
const value = metadata[key]
if (value !== undefined) {
// some properties of the metadata are collections indexed by the opaqueRef
const isCollection = Object.keys(value).some(subKey => subKey.startsWith('OpaqueRef:'))
if (isCollection) {
Object.values(value).forEach(subValue => fixBooleans(subValue, propertiesInKey))
} else {
fixBooleans(value, propertiesInKey)
}
}
}
}
return metadata
}
}

View File

@@ -6,11 +6,13 @@ const groupBy = require('lodash/groupBy.js')
const ignoreErrors = require('promise-toolbox/ignoreErrors')
const keyBy = require('lodash/keyBy.js')
const mapValues = require('lodash/mapValues.js')
const vhdStreamValidator = require('vhd-lib/vhdStreamValidator.js')
const { asyncMap } = require('@xen-orchestra/async-map')
const { createLogger } = require('@xen-orchestra/log')
const { decorateMethodsWith } = require('@vates/decorate-with')
const { defer } = require('golike-defer')
const { formatDateTime } = require('@xen-orchestra/xapi')
const { pipeline } = require('node:stream')
const { DeltaBackupWriter } = require('./writers/DeltaBackupWriter.js')
const { DeltaReplicationWriter } = require('./writers/DeltaReplicationWriter.js')
@@ -44,6 +46,8 @@ const forkDeltaExport = deltaExport =>
},
})
const noop = Function.prototype
class VmBackup {
constructor({
config,
@@ -245,7 +249,17 @@ class VmBackup {
const deltaExport = await exportDeltaVm(exportedVm, baseVm, {
fullVdisRequired,
})
// since NBD is network based, if one disk use nbd , all the disk use them
// except the suspended VDI
if (Object.values(deltaExport.streams).some(({ _nbd }) => _nbd)) {
Task.info('Transfer data using NBD')
}
const sizeContainers = mapValues(deltaExport.streams, stream => watchStreamSize(stream))
if (this._settings.validateVhdStreams) {
deltaExport.streams = mapValues(deltaExport.streams, stream => pipeline(stream, vhdStreamValidator, noop))
}
deltaExport.streams = mapValues(deltaExport.streams, this._throttleStream)
const timestamp = Date.now()
@@ -253,7 +267,7 @@ class VmBackup {
await this._callWriters(
writer =>
writer.transfer({
deltaExport: forkDeltaExport(deltaExport),
deltaExport: this._writers.size > 1 ? forkDeltaExport(deltaExport) : deltaExport,
sizeContainers,
timestamp,
}),

View File

@@ -187,11 +187,11 @@ exports.importDeltaVm = defer(async function importDeltaVm(
// 0. Create suspend_VDI
let suspendVdi
if (vmRecord.power_state === 'Suspended') {
if (vmRecord.suspend_VDI !== undefined && vmRecord.suspend_VDI !== 'OpaqueRef:NULL') {
const vdi = vdiRecords[vmRecord.suspend_VDI]
if (vdi === undefined) {
Task.warning('Suspend VDI not available for this suspended VM', {
vm: pick(vmRecord, 'uuid', 'name_label'),
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
})
} else {
suspendVdi = await xapi.getRecord(

View File

@@ -94,13 +94,13 @@ In case any incoherence is detected, the file is deleted so it will be fully gen
job.start(data: { mode: Mode, reportWhen: ReportWhen })
├─ task.info(message: 'vms', data: { vms: string[] })
├─ task.warning(message: string)
├─ task.start(data: { type: 'VM', id: string })
├─ task.start(data: { type: 'VM', id: string, name_label?: string })
│ ├─ task.warning(message: string)
| ├─ task.start(message: 'clean-vm')
│ │ └─ task.end
│ ├─ task.start(message: 'snapshot')
│ │ └─ task.end
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string, isFull: boolean })
│ ├─ task.start(message: 'export', data: { type: 'SR' | 'remote', id: string, name_label?: string, isFull: boolean })
│ │ ├─ task.warning(message: string)
│ │ ├─ task.start(message: 'transfer')
│ │ │ ├─ task.warning(message: string)

View File

@@ -0,0 +1,35 @@
#!/bin/sh
# This script must be executed at the start of the machine.
#
# It must run as root to be able to use xenstore-read and xenstore-write
# fail in case of error or undefined variable
set -eu
# stop there if a health check is not in progress
if [ "$(xenstore-read vm-data/xo-backup-health-check 2>&1)" != planned ]
then
exit
fi
# not necessary, but informs XO that this script has started which helps diagnose issues
xenstore-write vm-data/xo-backup-health-check running
# put your test here
#
# in this example, the command `sqlite3` is used to validate the health of a database
# and its output is captured and passed to XO via the XenStore in case of error
if output=$(sqlite3 ~/my-database.sqlite3 .table 2>&1)
then
# inform XO everything is ok
xenstore-write vm-data/xo-backup-health-check success
else
# inform XO there is an issue
xenstore-write vm-data/xo-backup-health-check failure
# more info about the issue can be written to `vm-data/health-check-error`
#
# it will be shown in XO
xenstore-write vm-data/xo-backup-health-check-error "$output"
fi

View File

@@ -8,13 +8,13 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.32.0",
"version": "0.36.1",
"engines": {
"node": ">=14.6"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node--test"
"test-integration": "node--test *.integ.js"
},
"dependencies": {
"@kldzj/stream-throttle": "^1.1.1",
@@ -24,10 +24,10 @@
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "^1.0.1",
"@vates/nbd-client": "^1.2.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.3.2",
"@xen-orchestra/fs": "^3.3.4",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1",
@@ -42,7 +42,7 @@
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"uuid": "^9.0.0",
"vhd-lib": "^4.2.1",
"vhd-lib": "^4.4.0",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -52,7 +52,7 @@
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^2.0.0"
"@xen-orchestra/xapi": "^2.2.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -20,9 +20,8 @@ const { AbstractDeltaWriter } = require('./_AbstractDeltaWriter.js')
const { checkVhd } = require('./_checkVhd.js')
const { packUuid } = require('./_packUuid.js')
const { Disposable } = require('promise-toolbox')
const NbdClient = require('@vates/nbd-client')
const { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
@@ -200,41 +199,12 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
await checkVhd(handler, parentPath)
}
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
let nbdClient
if (this._backup.config.useNbd && adapter.useVhdDirectory()) {
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()
// this will inform the xapi that we don't need this anymore
// and will detach the vdi from dom0
$defer(() => nbdClient.disconnect())
info('NBD client ready', { vdi: id, path })
Task.info('NBD used')
} catch (error) {
Task.warning('NBD configured but unusable', { error })
nbdClient = undefined
warn('error connecting to NBD server', { error, vdi: id, path })
}
} else {
debug('useNbd is disabled', { vdi: id, path })
}
transferSize += await adapter.writeVhd(path, deltaExport.streams[`${id}.vhd`], {
// no checksum for VHDs, because they will be invalidated by
// merges and chainings
checksum: false,
validator: tmpPath => checkVhd(handler, tmpPath),
writeBlockConcurrency: this._backup.config.writeBlockConcurrency,
nbdClient,
})
if (isDelta) {

View File

@@ -45,11 +45,13 @@ exports.DeltaReplicationWriter = class DeltaReplicationWriter extends MixinRepli
data: {
id: this._sr.uuid,
isFull,
name_label: this._sr.name_label,
type: 'SR',
},
})
this.transfer = task.wrapFn(this.transfer)
this.cleanup = task.wrapFn(this.cleanup, true)
this.cleanup = task.wrapFn(this.cleanup)
this.healthCheck = task.wrapFn(this.healthCheck, true)
return task.run(() => this._prepare())
}

View File

@@ -21,6 +21,7 @@ exports.FullReplicationWriter = class FullReplicationWriter extends MixinReplica
name: 'export',
data: {
id: props.sr.uuid,
name_label: this._sr.name_label,
type: 'SR',
// necessary?

View File

@@ -80,7 +80,7 @@ exports.MixinBackupWriter = (BaseClass = Object) =>
assert.notStrictEqual(
this._metadataFileName,
undefined,
'Metadata file name should be defined before making a healthcheck'
'Metadata file name should be defined before making a health check'
)
return Task.run(
{

View File

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

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "3.3.2",
"version": "3.3.4",
"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",
@@ -29,7 +29,7 @@
"@vates/async-each": "^1.0.0",
"@vates/coalesce-calls": "^0.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.0.1",
"@vates/read-chunk": "^1.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"bind-property-descriptor": "^2.0.0",

View File

@@ -9,13 +9,11 @@ import {
ListObjectsV2Command,
PutObjectCommand,
S3Client,
UploadPartCommand,
UploadPartCopyCommand,
} from '@aws-sdk/client-s3'
import { Upload } from '@aws-sdk/lib-storage'
import { NodeHttpHandler } from '@aws-sdk/node-http-handler'
import { getApplyMd5BodyChecksumPlugin } from '@aws-sdk/middleware-apply-body-checksum'
import assert from 'assert'
import { Agent as HttpAgent } from 'http'
import { Agent as HttpsAgent } from 'https'
import pRetry from 'promise-toolbox/retry'
@@ -24,7 +22,6 @@ import { decorateWith } from '@vates/decorate-with'
import { PassThrough, pipeline } from 'stream'
import { parse } from 'xo-remote-parser'
import copyStreamToBuffer from './_copyStreamToBuffer.js'
import createBufferFromStream from './_createBufferFromStream.js'
import guessAwsRegion from './_guessAwsRegion.js'
import RemoteHandlerAbstract from './abstract'
import { basename, join, split } from './path'
@@ -33,12 +30,7 @@ import { asyncEach } from '@vates/async-each'
// endpoints https://docs.aws.amazon.com/general/latest/gr/s3.html
// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
const MIN_PART_SIZE = 1024 * 1024 * 5 // 5MB
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PARTS_COUNT = 10000
const MAX_OBJECT_SIZE = 1024 * 1024 * 1024 * 1024 * 5 // 5TB
const IDEAL_FRAGMENT_SIZE = Math.ceil(MAX_OBJECT_SIZE / MAX_PARTS_COUNT) // the smallest fragment size that still allows a 5TB upload in 10000 fragments, about 524MB
const { warn } = createLogger('xo:fs:s3')
export default class S3Handler extends RemoteHandlerAbstract {
@@ -198,8 +190,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
const upload = new Upload({
client: this._s3,
queueSize: 1,
partSize: IDEAL_FRAGMENT_SIZE,
params: {
...this._createParams(path),
Body,
@@ -396,138 +386,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
} while (NextContinuationToken !== undefined)
}
async _write(file, buffer, position) {
if (typeof file !== 'string') {
file = file.fd
}
const uploadParams = this._createParams(file)
let fileSize
try {
fileSize = +(await this._s3.send(new HeadObjectCommand(uploadParams))).ContentLength
} catch (e) {
if (e.name === 'NotFound') {
fileSize = 0
} else {
throw e
}
}
if (fileSize < MIN_PART_SIZE) {
const resultBuffer = Buffer.alloc(Math.max(fileSize, position + buffer.length))
if (fileSize !== 0) {
const result = await this._s3.send(new GetObjectCommand(uploadParams))
await copyStreamToBuffer(result.Body, resultBuffer)
} else {
Buffer.alloc(0).copy(resultBuffer)
}
buffer.copy(resultBuffer, position)
await this._s3.send(
new PutObjectCommand({
...uploadParams,
Body: resultBuffer,
})
)
return { buffer, bytesWritten: buffer.length }
} else {
// using this trick: https://stackoverflow.com/a/38089437/72637
// multipart fragments have a minimum size of 5Mo and a max of 5Go unless they are last
// splitting the file in 3 parts: [prefix, edit, suffix]
// if `prefix` is bigger than 5Mo, it will be sourced from uploadPartCopy()
// otherwise otherwise it will be downloaded, concatenated to `edit`
// `edit` will always be an upload part
// `suffix` will always be sourced from uploadPartCopy()
// Then everything will be sliced in 5Gb parts before getting uploaded
const multipartParams = await this._s3.send(new CreateMultipartUploadCommand(uploadParams))
const copyMultipartParams = {
...multipartParams,
CopySource: this._makeCopySource(file),
}
try {
const parts = []
const prefixSize = position
let suffixOffset = prefixSize + buffer.length
let suffixSize = Math.max(0, fileSize - suffixOffset)
let hasSuffix = suffixSize > 0
let editBuffer = buffer
let editBufferOffset = position
let partNumber = 1
let prefixPosition = 0
// use floor() so that last fragment is handled in the if bellow
let fragmentsCount = Math.floor(prefixSize / MAX_PART_SIZE)
const prefixFragmentSize = MAX_PART_SIZE
let prefixLastFragmentSize = prefixSize - prefixFragmentSize * fragmentsCount
if (prefixLastFragmentSize >= MIN_PART_SIZE) {
// the last fragment of the prefix is smaller than MAX_PART_SIZE, but bigger than the minimum
// so we can copy it too
fragmentsCount++
prefixLastFragmentSize = 0
}
for (let i = 0; i < fragmentsCount; i++) {
const fragmentEnd = Math.min(prefixPosition + prefixFragmentSize, prefixSize)
assert.strictEqual(fragmentEnd - prefixPosition <= MAX_PART_SIZE, true)
const range = `bytes=${prefixPosition}-${fragmentEnd - 1}`
const copyPrefixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: range }
const part = await this._s3.send(new UploadPartCopyCommand(copyPrefixParams))
parts.push({ ETag: part.CopyPartResult.ETag, PartNumber: copyPrefixParams.PartNumber })
prefixPosition += prefixFragmentSize
}
if (prefixLastFragmentSize) {
// grab everything from the prefix that was too small to be copied, download and merge to the edit buffer.
const downloadParams = { ...uploadParams, Range: `bytes=${prefixPosition}-${prefixSize - 1}` }
let prefixBuffer
if (prefixSize > 0) {
const result = await this._s3.send(new GetObjectCommand(downloadParams))
prefixBuffer = await createBufferFromStream(result.Body)
} else {
prefixBuffer = Buffer.alloc(0)
}
editBuffer = Buffer.concat([prefixBuffer, buffer])
editBufferOffset -= prefixLastFragmentSize
}
if (hasSuffix && editBuffer.length < MIN_PART_SIZE) {
// the edit fragment is too short and is not the last fragment
// let's steal from the suffix fragment to reach the minimum size
// the suffix might be too short and itself entirely absorbed in the edit fragment, making it the last one.
const complementSize = Math.min(MIN_PART_SIZE - editBuffer.length, suffixSize)
const complementOffset = editBufferOffset + editBuffer.length
suffixOffset += complementSize
suffixSize -= complementSize
hasSuffix = suffixSize > 0
const prefixRange = `bytes=${complementOffset}-${complementOffset + complementSize - 1}`
const downloadParams = { ...uploadParams, Range: prefixRange }
const result = await this._s3.send(new GetObjectCommand(downloadParams))
const complementBuffer = await createBufferFromStream(result.Body)
editBuffer = Buffer.concat([editBuffer, complementBuffer])
}
const editParams = { ...multipartParams, Body: editBuffer, PartNumber: partNumber++ }
const editPart = await this._s3.send(new UploadPartCommand(editParams))
parts.push({ ETag: editPart.ETag, PartNumber: editParams.PartNumber })
if (hasSuffix) {
// use ceil because the last fragment can be arbitrarily small.
const suffixFragments = Math.ceil(suffixSize / MAX_PART_SIZE)
let suffixFragmentOffset = suffixOffset
for (let i = 0; i < suffixFragments; i++) {
const fragmentEnd = suffixFragmentOffset + MAX_PART_SIZE
assert.strictEqual(Math.min(fileSize, fragmentEnd) - suffixFragmentOffset <= MAX_PART_SIZE, true)
const suffixRange = `bytes=${suffixFragmentOffset}-${Math.min(fileSize, fragmentEnd) - 1}`
const copySuffixParams = { ...copyMultipartParams, PartNumber: partNumber++, CopySourceRange: suffixRange }
const suffixPart = (await this._s3.send(new UploadPartCopyCommand(copySuffixParams))).CopyPartResult
parts.push({ ETag: suffixPart.ETag, PartNumber: copySuffixParams.PartNumber })
suffixFragmentOffset = fragmentEnd
}
}
await this._s3.send(
new CompleteMultipartUploadCommand({
...multipartParams,
MultipartUpload: { Parts: parts },
})
)
} catch (e) {
await this._s3.send(new AbortMultipartUploadCommand(multipartParams))
throw e
}
}
}
async _openFile(path, flags) {
return path
}

View File

@@ -18,6 +18,7 @@
"@novnc/novnc": "^1.3.0",
"@types/d3-time-format": "^4.0.0",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.8",
"@vueuse/core": "^9.5.0",
"@vueuse/math": "^9.5.0",
"complex-matcher": "^0.7.0",
@@ -25,13 +26,14 @@
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"highlight.js": "^11.6.0",
"human-format": "^1.0.0",
"human-format": "^1.1.0",
"iterable-backoff": "^0.1.0",
"json-rpc-2.0": "^1.3.0",
"json5": "^2.2.1",
"limit-concurrency-decorator": "^0.5.0",
"lodash-es": "^4.17.21",
"make-error": "^1.3.6",
"markdown-it": "^13.0.1",
"marked": "^4.2.12",
"pinia": "^2.0.14",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.2.37",

View File

@@ -1,25 +1,5 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #subtitle>{{ $t("following-hosts-unreachable") }}</template>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url.hostname">
<a :href="url.href" rel="noopener" target="_blank">{{ 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>
<UnreachableHostsModal />
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin />
</div>
@@ -41,21 +21,14 @@ import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
import { logicAnd } from "@vueuse/math";
import { difference } from "lodash";
import { computed, ref, watch, watchEffect } from "vue";
import { useRoute } from "vue-router";
const unreachableHostsUrls = ref<URL[]>([]);
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
import { computed } from "vue";
let link = document.querySelector(
"link[rel~='icon']"
@@ -70,7 +43,7 @@ link.href = favicon;
document.title = "XO Lite";
const xenApiStore = useXenApiStore();
const hostStore = useHostStore();
const { pool } = usePoolStore().subscribe();
useChartTheme();
const uiStore = useUiStore();
@@ -92,34 +65,14 @@ if (import.meta.env.DEV) {
);
}
const route = useRoute();
watchEffect(() => {
if (route.meta.hasStoryNav) {
return;
}
if (xenApiStore.isConnected) {
xenApiStore.init();
}
});
watch(
() => hostStore.allRecords,
(hosts, previousHosts) => {
difference(hosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.push(url)
);
});
whenever(
() => pool.value?.$ref,
async (poolRef) => {
const xenApi = xenApiStore.getXapi();
await xenApi.injectWatchEvent(poolRef);
await xenApi.startWatch();
}
);
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
const reload = () => window.location.reload();
</script>
<style lang="postcss">

View File

@@ -38,6 +38,11 @@ code * {
color: var(--color-extra-blue-d20);
}
.link:active {
.link:active,
.link.router-link-active {
color: var(--color-extra-blue-d40);
}
.link.router-link-active {
text-decoration: underline;
}

View File

@@ -12,13 +12,23 @@ html {
font-family: Poppins, sans-serif;
}
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
ol, ul {
ol,
ul {
list-style: none;
}

View File

@@ -1,75 +1,85 @@
:root {
--color-blue-scale-000: #000000;
--color-blue-scale-100: #1A1B38;
--color-blue-scale-200: #595A6F;
--color-blue-scale-300: #9899A5;
--color-blue-scale-400: #E5E5E7;
--color-blue-scale-500: #FFFFFF;
--color-blue-scale-100: #1a1b38;
--color-blue-scale-200: #595a6f;
--color-blue-scale-300: #9899a5;
--color-blue-scale-400: #e5e5e7;
--color-blue-scale-500: #ffffff;
--color-extra-blue-l60: #D1CEFB;
--color-extra-blue-l40: #BBB5F9;
--color-extra-blue-l20: #A39DF8;
--color-extra-blue-base: #8F84FF;
--color-extra-blue-d20: #716AC6;
--color-extra-blue-d40: #554F94;
--color-extra-blue-l60: #d1cefb;
--color-extra-blue-l40: #bbb5f9;
--color-extra-blue-l20: #a39df8;
--color-extra-blue-base: #8f84ff;
--color-extra-blue-d20: #716ac6;
--color-extra-blue-d40: #554f94;
--color-extra-blue-d60: #383563;
--color-green-infra-l60: #B5DBCA;
--color-green-infra-l40: #91C9B0;
--color-green-infra-l20: #70B795;
--color-green-infra-base: #55A57B;
--color-green-infra-l60: #b5dbca;
--color-green-infra-l40: #91c9b0;
--color-green-infra-l20: #70b795;
--color-green-infra-base: #55a57b;
--color-green-infra-d20: #438463;
--color-green-infra-d40: #32634A;
--color-green-infra-d40: #32634a;
--color-green-infra-d60: #214231;
--color-orange-world-l60: #F2CDA8;
--color-orange-world-l40: #EBB57D;
--color-orange-world-l20: #E59D56;
--color-orange-world-base: #EF7F18;
--color-orange-world-d20: #BF6612;
--color-orange-world-d40: #864F1F;
--color-orange-world-d60: #5A3514;
--color-orange-world-l60: #f2cda8;
--color-orange-world-l40: #ebb57d;
--color-orange-world-l20: #e59d56;
--color-orange-world-base: #ef7f18;
--color-orange-world-d20: #bf6612;
--color-orange-world-d40: #864f1f;
--color-orange-world-d60: #5a3514;
--color-red-vates-l60: #DDA5A7;
--color-red-vates-l40: #CE787C;
--color-red-vates-l20: #BF4F51;
--color-red-vates-base: #BE1621;
--color-red-vates-d20: #8E2221;
--color-red-vates-d40: #6A1919;
--color-red-vates-l60: #dda5a7;
--color-red-vates-l40: #ce787c;
--color-red-vates-l20: #bf4f51;
--color-red-vates-base: #be1621;
--color-red-vates-d20: #8e2221;
--color-red-vates-d40: #6a1919;
--color-red-vates-d60: #471010;
--color-grayscale-200: #585757;
--background-color-primary: #FFFFFF;
--background-color-secondary: #F6F6F7;
--background-color-extra-blue: #F4F3FE;
--background-color-green-infra: #ECF5F2;
--background-color-orange-world: #FBF2E9;
--background-color-red-vates: #F5E8E9;
--background-color-primary: #ffffff;
--background-color-secondary: #f6f6f7;
--background-color-extra-blue: #f4f3fe;
--background-color-green-infra: #ecf5f2;
--background-color-orange-world: #fbf2e9;
--background-color-red-vates: #f5e8e9;
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.06);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.06), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.08);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.06), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1),
0 0.2rem 0.1rem rgba(20, 20, 30, 0.06),
0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1),
0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1rem rgba(20, 20, 30, 0.08);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1),
0 0.9rem 4.6rem rgba(20, 20, 30, 0.06),
0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
}
:root.dark {
--color-blue-scale-000: #FFFFFF;
--color-blue-scale-100: #E5E5E7;
--color-blue-scale-200: #9899A5;
--color-blue-scale-300: #595A6F;
--color-blue-scale-400: #1A1B38;
--color-blue-scale-000: #ffffff;
--color-blue-scale-100: #e5e5e7;
--color-blue-scale-200: #9899a5;
--color-blue-scale-300: #595a6f;
--color-blue-scale-400: #1a1b38;
--color-blue-scale-500: #000000;
--background-color-primary: #14141D;
--background-color-secondary: #17182A;
--background-color-extra-blue: #35335D;
--background-color-green-infra: #243B3D;
--background-color-primary: #14141d;
--background-color-secondary: #17182a;
--background-color-extra-blue: #35335d;
--background-color-green-infra: #243b3d;
--background-color-orange-world: #493328;
--background-color-red-vates: #3C1A28;
--background-color-red-vates: #3c1a28;
--shadow-100: 0 0.1rem 0.1rem rgba(20, 20, 30, 0.12);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.2), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.12), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.16);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.2), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.12), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.16);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.2), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.12), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.08);
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.2),
0 0.2rem 0.1rem rgba(20, 20, 30, 0.12),
0 0.1rem 0.1rem rgba(20, 20, 30, 0.16);
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.2),
0 0.1rem 1.8rem rgba(20, 20, 30, 0.12), 0 0.6rem 1rem rgba(20, 20, 30, 0.16);
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.2),
0 0.9rem 4.6rem rgba(20, 20, 30, 0.12),
0 2.4rem 3.8rem rgba(20, 20, 30, 0.08);
}

View File

@@ -3,10 +3,10 @@
</template>
<script lang="ts" setup>
import { type Ref, computed, ref } from "vue";
import markdown from "@/libs/markdown";
import { useEventListener } from "@vueuse/core";
import "highlight.js/styles/github-dark.css";
import { markdown } from "@/libs/markdown";
import { computed, type Ref, ref } from "vue";
const rootElement = ref() as Ref<HTMLElement>;
@@ -14,7 +14,7 @@ const props = defineProps<{
content: string;
}>();
const html = computed(() => markdown.render(props.content ?? ""));
const html = computed(() => markdown.parse(props.content ?? ""));
useEventListener(
rootElement,
@@ -96,6 +96,7 @@ useEventListener(
position: absolute;
z-index: 1;
right: 1rem;
top: 0.4rem;
cursor: pointer;
color: white;
border: none;

View File

@@ -1,15 +1,15 @@
<template>
<div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip">
<span class="triangle" />
<span class="label">{{ content }}</span>
<span class="label">{{ options.content }}</span>
</div>
</template>
<script lang="ts" setup>
import { isEmpty, isFunction, isString } from "lodash-es";
import type { TooltipOptions } from "@/stores/tooltip.store";
import { isString } from "lodash-es";
import place from "placement.js";
import { computed, ref, watchEffect } from "vue";
import type { TooltipOptions } from "@/stores/tooltip.store";
const props = defineProps<{
target: HTMLElement;
@@ -18,29 +18,13 @@ const props = defineProps<{
const tooltipElement = ref<HTMLElement>();
const content = computed(() =>
isString(props.options) ? props.options : props.options.content
const isDisabled = computed(() =>
isString(props.options.content)
? props.options.content.trim() === ""
: props.options.content === false
);
const isDisabled = computed(() => {
if (isEmpty(content.value)) {
return true;
}
if (isString(props.options)) {
return false;
}
if (isFunction(props.options.disabled)) {
return props.options.disabled(props.target);
}
return props.options.disabled ?? false;
});
const placement = computed(() =>
isString(props.options) ? "top" : props.options.placement ?? "top"
);
const placement = computed(() => props.options.placement ?? "top");
watchEffect(() => {
if (tooltipElement.value) {

View File

@@ -14,7 +14,12 @@
</UiActionButton>
</UiFilterGroup>
<UiModal v-if="isOpen" :icon="faFilter" @submit.prevent="handleSubmit">
<UiModal
v-if="isOpen"
:icon="faFilter"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"

View File

@@ -17,7 +17,12 @@
</UiActionButton>
</UiFilterGroup>
<UiModal v-if="isOpen" :icon="faSort" @submit.prevent="handleSubmit">
<UiModal
v-if="isOpen"
:icon="faSort"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">

View File

@@ -18,24 +18,27 @@
/>
</div>
<UiTable>
<template #header>
<td v-if="isSelectable">
<input v-model="areAllSelected" type="checkbox" />
</td>
<slot name="header" />
</template>
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
<td v-if="isSelectable">
<input
v-model="selected"
:value="item[props.idProperty]"
type="checkbox"
/>
</td>
<slot :item="item" name="row" />
</tr>
<UiTable vertical-border>
<thead>
<tr>
<td v-if="isSelectable">
<input v-model="areAllSelected" type="checkbox" />
</td>
<slot name="head-row" />
</tr>
</thead>
<tbody>
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
<td v-if="isSelectable">
<input
v-model="selected"
:value="item[props.idProperty]"
type="checkbox"
/>
</td>
<slot :item="item" name="body-row" />
</tr>
</tbody>
</UiTable>
</template>

View File

@@ -1,33 +1,31 @@
<template>
<div class="wrapper-spinner" v-if="store.isLoading">
<div v-if="!isReady" class="wrapper-spinner">
<UiSpinner class="spinner" />
</div>
<ObjectNotFoundView :id="id" v-else-if="isRecordNotFound" />
<ObjectNotFoundView v-else-if="isRecordNotFound" :id="id" />
<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 ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
const storeByType = {
vm: useVmStore,
host: useHostStore,
};
const props = defineProps<{
isReady: boolean;
uuidChecker: (uuid: string) => boolean;
id?: string;
}>();
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)
() => props.isReady && !props.uuidChecker(id.value)
);
</script>

View File

@@ -3,9 +3,16 @@
</template>
<script lang="ts" setup>
import { onBeforeUnmount, ref, watchEffect } from "vue";
import { fibonacci } from "iterable-backoff";
import { computed, onBeforeUnmount, ref, watch, watchEffect } from "vue";
import VncClient from "@novnc/novnc/core/rfb";
import { useXenApiStore } from "@/stores/xen-api.store";
import { promiseTimeout } from "@vueuse/shared";
const N_TOTAL_TRIES = 8;
const FIBONACCI_MS_ARRAY: number[] = Array.from(
fibonacci().toMs().take(N_TOTAL_TRIES)
);
const props = defineProps<{
location: string;
@@ -14,37 +21,84 @@ const props = defineProps<{
const vmConsoleContainer = ref<HTMLDivElement>();
const xenApiStore = useXenApiStore();
let vncClient: VncClient | undefined;
const url = computed(() => {
if (xenApiStore.currentSessionId == null) {
return;
}
const _url = new URL(props.location);
_url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
_url.searchParams.set("session_id", xenApiStore.currentSessionId);
return _url;
});
const clearVncClient = () => {
if (vncClient !== undefined) {
if (vncClient._rfbConnectionState !== "disconnected") {
vncClient.disconnect();
let vncClient: VncClient | undefined;
let nConnectionAttempts = 0;
const handleDisconnectionEvent = () => {
clearVncClient();
if (props.isConsoleAvailable) {
nConnectionAttempts++;
if (nConnectionAttempts > N_TOTAL_TRIES) {
console.error(
"The number of reconnection attempts has been exceeded for:",
props.location
);
return;
}
vncClient = undefined;
console.error(
`Connection lost for the remote console: ${
props.location
}. New attempt in ${FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]}ms`
);
createVncConnection();
}
};
const handleConnectionEvent = () => (nConnectionAttempts = 0);
const clearVncClient = () => {
if (vncClient === undefined) {
return;
}
vncClient.removeEventListener("disconnect", handleDisconnectionEvent);
vncClient.removeEventListener("connect", handleConnectionEvent);
if (vncClient._rfbConnectionState !== "disconnected") {
vncClient.disconnect();
}
vncClient = undefined;
};
const createVncConnection = async () => {
if (nConnectionAttempts !== 0) {
await promiseTimeout(FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]);
}
vncClient = new VncClient(vmConsoleContainer.value!, url.value!.toString(), {
wsProtocols: ["binary"],
});
vncClient.scaleViewport = true;
vncClient.addEventListener("disconnect", handleDisconnectionEvent);
vncClient.addEventListener("connect", handleConnectionEvent);
};
watch(url, clearVncClient);
watchEffect(() => {
if (
!vmConsoleContainer.value ||
!xenApiStore.currentSessionId ||
url.value === undefined ||
vmConsoleContainer.value === undefined ||
!props.isConsoleAvailable
) {
return;
}
clearVncClient();
const url = new URL(props.location);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("session_id", xenApiStore.currentSessionId);
vncClient = new VncClient(vmConsoleContainer.value, url.toString(), {
wsProtocols: ["binary"],
});
vncClient.scaleViewport = true;
nConnectionAttempts = 0;
createVncConnection();
});
onBeforeUnmount(() => {

View File

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

View File

@@ -19,6 +19,7 @@
class="preset-tab"
@click="open"
>
<UiIcon :icon="faSliders" />
Presets
</UiTab>
</template>
@@ -105,6 +106,7 @@ import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiTab from "@/components/ui/UiTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import {
@@ -116,6 +118,7 @@ import {
ModelParam,
type Param,
} from "@/libs/story/story-param";
import { faSliders } from "@fortawesome/free-solid-svg-icons";
import "highlight.js/styles/github-dark.css";
import { uniqueId, upperFirst } from "lodash-es";
import { computed, reactive, ref, watch, watchEffect } from "vue";
@@ -174,8 +177,8 @@ if (propParams.value.length !== 0) {
selectedTab.value = TAB.SETTINGS;
}
const propValues = reactive<Record<string, any>>({});
const settingValues = reactive<Record<string, any>>({});
const propValues = ref<Record<string, any>>({});
const settingValues = ref<Record<string, any>>({});
const eventsLog = ref<
{ id: string; name: string; args: { name: string; value: any }[] }[]
>([]);
@@ -183,13 +186,13 @@ const unreadEventsCount = ref(0);
const resetProps = () => {
propParams.value.forEach((param) => {
propValues[param.name] = param.getPresetValue();
propValues.value[param.name] = param.getPresetValue();
});
};
const resetSettings = () => {
settingParams.value.forEach((param) => {
settingValues[param.name] = param.getPresetValue();
settingValues.value[param.name] = param.getPresetValue();
});
};
@@ -234,13 +237,13 @@ const slotProperties = computed(() => {
const properties: Record<string, any> = {};
propParams.value.forEach(({ name }) => {
properties[name] = propValues[name];
properties[name] = propValues.value[name];
});
eventParams.value.forEach((eventParam) => {
properties[`on${upperFirst(eventParam.name)}`] = (...args: any[]) => {
if (eventParam.isVModel()) {
propValues[eventParam.rawName] = args[0];
propValues.value[eventParam.rawName] = args[0];
}
const logArgs = Object.keys(eventParam.getArguments()).map(
(argName, index) => ({
@@ -260,7 +263,7 @@ const slotSettings = computed(() => {
const result: Record<string, any> = {};
settingParams.value.forEach(({ name }) => {
result[name] = settingValues[name];
result[name] = settingValues.value[name];
});
return result;
@@ -284,13 +287,13 @@ const applyPreset = (preset: {
}) => {
if (preset.props !== undefined) {
Object.entries(preset.props).forEach(([name, value]) => {
propValues[name] = value;
propValues.value[name] = value;
});
}
if (preset.settings !== undefined) {
Object.entries(preset.settings).forEach(([name, value]) => {
settingValues[name] = value;
settingValues.value[name] = value;
});
}
};

View File

@@ -2,7 +2,7 @@
<StoryParamsTable>
<thead>
<tr>
<th>Prop</th>
<th>Setting</th>
<th><!--Widget--></th>
<th>Help</th>
</tr>

View File

@@ -1,12 +1,5 @@
<template>
<li
v-if="host"
class="infra-host-item"
v-tooltip="{
content: host.name_label,
disabled: isTooltipDisabled,
}"
>
<li v-if="host !== undefined" class="infra-host-item">
<InfraItemLabel
:active="isCurrentHost"
:icon="faServer"
@@ -15,10 +8,10 @@
{{ host.name_label || "(Host)" }}
<template #actions>
<InfraAction
:icon="faStar"
class="master-icon"
v-if="isPoolMaster"
v-tooltip="'Master'"
:icon="faStar"
class="master-icon"
/>
<InfraAction
:icon="isExpanded ? faAngleDown : faAngleUp"
@@ -32,8 +25,13 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import {
faAngleDown,
faAngleUp,
@@ -41,25 +39,18 @@ import {
faStar,
} from "@fortawesome/free-solid-svg-icons";
import { useToggle } from "@vueuse/core";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { hasEllipsis } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import { computed } from "vue";
const props = defineProps<{
hostOpaqueRef: string;
}>();
const hostStore = useHostStore();
const host = computed(() => hostStore.getRecord(props.hostOpaqueRef));
const { getByOpaqueRef } = useHostStore().subscribe();
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
const poolStore = usePoolStore();
const isPoolMaster = computed(
() => poolStore.pool?.master === props.hostOpaqueRef
);
const { pool } = usePoolStore().subscribe();
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
const uiStore = useUiStore();
@@ -67,17 +58,16 @@ const isCurrentHost = computed(
() => props.hostOpaqueRef === uiStore.currentHostOpaqueRef
);
const [isExpanded, toggle] = useToggle(true);
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>
.infra-host-item:deep(.link) {
.infra-host-item:deep(.link),
.infra-host-item:deep(.link-placeholder) {
padding-left: 3rem;
}
.infra-vm-list:deep(.link) {
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 4.5rem;
}

View File

@@ -1,26 +1,24 @@
<template>
<ul class="infra-host-list">
<li v-if="isLoading">{{ $t("loading-hosts") }}</li>
<li v-else-if="hasError" class="text-error">
<li v-if="hasError" class="text-error">
{{ $t("error-no-data") }}
</li>
<li v-else-if="!isReady">{{ $t("loading-hosts") }}</li>
<template v-else>
<InfraHostItem
v-for="opaqueRef in opaqueRefs"
:key="opaqueRef"
:host-opaque-ref="opaqueRef"
v-for="host in hosts"
:key="host.$ref"
:host-opaque-ref="host.$ref"
/>
</template>
</ul>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
import { useHostStore } from "@/stores/host.store";
const hostStore = useHostStore();
const { hasError, isLoading, opaqueRefs } = storeToRefs(hostStore);
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
</script>
<style lang="postcss" scoped>

View File

@@ -7,9 +7,9 @@
class="infra-item-label"
v-bind="$attrs"
>
<a :href="href" class="link" @click="navigate">
<a :href="href" class="link" @click="navigate" v-tooltip="hasTooltip">
<UiIcon :icon="icon" class="icon" />
<div class="text">
<div ref="textElement" class="text">
<slot />
</div>
</a>
@@ -22,7 +22,10 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { computed, ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
defineProps<{
@@ -30,6 +33,9 @@ defineProps<{
route: RouteLocationRaw;
active?: boolean;
}>();
const textElement = ref<HTMLElement>();
const hasTooltip = computed(() => hasEllipsis(textElement.value));
</script>
<style lang="postcss" scoped>

View File

@@ -1,12 +1,12 @@
<template>
<ul class="infra-pool-list">
<InfraLoadingItem
v-if="isLoading || pool === undefined"
:icon="faBuilding"
/>
<li v-else-if="hasError" class="text-error">
<li v-if="hasError" class="text-error">
{{ $t("error-no-data") }}
</li>
<InfraLoadingItem
v-else-if="!isReady || pool === undefined"
:icon="faBuilding"
/>
<li v-else class="infra-pool-item">
<InfraItemLabel
:icon="faBuilding"
@@ -24,16 +24,14 @@
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import InfraHostList from "@/components/infra/InfraHostList.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { usePoolStore } from "@/stores/pool.store";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
const poolStore = usePoolStore();
const { hasError, isLoading, pool } = storeToRefs(poolStore);
const { isReady, hasError, pool } = usePoolStore().subscribe();
</script>
<style lang="postcss" scoped>
@@ -42,7 +40,8 @@ const { hasError, isLoading, pool } = storeToRefs(poolStore);
font-weight: 500;
}
.infra-vm-list:deep(.link) {
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 3rem;
}

View File

@@ -1,12 +1,5 @@
<template>
<li
ref="rootElement"
class="infra-vm-item"
v-tooltip="{
content: vm.name_label,
disabled: isTooltipDisabled,
}"
>
<li v-if="vm !== undefined" ref="rootElement" class="infra-vm-item">
<InfraItemLabel
v-if="isVisible"
:icon="faDisplay"
@@ -15,7 +8,7 @@
{{ vm.name_label || "(VM)" }}
<template #actions>
<InfraAction>
<PowerStateIcon :state="vm?.power_state" />
<PowerStateIcon :state="vm.power_state" />
</InfraAction>
</template>
</InfraItemLabel>
@@ -23,20 +16,20 @@
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import { hasEllipsis } from "@/libs/utils";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
import { computed, ref } from "vue";
const props = defineProps<{
vmOpaqueRef: string;
}>();
const { getByOpaqueRef } = useVmStore().subscribe();
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
const rootElement = ref();
const isVisible = ref(false);
@@ -46,13 +39,6 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
stop();
}
});
const vmStore = useVmStore();
const vm = computed(() => vmStore.getRecord(props.vmOpaqueRef));
const isTooltipDisabled = (target: HTMLElement) =>
!hasEllipsis(target.querySelector(".text"));
</script>
<style lang="postcss" scoped>

View File

@@ -1,38 +1,32 @@
<template>
<ul class="infra-vm-list">
<template v-if="isLoading">
<InfraLoadingItem v-for="i in 3" :icon="faDisplay" :key="i" />
<li v-if="hasError" class="text-error">{{ $t("error-no-data") }}</li>
<template v-else-if="!isReady">
<InfraLoadingItem v-for="i in 3" :key="i" :icon="faDisplay" />
</template>
<p class="text-error" v-else-if="hasError">{{ $t("error-no-data") }}</p>
<InfraVmItem
v-else
v-for="vmOpaqueRef in vmOpaqueRefs"
:key="vmOpaqueRef"
:vm-opaque-ref="vmOpaqueRef"
/>
<InfraVmItem v-for="vm in vms" :key="vm.$ref" :vm-opaque-ref="vm.$ref" />
</ul>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
hostOpaqueRef?: string;
}>();
const vmStore = useVmStore();
const { hasError, isLoading, opaqueRefsByHostRef } = storeToRefs(vmStore);
const vmOpaqueRefs = computed(() =>
opaqueRefsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
const vms = computed(() =>
recordsByHostRef.value.get(props.hostOpaqueRef ?? "OpaqueRef:NULL")
);
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
.text-error {
padding-left: 3rem;
font-weight: 700;

View File

@@ -10,6 +10,7 @@ import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import TitleBar from "@/components/TitleBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const poolStore = usePoolStore();
const name = computed(() => poolStore.pool?.name_label ?? "...");
const { pool } = usePoolStore().subscribe();
const name = computed(() => pool.value?.name_label ?? "...");
</script>

View File

@@ -1,43 +1,39 @@
<template>
<UiTabBar :disabled="!isReady">
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: pool?.uuid } }">
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: pool?.uuid } }">
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.stats', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.stats', params: { uuid: pool?.uuid } }">
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.system', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.system', params: { uuid: pool?.uuid } }">
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.network', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.network', params: { uuid: pool?.uuid } }">
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.storage', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.storage', params: { uuid: pool?.uuid } }">
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: pool?.uuid } }">
{{ $t("tasks") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: pool?.uuid } }">
{{ $t("hosts") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.vms', params: { uuid: poolUuid } }">
<RouterTab :to="{ name: 'pool.vms', params: { uuid: pool?.uuid } }">
{{ $t("vms") }}
</RouterTab>
</UiTabBar>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { computed } from "vue";
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const poolStore = usePoolStore();
const { pool, isReady } = storeToRefs(poolStore);
const poolUuid = computed(() => pool.value?.uuid);
const { pool, isReady } = usePoolStore().subscribe();
</script>

View File

@@ -7,9 +7,9 @@
<UiStatusIcon v-if="state !== 'success'" :state="state" />
</template>
</UiCardTitle>
<div v-if="isReady" class="progress-item" :class="state">
<UiProgressBar color="custom" :value="value" :max-value="maxValue" />
<UiProgressScale :max-value="maxValue" unit="%" :steps="1" />
<div v-if="isReady" :class="state" class="progress-item">
<UiProgressBar :max-value="maxValue" :value="value" color="custom" />
<UiProgressScale :max-value="maxValue" :steps="1" unit="%" />
<UiProgressLegend :label="$t('vcpus')" :value="`${value}%`" />
<UiCardFooter>
<template #left>
@@ -27,52 +27,62 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { storeToRefs } from "pinia";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiStatusIcon from "@/components/ui/icon/UiStatusIcon.vue";
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
import UiProgressLegend from "@/components/ui/progress/UiProgressLegend.vue";
import UiProgressScale from "@/components/ui/progress/UiProgressScale.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import UiStatusIcon from "@/components/ui/icon/UiStatusIcon.vue";
import { isHostRunning, percent } from "@/libs/utils";
import { percent } from "@/libs/utils";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import { useVmMetricsStore } from "@/stores/vm-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { logicAnd } from "@vueuse/math";
import { computed } from "vue";
const ACTIVE_STATES = new Set(["Running", "Paused"]);
const { allRecords: hosts, isReady: hostStoreIsReady } = storeToRefs(
useHostStore()
);
const { allRecords: vms, isReady: vmStoreIsReady } = storeToRefs(useVmStore());
const vmMetricsStore = useVmMetricsStore();
const { isReady: isHostStoreReady, runningHosts } = useHostStore().subscribe({
hostMetricsSubscription: useHostMetricsStore().subscribe(),
});
const { records: vms, isReady: isVmStoreReady } = useVmStore().subscribe();
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
useVmMetricsStore().subscribe();
const nPCpu = computed(() =>
hosts.value.reduce(
(total, host) =>
isHostRunning(host) ? total + Number(host.cpu_info.cpu_count) : total,
runningHosts.value.reduce(
(total, host) => total + Number(host.cpu_info.cpu_count),
0
)
);
const nVCpuInUse = computed(() =>
vms.value.reduce(
const nVCpuInUse = computed(() => {
if (!isReady.value) {
return 0;
}
return vms.value.reduce(
(total, vm) =>
ACTIVE_STATES.has(vm.power_state)
? total + vmMetricsStore.getRecord(vm.metrics).VCPUs_number
? total + getVmMetrics(vm.metrics)!.VCPUs_number
: total,
0
)
);
);
});
const value = computed(() =>
Math.round(percent(nVCpuInUse.value, nPCpu.value))
);
const maxValue = computed(() => Math.ceil(value.value / 100) * 100);
const state = computed(() => (value.value > 100 ? "warning" : "success"));
const isReady = computed(
() => vmStoreIsReady.value && vmMetricsStore.isReady && hostStoreIsReady.value
const isReady = logicAnd(
isVmStoreReady,
isHostStoreReady,
isVmMetricsStoreReady
);
</script>
@@ -82,14 +92,17 @@ const isReady = computed(
--progress-bar-height: 1.2rem;
--progress-bar-color: var(--color-extra-blue-base);
--progress-bar-background-color: var(--color-blue-scale-400);
&.warning {
--progress-bar-color: var(--color-orange-world-base);
--footer-value-color: var(--color-orange-world-base);
}
& .footer-value {
color: var(--footer-value-color);
}
}
.spinner {
color: var(--color-extra-blue-base);
display: flex;

View File

@@ -6,15 +6,16 @@
</UiCard>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useVmStore } from "@/stores/vm.store";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
const hasError = computed(
() => useVmStore().hasError || useHostStore().hasError
);
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const hasError = computed(() => hasVmError.value || hasHostError.value);
</script>

View File

@@ -6,16 +6,17 @@
</UiCard>
</template>
<script setup lang="ts">
import { computed } from "vue";
<script lang="ts" setup>
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useVmStore } from "@/stores/vm.store";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
const hasError = computed(
() => useVmStore().hasError || useHostStore().hasError
);
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const hasError = computed(() => hasVmError.value || hasHostError.value);
</script>

View File

@@ -1,8 +1,8 @@
<template>
<UiCard :color="hasError ? 'error' : undefined">
<UiCardTitle>{{ $t("status") }}</UiCardTitle>
<UiSpinner v-if="isLoading" class="spinner" />
<NoDataError v-else-if="hasError" />
<NoDataError v-if="hasError" />
<UiSpinner v-else-if="!isReady" class="spinner" />
<template v-else>
<PoolDashboardStatusItem
:active="activeHostsCount"
@@ -20,6 +20,7 @@
</template>
<script lang="ts" setup>
import NoDataError from "@/components/NoDataError.vue";
import PoolDashboardStatusItem from "@/components/pool/dashboard/PoolDashboardStatusItem.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
@@ -28,30 +29,33 @@ import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import NoDataError from "@/components/NoDataError.vue";
const vmStore = useVmStore();
const hostMetricsStore = useHostMetricsStore();
const {
isReady: isVmReady,
records: vms,
hasError: hasVmError,
runningVms,
} = useVmStore().subscribe();
const hasError = computed(() => vmStore.hasError || hostMetricsStore.hasError);
const {
isReady: isHostMetricsReady,
records: hostMetrics,
hasError: hasHostMetricsError,
} = useHostMetricsStore().subscribe();
const isLoading = computed(
() => vmStore.isLoading && hostMetricsStore.isLoading
const hasError = computed(() => hasVmError.value || hasHostMetricsError.value);
const isReady = computed(() => isVmReady.value && isHostMetricsReady.value);
const totalHostsCount = computed(() => hostMetrics.value.length);
const activeHostsCount = computed(
() => hostMetrics.value.filter((hostMetrics) => hostMetrics.live).length
);
const totalHostsCount = computed(() => hostMetricsStore.opaqueRefs.length);
const activeHostsCount = computed(() => {
return hostMetricsStore.opaqueRefs.filter(
(opaqueRef) => hostMetricsStore.getRecord(opaqueRef)?.live
).length;
});
const totalVmsCount = computed(() => vms.value.length);
const totalVmsCount = computed(() => vmStore.opaqueRefs.length);
const activeVmsCount = computed(() => {
return vmStore.opaqueRefs.filter(
(opaqueRef) => vmStore.getRecord(opaqueRef)?.power_state === "Running"
).length;
});
const activeVmsCount = computed(() => runningVms.value.length);
</script>
<style lang="postcss" scoped>

View File

@@ -18,18 +18,16 @@
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { computed } from "vue";
import NoDataError from "@/components/NoDataError.vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import UsageBar from "@/components/UsageBar.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useSrStore } from "@/stores/storage.store";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { computed } from "vue";
const srStore = useSrStore();
const { hasError, isReady } = storeToRefs(srStore);
const { records: srs, isReady, hasError } = useSrStore().subscribe();
const data = computed<{
result: { id: string; label: string; value: number }[];
@@ -40,7 +38,7 @@ const data = computed<{
let maxSize = 0;
let usedSize = 0;
srStore.allRecords.forEach(
srs.value.forEach(
({ name_label, physical_size, physical_utilisation, uuid }) => {
if (physical_size < 0 || physical_utilisation < 0) {
return;

View File

@@ -1,8 +1,8 @@
<template>
<UiCardTitle
subtitle
:left="$t('hosts')"
:right="$t('top-#', { n: N_ITEMS })"
subtitle
/>
<NoDataError v-if="hasError" />
<UsageBar v-else :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
@@ -16,11 +16,10 @@ import { getAvgCpuUsage } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { useHostStore } from "@/stores/host.store";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { storeToRefs } from "pinia";
import { computed, type ComputedRef, inject } from "vue";
import NoDataError from "@/components/NoDataError.vue";
const { hasError } = storeToRefs(useHostStore());
const { hasError } = useHostStore().subscribe();
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",

View File

@@ -12,14 +12,13 @@
<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 type { HostStats } from "@/libs/xapi-stats";
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";
@@ -28,7 +27,7 @@ const { t } = useI18n();
const hostLastWeekStats =
inject<FetchedStats<XenApiHost, HostStats>>("hostLastWeekStats");
const { allRecords: hosts } = storeToRefs(useHostStore());
const { records: hosts } = useHostStore().subscribe();
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)

View File

@@ -18,9 +18,8 @@ import type { VmStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useVmStore } from "@/stores/vm.store";
import { storeToRefs } from "pinia";
const { hasError } = storeToRefs(useVmStore());
const { hasError } = useVmStore().subscribe();
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",

View File

@@ -18,9 +18,8 @@ import type { HostStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useHostStore } from "@/stores/host.store";
import { storeToRefs } from "pinia";
const { hasError } = storeToRefs(useHostStore());
const { hasError } = useHostStore().subscribe();
const stats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",

View File

@@ -18,33 +18,39 @@
import LinearChart from "@/components/charts/LinearChart.vue";
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import type { FetchedStats } from "@/composables/fetch-stats.composable";
import { formatSize, getHostMemory } from "@/libs/utils";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
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 hostMetricsSubscription = useHostMetricsStore().subscribe();
const hostStore = useHostStore();
const { runningHosts } = hostStore.subscribe({ hostMetricsSubscription });
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)
sumBy(
runningHosts.value,
(host) => getHostMemory(host, hostMetricsSubscription)?.size ?? 0
)
);
const currentData = computed(() => {
let size = 0,
usage = 0;
runningHosts.value.forEach((host) => {
const hostMemory = getHostMemory(host);
const hostMemory = getHostMemory(host, hostMetricsSubscription);
size += hostMemory?.size ?? 0;
usage += hostMemory?.usage ?? 0;
});

View File

@@ -18,9 +18,8 @@ import type { VmStats } from "@/libs/xapi-stats";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useVmStore } from "@/stores/vm.store";
import { storeToRefs } from "pinia";
const { hasError } = storeToRefs(useVmStore());
const { hasError } = useVmStore().subscribe();
const stats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",

View File

@@ -1,8 +1,9 @@
<template>
<tr class="finished-task-row" :class="{ finished: !isPending }">
<tr :class="{ finished: !isPending }" class="finished-task-row">
<td>{{ task.name_label }}</td>
<td>
<RouterLink
v-if="host !== undefined"
:to="{
name: 'host.dashboard',
params: { uuid: host.uuid },
@@ -43,7 +44,7 @@ const props = defineProps<{
task: XenApiTask;
}>();
const { getRecord: getHost } = useHostStore();
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
const createdAt = computed(() => parseDateTime(props.task.created));

View File

@@ -1,20 +1,23 @@
<template>
<UiTable class="tasks-table">
<template #header>
<th>{{ $t("name") }}</th>
<th>{{ $t("object") }}</th>
<th>{{ $t("task.progress") }}</th>
<th>{{ $t("task.started") }}</th>
<th>{{ $t("task.estimated-end") }}</th>
</template>
<TaskRow
v-for="task in pendingTasks"
:key="task.uuid"
:task="task"
is-pending
/>
<TaskRow v-for="task in finishedTasks" :key="task.uuid" :task="task" />
<thead>
<tr>
<th>{{ $t("name") }}</th>
<th>{{ $t("object") }}</th>
<th>{{ $t("task.progress") }}</th>
<th>{{ $t("task.started") }}</th>
<th>{{ $t("task.estimated-end") }}</th>
</tr>
</thead>
<tbody>
<TaskRow
v-for="task in pendingTasks"
:key="task.uuid"
:task="task"
is-pending
/>
<TaskRow v-for="task in finishedTasks" :key="task.uuid" :task="task" />
</tbody>
</UiTable>
</template>

View File

@@ -16,6 +16,7 @@ defineProps<{
<style lang="postcss" scoped>
.ui-badge {
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.4rem;

View File

@@ -146,7 +146,7 @@ const className = computed(() => {
.content {
overflow: auto;
min-height: 23rem;
font-size: 1.6rem;
max-height: calc(100vh - 40rem);
margin-top: 2rem;
}

View File

@@ -1,45 +1,55 @@
<template>
<table class="ui-table">
<thead>
<tr class="header-row">
<slot name="header" />
</tr>
</thead>
<tbody class="body">
<slot />
</tbody>
<table :class="{ 'vertical-border': verticalBorder }" class="ui-table">
<slot />
</table>
</template>
<script lang="ts" setup></script>
<script lang="ts" setup>
defineProps<{
verticalBorder?: boolean;
}>();
</script>
<style lang="postcss" scoped>
.ui-table {
width: 100%;
border-spacing: 0;
}
:deep(th),
:deep(td) {
padding: 1rem;
border-top: 1px solid lightgrey;
border-right: 1px solid lightgrey;
text-align: left;
&:last-child {
border-right: none;
}
}
:deep(.header-row th) {
color: var(--color-extra-blue-base);
font-size: 1.4rem;
font-weight: 400;
text-transform: uppercase;
}
:deep(.body td) {
background-color: var(--background-color-primary);
font-weight: 400;
font-size: 1.6rem;
line-height: 2.4rem;
color: var(--color-blue-scale-200);
:deep(th),
:deep(td) {
padding: 1rem;
border-top: 1px solid var(--color-blue-scale-400);
text-align: left;
}
:deep(th) {
font-weight: 700;
}
:deep(thead) {
th,
td {
color: var(--color-extra-blue-base);
font-size: 1.4rem;
font-weight: 400;
text-transform: uppercase;
}
}
&.vertical-border {
:deep(th),
:deep(td) {
border-right: 1px solid var(--color-blue-scale-400);
&:last-child {
border-right: none;
}
}
}
}
</style>

View File

@@ -1,7 +1,13 @@
<template>
<div class="legend">
<span class="circle" />
<slot name="label">{{ label }}</slot>
<template v-if="$slots.label || label">
<span class="circle" />
<div class="label-container">
<div ref="labelElement" v-tooltip="isTooltipEnabled" class="label">
<slot name="label">{{ label }}</slot>
</div>
</div>
</template>
<UiBadge class="badge">
<slot name="value">{{ value }}</slot>
</UiBadge>
@@ -10,14 +16,23 @@
<script lang="ts" setup>
import UiBadge from "@/components/ui/UiBadge.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import { computed, ref } from "vue";
defineProps<{
label?: string;
value?: string;
}>();
const labelElement = ref<HTMLElement>();
const isTooltipEnabled = computed(() =>
hasEllipsis(labelElement.value, { vertical: true })
);
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
.badge {
font-size: 0.9em;
font-weight: 700;
@@ -25,8 +40,8 @@ defineProps<{
.circle {
display: inline-block;
width: 1rem;
height: 1rem;
min-width: 1rem;
min-height: 1rem;
border-radius: 0.5rem;
background-color: var(--progress-bar-color);
}
@@ -38,4 +53,14 @@ defineProps<{
gap: 0.5rem;
margin: 1.6em 0;
}
.label-container {
overflow: hidden;
}
.label {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -2,7 +2,7 @@
<TitleBar :icon="faDisplay">
{{ name }}
<template #actions>
<AppMenu shadow placement="bottom-end">
<AppMenu v-if="vm !== undefined" placement="bottom-end" shadow>
<template #trigger="{ open, isOpen }">
<UiButton :active="isOpen" :icon="faPowerOff" @click="open">
{{ $t("change-state") }}
@@ -10,10 +10,10 @@
</UiButton>
</template>
<MenuItem
@click="xenApi.vm.start({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'start')"
:disabled="!isHalted"
:icon="faPlay"
@click="xenApi.vm.start(vm!.$ref)"
>
{{ $t("start") }}
</MenuItem>
@@ -25,22 +25,24 @@
{{ $t("start-on-host") }}
<template #submenu>
<MenuItem
v-for="host in hostStore.allRecords"
@click="xenApi.vm.startOn({ vmRef: vm.$ref, hostRef: host.$ref })"
v-for="host in hosts as XenApiHost[]"
v-bind:key="host.$ref"
:icon="faServer"
@click="xenApi.vm.startOn(vm!.$ref, host.$ref)"
>
<div class="wrapper">
{{ host.name_label }}
<div>
<UiIcon
:icon="
host.$ref === poolStore.pool?.master ? faStar : undefined
"
:icon="host.$ref === pool?.master ? faStar : undefined"
class="star"
/>
<PowerStateIcon
:state="isHostRunning(host) ? 'Running' : 'Halted'"
:state="
isHostRunning(host, hostMetricsSubscription)
? 'Running'
: 'Halted'
"
/>
</div>
</div>
@@ -48,63 +50,59 @@
</template>
</MenuItem>
<MenuItem
@click="xenApi.vm.pause({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'pause')"
:disabled="!isRunning"
:icon="faPause"
@click="xenApi.vm.pause(vm!.$ref)"
>
{{ $t("pause") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.suspend({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'suspend')"
:disabled="!isRunning"
:icon="faMoon"
@click="xenApi.vm.suspend(vm!.$ref)"
>
{{ $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(vm, ['unpause', 'resume'])"
:disabled="!isSuspended && !isPaused"
:icon="faCirclePlay"
@click="xenApi.vm.resume({ [vm!.$ref]: vm!.power_state })"
>
{{ $t("resume") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'clean_reboot')"
:disabled="!isRunning"
:icon="faRotateLeft"
@click="xenApi.vm.reboot(vm!.$ref)"
>
{{ $t("reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.reboot({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending(vm, 'hard_reboot')"
:disabled="!isRunning && !isPaused"
:icon="faRepeat"
@click="xenApi.vm.reboot(vm!.$ref, true)"
>
{{ $t("force-reboot") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref })"
:busy="isOperationsPending(vm, 'clean_shutdown')"
:disabled="!isRunning"
:icon="faPowerOff"
@click="xenApi.vm.shutdown(vm!.$ref)"
>
{{ $t("shutdown") }}
</MenuItem>
<MenuItem
@click="xenApi.vm.shutdown({ vmRef: vm.$ref, force: true })"
:busy="isOperationsPending(vm, 'hard_shutdown')"
:disabled="!isRunning && !isSuspended && !isPaused"
:icon="faPlug"
@click="xenApi.vm.shutdown(vm!.$ref, true)"
>
{{ $t("force-shutdown") }}
</MenuItem>
@@ -112,14 +110,17 @@
</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/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { isHostRunning, isOperationsPending } from "@/libs/utils";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useVmStore } from "@/stores/vm.store";
@@ -140,22 +141,23 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { computedAsync } from "@vueuse/core";
const vmStore = useVmStore();
const hostStore = useHostStore();
const poolStore = usePoolStore();
const { getByUuid: getVmByUuid } = useVmStore().subscribe();
const { records: hosts } = useHostStore().subscribe();
const { pool } = usePoolStore().subscribe();
const hostMetricsSubscription = useHostMetricsStore().subscribe();
const xenApi = useXenApiStore().getXapi();
const { currentRoute } = useRouter();
const vm = computed(
() => vmStore.getRecordByUuid(currentRoute.value.params.uuid as string)!
const vm = computed(() =>
getVmByUuid(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");
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>

View File

@@ -0,0 +1,37 @@
<template>
<UiTabBar>
<RouterTab :to="{ name: 'vm.dashboard', params: { uuid } }">
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.console', params: { uuid } }">
{{ $t("console") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.alarms', params: { uuid } }">
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.stats', params: { uuid } }">
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.system', params: { uuid } }">
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.network', params: { uuid } }">
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.storage', params: { uuid } }">
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'vm.tasks', params: { uuid } }">
{{ $t("tasks") }}
</RouterTab>
</UiTabBar>
</template>
<script lang="ts" setup>
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
defineProps<{
uuid: string;
}>();
</script>

View File

@@ -6,10 +6,10 @@ This composable allows you to keep a history of each removed item of an array.
```typescript
const myArray = ref([]);
const history = useArrayRemovedItemsHistory(myArray)
const history = useArrayRemovedItemsHistory(myArray);
myArray.push('A'); // myArray = ['A']; history = []
myArray.push('B'); // myArray = ['A', 'B']; history = []
myArray.push("A"); // myArray = ['A']; history = []
myArray.push("B"); // myArray = ['A', 'B']; history = []
myArray.shift(); // myArray = ['B']; history = ['A']
```
@@ -25,8 +25,8 @@ Be careful when using an array of objects which is likely to be replaced (instea
```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' }]
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' }`.
@@ -35,7 +35,11 @@ You must therefore use an identity function as third parameter to return the val
```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' }]
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

@@ -1,5 +1,3 @@
import { computed, onUnmounted, ref, type ComputedRef } from "vue";
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
import {
type GRANULARITY,
type HostStats,
@@ -8,36 +6,36 @@ import {
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";
const STORES_BY_OBJECT_TYPE = {
host: useHostStore,
vm: useVmStore,
};
import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
import { computed, type ComputedRef, onUnmounted, ref } from "vue";
export type Stat<T> = {
id: string;
name: string;
stats?: T | null;
stats: T | undefined;
pausable: Pausable;
};
type GetStats<T extends HostStats | VmStats> = (
uuid: string,
granularity: GRANULARITY
) => Promise<XapiStatsResponse<T>> | undefined;
export type FetchedStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats
> = {
register: (object: T) => void;
unregister: (object: T) => void;
stats?: ComputedRef<Stat<S>[] | null>;
timestampStart?: ComputedRef<number>;
timestampEnd?: ComputedRef<number>;
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) {
>(getStats: GetStats<S>, granularity: GRANULARITY): FetchedStats<T, S> {
const stats = ref<Map<string, Stat<S>>>(new Map());
const timestamp = ref<number[]>([0, 0]);
@@ -54,18 +52,12 @@ export default function useFetchStats<
return;
}
const objectStore = STORES_BY_OBJECT_TYPE[type]();
const newStats = await getStats(object.uuid, granularity);
if (objectStore.hasError) {
stats.value.get(mapKey)!.stats = null;
if (newStats === undefined) {
return;
}
const newStats = (await objectStore.getStats(
object.uuid,
granularity
)) as XapiStatsResponse<S>;
timestamp.value = [
newStats.endTimestamp -
RRD_STEP_FROM_STRING[granularity] *

View File

@@ -1,36 +1,71 @@
# Tooltip Directive
By default, tooltip will appear centered above the target element.
By default, the tooltip will appear centered above the target element.
## Directive argument
The directive argument can be either:
- The tooltip content
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
## Tooltip content
The tooltip content can be either:
- `false` or an empty-string to disable the tooltip
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
- Non-empty string to enable the tooltip and use the string as content.
## Tooltip placement
Tooltip can be placed on the following positions:
- `top`
- `top-start`
- `top-end`
- `bottom`
- `bottom-start`
- `bottom-end`
- `left`
- `left-start`
- `left-end`
- `right`
- `right-start`
- `right-end`
## Usage
```vue
<template>
<!-- Static -->
<!-- Boolean / Undefined -->
<span v-tooltip="true"
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<span v-tooltip
>This content will be ellipsized by CSS but displayed entirely in the
tooltip</span
>
<!-- String -->
<span v-tooltip="'Tooltip content'">Item</span>
<!-- Dynamic -->
<span v-tooltip="myTooltipContent">Item</span>
<!-- Placement -->
<!-- Object -->
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
<!-- Disabling (variable) -->
<span v-tooltip="{ content: 'Foobar', disabled: isDisabled }">Item</span>
<!-- Dynamic -->
<span v-tooltip="myTooltip">Item</span>
<!-- Disabling (function) -->
<span v-tooltip="{ content: 'Foobar', disabled: isDisabledFn }">Item</span>
<!-- Conditional -->
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span>
</template>
<script setup>
import { ref } from "vue";
import { vTooltip } from "@/directives/tooltip.directive";
const myTooltipContent = ref("Content");
const isDisabled = ref(true);
const isDisabledFn = (target: Element) => {
// return boolean;
};
const myTooltip = ref("Content"); // or ref({ content: "Content", placement: "left-end" })
const isTooltipEnabled = ref(true);
</script>
```

View File

@@ -1,8 +1,36 @@
import type { Directive } from "vue";
import type { TooltipEvents, TooltipOptions } from "@/stores/tooltip.store";
import { useTooltipStore } from "@/stores/tooltip.store";
import { isObject } from "lodash-es";
import type { Options } from "placement.js";
import type { Directive } from "vue";
export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
type TooltipDirectiveContent = undefined | boolean | string;
type TooltipDirectiveOptions =
| TooltipDirectiveContent
| {
content?: TooltipDirectiveContent;
placement?: Options["placement"];
};
const parseOptions = (
options: TooltipDirectiveOptions,
target: HTMLElement
): TooltipOptions => {
const { placement, content } = isObject(options)
? options
: { placement: undefined, content: options };
return {
placement,
content:
content === true || content === undefined
? target.innerText.trim()
: content,
};
};
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
mounted(target, binding) {
const store = useTooltipStore();
@@ -10,11 +38,11 @@ export const vTooltip: Directive<HTMLElement, TooltipOptions> = {
? { on: "focusin", off: "focusout" }
: { on: "mouseenter", off: "mouseleave" };
store.register(target, binding.value, events);
store.register(target, parseOptions(binding.value, target), events);
},
updated(target, binding) {
const store = useTooltipStore();
store.updateOptions(target, binding.value);
store.updateOptions(target, parseOptions(binding.value, target));
},
beforeUnmount(target) {
const store = useTooltipStore();

View File

@@ -1,12 +1,12 @@
import HLJS from "highlight.js";
import MarkdownIt from "markdown-it";
import { marked } from "marked";
export const markdown = new MarkdownIt();
markdown.set({
highlight: (str: string, lang: string) => {
const code = highlight(str, lang);
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
marked.use({
renderer: {
code(str: string, lang: string) {
const code = highlight(str, HLJS.getLanguage(lang) ? lang : "plaintext");
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
},
},
});
@@ -25,11 +25,7 @@ function highlight(str: string, lang: string) {
case "vue-style":
return wrap(str.trim(), "style");
default: {
if (HLJS.getLanguage(lang) !== undefined) {
return copyable(HLJS.highlight(str, { language: lang }).value);
}
return copyable(markdown.utils.escapeHtml(str));
return copyable(HLJS.highlight(str, { language: lang }).value);
}
}
}
@@ -62,3 +58,5 @@ function wrap(str: string, tag: "template" | "script" | "style") {
function copyable(code: string) {
return `<div class="copyable">${code}</div>`;
}
export default marked;

View File

@@ -1,18 +1,18 @@
import { utcParse } from "d3-time-format";
import humanFormat from "human-format";
import { castArray, round } from "lodash-es";
import { find, forEach, isEqual, size, sum } from "lodash-es";
import { type ComputedGetter, type Ref, computed, ref, watchEffect } from "vue";
import type { Filter } from "@/types/filter";
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
import type {
RawObjectType,
RawXenApiRecord,
XenApiHost,
XenApiHostMetrics,
XenApiRecord,
XenApiVm,
} from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import type { CollectionSubscription } from "@/stores/xapi-collection.store";
import type { Filter } from "@/types/filter";
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
import { utcParse } from "d3-time-format";
import humanFormat from "human-format";
import { castArray, find, forEach, round, size, sum } from "lodash-es";
export function sortRecordsByNameLabel(
record1: { name_label: string },
@@ -71,12 +71,25 @@ export function parseDateTime(dateTime: string) {
return date.getTime();
}
export const hasEllipsis = (target: Element | undefined | null) =>
target != undefined && target.clientWidth < target.scrollWidth;
export const hasEllipsis = (
target: Element | undefined | null,
{ vertical = false }: { vertical?: boolean } = {}
) => {
if (target == null) {
return false;
}
if (vertical) {
return target.clientHeight < target.scrollHeight;
}
return target.clientWidth < target.scrollWidth;
};
export function percent(currentValue: number, maxValue: number, precision = 2) {
return round((currentValue / maxValue) * 100, precision);
}
export function getAvgCpuUsage(cpus?: object | any[], { nSequence = 4 } = {}) {
const statsLength = getStatsLength(cpus);
if (statsLength === undefined) {
@@ -101,48 +114,37 @@ export function getStatsLength(stats?: object | any[]) {
return size(find(stats, (stat) => stat != null));
}
export function deepComputed<T>(getter: ComputedGetter<T>) {
const value = computed(getter);
const cache = ref<T>(value.value) as Ref<T>;
watchEffect(() => {
if (!isEqual(cache.value, value.value)) {
cache.value = value.value;
}
});
return cache;
export function isHostRunning(
host: XenApiHost,
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
) {
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
}
export function isHostRunning(host: XenApiHost) {
const store = useHostMetricsStore();
try {
return store.getRecord(host.metrics).live;
} catch (_) {
return undefined;
}
}
export function getHostMemory(
host: XenApiHost,
hostMetricsSubscription: CollectionSubscription<XenApiHostMetrics>
) {
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
export function getHostMemory(host: XenApiHost) {
try {
const metrics = useHostMetricsStore().getRecord(host.metrics);
const total = +metrics.memory_total;
if (hostMetrics !== undefined) {
const total = +hostMetrics.memory_total;
return {
usage: total - +metrics.memory_free,
usage: total - +hostMetrics.memory_free,
size: total,
};
} catch (error) {
console.error("getHostMemory function:", error);
return undefined;
}
}
export const buildXoObject = (
record: RawXenApiRecord<XenApiRecord>,
export const buildXoObject = <T extends XenApiRecord>(
record: RawXenApiRecord<T>,
params: { opaqueRef: string }
) => ({
...record,
$ref: params.opaqueRef,
});
) => {
return {
...record,
$ref: params.opaqueRef,
} as T;
};
export function parseRamUsage(
{
@@ -181,6 +183,15 @@ export function parseRamUsage(
export const getFirst = <T>(value: T | T[]): T | undefined =>
Array.isArray(value) ? value[0] : value;
export function requireSubscription<T>(
subscription: T | undefined,
type: RawObjectType
): asserts subscription is T {
if (subscription === undefined) {
throw new Error(`You need to provide a ${type} subscription`);
}
}
export const isOperationsPending = (
obj: XenApiVm,
operations: string[] | string

View File

@@ -1,67 +1,72 @@
import { JSONRPCClient } from "json-rpc-2.0";
import { buildXoObject, parseDateTime } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
import { JSONRPCClient } from "json-rpc-2.0";
import { castArray } from "lodash-es";
export type RawObjectType =
| "Bond"
| "Certificate"
| "Cluster"
| "Cluster_host"
| "DR_task"
| "Feature"
| "GPU_group"
| "PBD"
| "PCI"
| "PGPU"
| "PIF"
| "PIF_metrics"
| "PUSB"
| "PVS_cache_storage"
| "PVS_proxy"
| "PVS_server"
| "PVS_site"
| "SDN_controller"
| "SM"
| "SR"
| "USB_group"
| "VBD"
| "VBD_metrics"
| "VDI"
| "VGPU"
| "VGPU_type"
| "VIF"
| "VIF_metrics"
| "VLAN"
| "VM"
| "VMPP"
| "VMSS"
| "VM_appliance"
| "VM_guest_metrics"
| "VM_metrics"
| "VUSB"
| "blob"
| "console"
| "crashdump"
| "host"
| "host_cpu"
| "host_crashdump"
| "host_metrics"
| "host_patch"
| "network"
| "network_sriov"
| "pool"
| "pool_patch"
| "pool_update"
| "role"
| "secret"
| "subject"
| "task"
| "tunnel";
const OBJECT_TYPES = {
bond: "Bond",
certificate: "Certificate",
cluster: "Cluster",
cluster_host: "Cluster_host",
dr_task: "DR_task",
feature: "Feature",
gpu_group: "GPU_group",
pbd: "PBD",
pci: "PCI",
pgpu: "PGPU",
pif: "PIF",
pif_metrics: "PIF_metrics",
pusb: "PUSB",
pvs_cache_storage: "PVS_cache_storage",
pvs_proxy: "PVS_proxy",
pvs_server: "PVS_server",
pvs_site: "PVS_site",
sdn_controller: "SDN_controller",
sm: "SM",
sr: "SR",
usb_group: "USB_group",
vbd: "VBD",
vbd_metrics: "VBD_metrics",
vdi: "VDI",
vgpu: "VGPU",
vgpu_type: "VGPU_type",
vif: "VIF",
vif_metrics: "VIF_metrics",
vlan: "VLAN",
vm: "VM",
vmpp: "VMPP",
vmss: "VMSS",
vm_guest_metrics: "VM_guest_metrics",
vm_metrics: "VM_metrics",
vusb: "VUSB",
blob: "blob",
console: "console",
crashdump: "crashdump",
host: "host",
host_cpu: "host_cpu",
host_crashdump: "host_crashdump",
host_metrics: "host_metrics",
host_patch: "host_patch",
network: "network",
network_sriov: "network_sriov",
pool: "pool",
pool_patch: "pool_patch",
pool_update: "pool_update",
role: "role",
secret: "secret",
subject: "subject",
task: "task",
tunnel: "tunnel",
} as const;
export type ObjectType = keyof typeof OBJECT_TYPES;
export type RawObjectType = (typeof OBJECT_TYPES)[ObjectType];
export const getRawObjectType = (type: ObjectType): RawObjectType => {
return OBJECT_TYPES[type];
};
export type PowerState = "Running" | "Paused" | "Halted" | "Suspended";
export type ObjectType = Lowercase<RawObjectType>;
export interface XenApiRecord {
$ref: string;
uuid: string;
@@ -83,6 +88,7 @@ export interface XenApiHost extends XenApiRecord {
metrics: string;
resident_VMs: string[];
cpu_info: { cpu_count: string };
software_version: { product_version: string };
}
export interface XenApiSr extends XenApiRecord {
@@ -137,7 +143,7 @@ type WatchCallbackResult = {
class: ObjectType;
operation: "add" | "mod" | "del";
ref: string;
snapshot: object;
snapshot: RawXenApiRecord<XenApiRecord>;
};
type WatchCallback = (results: WatchCallbackResult[]) => void;
@@ -199,8 +205,8 @@ export default class XenApi {
}
}
disconnect() {
this.#call("session.logout", [this.#sessionId]);
async disconnect() {
await this.#call("session.logout", [this.#sessionId]);
this.stopWatch();
this.#sessionId = undefined;
}
@@ -247,20 +253,15 @@ export default class XenApi {
return fetch(url);
}
async loadRecords<T extends XenApiRecord>(
type: RawObjectType
): Promise<Map<string, T>> {
async loadRecords<T extends XenApiRecord>(type: RawObjectType): Promise<T[]> {
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
`${type}.get_all_records`,
[this.sessionId]
);
const entries = Object.entries(result).map<[string, T]>(([key, entry]) => [
key,
buildXoObject(entry, { opaqueRef: key }) as T,
]);
return new Map(entries);
return Object.entries(result).map(([opaqueRef, record]) =>
buildXoObject(record, { opaqueRef })
);
}
async #watch() {
@@ -286,7 +287,7 @@ export default class XenApi {
startWatch() {
this.#watching = true;
this.#watch();
return this.#watch();
}
stopWatch() {
@@ -307,77 +308,57 @@ export default class XenApi {
}
get vm() {
type VmsRef =
| {
vmRef: XenApiVm["$ref"];
vmsRef?: undefined;
}
| {
vmRef?: undefined;
vmsRef: XenApiVm["$ref"][];
};
type VmRefs = XenApiVm["$ref"] | XenApiVm["$ref"][];
type VmRefsWithPowerState = Record<
XenApiVm["$ref"],
XenApiVm["power_state"]
>;
return {
start: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.start", [vmRef, false, false]))
);
},
startOn: ({ vmRef, vmsRef, hostRef }: VmsRef & { hostRef: string }) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) =>
start: (vmRefs: VmRefs) =>
Promise.all(
castArray(vmRefs).map((vmRef) =>
this._call("VM.start", [vmRef, false, false])
)
),
startOn: (vmRefs: VmRefs, hostRef: XenApiHost["$ref"]) =>
Promise.all(
castArray(vmRefs).map((vmRef) =>
this._call("VM.start_on", [vmRef, hostRef, false, false])
)
),
pause: (vmRefs: VmRefs) =>
Promise.all(
castArray(vmRefs).map((vmRef) => this._call("VM.pause", [vmRef]))
),
suspend: (vmRefs: VmRefs) => {
return Promise.all(
castArray(vmRefs).map((vmRef) => this._call("VM.suspend", [vmRef]))
);
},
pause: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
resume: (vmRefsWithPowerState: VmRefsWithPowerState) => {
const vmRefs = Object.keys(vmRefsWithPowerState);
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.pause", [vmRef]))
);
},
suspend: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
return Promise.all(
_vmsRef.map((vmRef) => this._call("VM.suspend", [vmRef]))
);
},
resume: ({ vmRef, vmsRef }: VmsRef) => {
const _vmsRef = vmsRef ?? [vmRef];
const vmStore = useVmStore();
return Promise.all(
_vmsRef.map((ref) => {
const isSuspended =
vmStore.getRecord(ref).power_state === "Suspended";
return this._call(
`VM.${isSuspended ? "resume" : "unpause"}`,
isSuspended ? [ref, false, false] : [ref]
);
vmRefs.map((vmRef) => {
if (vmRefsWithPowerState[vmRef] === "Suspended") {
return this._call("VM.resume", [vmRef, false, false]);
}
return this._call("VM.unpause", [vmRef]);
})
);
},
reboot: ({
vmRef,
vmsRef,
force = false,
}: VmsRef & { force?: boolean }) => {
const _vmsRef = vmsRef ?? [vmRef];
reboot: (vmRefs: VmRefs, force = false) => {
return Promise.all(
_vmsRef.map((vmRef) =>
castArray(vmRefs).map((vmRef) =>
this._call(`VM.${force ? "hard" : "clean"}_reboot`, [vmRef])
)
);
},
shutdown: ({
vmRef,
vmsRef,
force = false,
}: VmsRef & { force?: boolean }) => {
const _vmsRef = vmsRef ?? [vmRef];
shutdown: (vmRefs: VmRefs, force = false) => {
return Promise.all(
_vmsRef.map((vmRef) =>
castArray(vmRefs).map((vmRef) =>
this._call(`VM.${force ? "hard" : "clean"}_shutdown`, [vmRef])
)
);

View File

@@ -1,4 +1,5 @@
{
"about": "About",
"add": "Add",
"add-filter": "Add filter",
"add-or": "+OR",
@@ -16,6 +17,7 @@
"coming-soon": "Coming soon!",
"community": "Community",
"community-name": "{name} community",
"console": "Console",
"copy": "Copy",
"cpu-provisioning": "CPU provisioning",
"cpu-usage": "CPU usage",
@@ -24,10 +26,12 @@
"descending": "descending",
"description": "Description",
"display": "Display",
"documentation": "Documentation",
"documentation-name": "{name} documentation",
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"edit-config": "Edit config",
"error-no-data": "Error, can't collect data.",
"error-occured": "An error has occurred",
"error-occured": "An error has occurred",
"export": "Export",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
@@ -108,6 +112,8 @@
"status": "Status",
"storage": "Storage",
"storage-usage": "Storage usage",
"support": "Support",
"support-name": "{name} pro support",
"suspend": "Suspend",
"switch-theme": "Switch theme",
"system": "System",

View File

@@ -1,4 +1,5 @@
{
"about": "À propos",
"add": "Ajouter",
"add-filter": "Ajouter un filtre",
"add-or": "+OU",
@@ -16,6 +17,7 @@
"coming-soon": "Bientôt disponible !",
"community": "Communauté",
"community-name": "Communauté {name}",
"console": "Console",
"copy": "Copier",
"cpu-provisioning": "Provisionnement CPU",
"cpu-usage": "Utilisation CPU",
@@ -24,6 +26,8 @@
"descending": "descendant",
"description": "Description",
"display": "Affichage",
"documentation": "Documentation",
"documentation-name": "Documentation {name}",
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
"edit-config": "Modifier config",
"error-no-data": "Erreur, impossible de collecter les données.",
@@ -108,6 +112,8 @@
"status": "Statut",
"storage": "Stockage",
"storage-usage": "Utilisation du stockage",
"support": "Support",
"support-name": "Support pro {name}",
"suspend": "Suspendre",
"switch-theme": "Changer de thème",
"system": "Système",

View File

@@ -1,12 +1,11 @@
import pool from "@/router/pool";
import vm from "@/router/vm";
import HomeView from "@/views/HomeView.vue";
import HostDashboardView from "@/views/host/HostDashboardView.vue";
import HostRootView from "@/views/host/HostRootView.vue";
import PageNotFoundView from "@/views/PageNotFoundView.vue";
import SettingsView from "@/views/settings/SettingsView.vue";
import StoryView from "@/views/StoryView.vue";
import VmConsoleView from "@/views/vm/VmConsoleView.vue";
import VmRootView from "@/views/vm/VmRootView.vue";
import storiesRoutes from "virtual:stories";
import { createRouter, createWebHashHistory } from "vue-router";
@@ -31,6 +30,7 @@ const router = createRouter({
component: SettingsView,
},
pool,
vm,
{
path: "/host/:uuid",
component: HostRootView,
@@ -42,17 +42,6 @@ const router = createRouter({
},
],
},
{
path: "/vm/:uuid",
component: VmRootView,
children: [
{
path: "console",
name: "vm.console",
component: VmConsoleView,
},
],
},
{
path: "/:pathMatch(.*)*",
name: "notFound",

View File

@@ -0,0 +1,47 @@
export default {
path: "/vm/:uuid",
component: () => import("@/views/vm/VmRootView.vue"),
redirect: { name: "vm.console" },
children: [
{
path: "dashboard",
name: "vm.dashboard",
component: () => import("@/views/vm/VmDashboardView.vue"),
},
{
path: "console",
name: "vm.console",
component: () => import("@/views/vm/VmConsoleView.vue"),
},
{
path: "alarms",
name: "vm.alarms",
component: () => import("@/views/vm/VmAlarmsView.vue"),
},
{
path: "stats",
name: "vm.stats",
component: () => import("@/views/vm/VmStatsView.vue"),
},
{
path: "system",
name: "vm.system",
component: () => import("@/views/vm/VmSystemView.vue"),
},
{
path: "network",
name: "vm.network",
component: () => import("@/views/vm/VmNetworkView.vue"),
},
{
path: "storage",
name: "vm.storage",
component: () => import("@/views/vm/VmStorageView.vue"),
},
{
path: "tasks",
name: "vm.tasks",
component: () => import("@/views/vm/VmTasksView.vue"),
},
],
};

View File

@@ -1,7 +1,6 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
import type { XenApiConsole } from "@/libs/xen-api";
import { createRecordContext } from "@/stores/index";
export const useConsoleStore = defineStore("console", () =>
createRecordContext<XenApiConsole>("console")
useXapiCollectionStore().get("console")
);

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