Compare commits

..

145 Commits

Author SHA1 Message Date
Thierry Goettelmann
6d0dccf5ba feat(lite): Replace FontAwesomeIcon with UiIcon 2022-11-15 08:48:49 +01:00
Julien Fontanet
185509a0cf fix(xo-server/proxy.upgradeAppliance): use getObject method on correct object
Introduced by 572359892
2022-11-10 18:12:57 +01:00
Julien Fontanet
08298d3284 feat: limit concurrency of root build script
Should fixes https://xcp-ng.org/forum/post/54567
2022-11-10 18:09:05 +01:00
Mathieu
7a4cec5093 fix(dashboard/health): filter correctly unhealthyVdis (#6519)
See zammad#10720
2022-11-10 15:35:05 +01:00
rajaa-b
f44f5199c6 feat(lite): uncollapse hosts by default (#6428) 2022-11-10 15:12:37 +01:00
kursantkvi
81abc091de feat(xo-web/intl): Russian localization (#6526) 2022-11-10 10:02:16 +01:00
Pierre Donias
7e4f4c445d feat: release 5.76.1 (#6523) 2022-11-08 16:24:01 +01:00
Pierre Donias
5a673c1833 feat: technical release (#6521) 2022-11-08 11:12:48 +01:00
Mathieu
266231ae0f fix(xo-web): "Pro Support" instead of "pool support" in XCP-ng support tooltips (#6517)
See https://xcp-ng.org/forum/topic/6535
2022-11-08 10:11:16 +01:00
Florent BEAUCHAMP
9e87a887cb fix(xo-web/backup): cleanup settings correctly when deselecting health check (#6515)
Fix #6501
2022-11-08 10:06:04 +01:00
Mathieu
12e98bfd31 fix(xo-web/health): fix "an error has occurred" (#6508) 2022-11-08 09:52:54 +01:00
Mathieu
249f124ba6 fix(xo-web/license): display the license product ID in SelectLicense (#6512)
See zammad#10750
2022-11-08 09:50:46 +01:00
Julien Fontanet
131643a91b feat(xo-server/rest-api): expose VDI snapshots 2022-11-07 17:14:08 +01:00
Julien Fontanet
df3df18690 feat(xo-server/rest-api): expose VM snapshots and templates 2022-11-07 17:00:22 +01:00
Julien Fontanet
5401d17610 fix(xo-server/backup): respect httpProxy when connecting to XAPIs (#6513) 2022-11-07 15:07:35 +01:00
Florent BEAUCHAMP
90ea2284c6 fix(xo-vmk-to-vhd): failing tests (#6518)
Sometimes `buffer.allocUnsafe` was generating an buffer containing only zeroes, that buffer was filtered in packages/xo-vmdk-to-vhd/src/vmdk-generate.js line 140, thus the generated vmdk was variable
2022-11-07 13:28:43 +01:00
Mathieu
a4c5792f9e fix(lite): fix .value is undefined (#6469) 2022-11-07 10:18:12 +01:00
Julien Fontanet
5723598923 feat(xo-server/proxy.upgradeAppliance): support proxies with unknown VM 2022-11-07 00:19:13 +01:00
Julien Fontanet
aa0b2ff93a feat(xo-server/proxy.register): vmUuid parameter 2022-11-06 01:10:09 +01:00
Gabriel Gunullu
be6233f12b test(backups): from Jest to test (#6500) 2022-11-04 17:00:02 +01:00
Olivier Lambert
17df749790 chore(ISSUE_TEMPLATE/bug_report): make it mandatory and ask commit (#6509) 2022-11-04 11:25:58 +01:00
Gabriel Gunullu
97f852f8e8 test(log): from Jest to test (#6498) 2022-11-04 10:54:11 +01:00
Gabriel Gunullu
dc3446d61a test(template): from Jest to test (#6499) 2022-11-04 10:53:38 +01:00
Mathieu
c830a0b208 fix(pool): added tooltip for no support icon (#6505)
See zammad#10716
2022-11-04 10:46:17 +01:00
Thierry Goettelmann
ff0307b68f fix(lite): Vite constants declaration (#6511) 2022-11-04 10:27:41 +01:00
Mathieu
1c3cad9235 feat(lite): alert when unreachable hosts (#6378) 2022-11-04 10:02:02 +01:00
Julien Fontanet
ccafc15b66 fix(xo-server): split-log → split-host
Introduced by ed7ff1fad

Fixes https://xcp-ng.org/forum/post/54503
2022-11-03 14:54:23 +01:00
Julien Fontanet
a40d6b32e3 fix(xo-server/sample.config.toml): typo log → logs
Introduced by 2dda1aecc

Fixes https://xcp-ng.org/forum/post/54351
2022-11-03 13:57:34 +01:00
Pierre Donias
de1ee92fe7 chore(lite): normalize package 2022-11-03 10:30:39 +01:00
Pierre Donias
c7227d2f50 feat(lite): settings page (#6418) 2022-11-03 10:30:39 +01:00
Mathieu
b2cebbfaf4 fix(lite): invalidate sessionId token (#6480) 2022-11-03 10:30:39 +01:00
Pierre Donias
30fbbc92ca feat(lite/ProgressBar): use transition instead of animation (#6466) 2022-11-03 10:30:39 +01:00
Mathieu
d1b210cf16 fix(lite/dashboard): add missing 'id' field for storage usage (#6467) 2022-11-03 10:30:39 +01:00
Mathieu
9963568368 feat(lite/pool/dashboard): top 5 CPU usage (#6370) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
ffc3249b33 feat(lite/component): UiSpinner (#6427) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
29826db81b fix(lite): fix build errors (#6448) 2022-11-03 10:30:39 +01:00
Pierre Donias
5367a76db5 chore(lite): create CHANGELOG.md (#6457) 2022-11-03 10:30:39 +01:00
Pierre Donias
2512a00205 fix(lite/UiBadge): do not instanciate FontAwesomeIcon if icon is undefined (#6446) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
72a3a9f04f feat(lite/component): Radio, Checkbox, Select, Input, Toggle (#6426) 2022-11-03 10:30:39 +01:00
Mathieu
b566e0fd46 feat(lite): persit language change (#6443) 2022-11-03 10:30:39 +01:00
Pierre Donias
4621fb4e9b feat(lite): set default language to English (#6442)
See https://xcp-ng.org/forum/topic/4731/xen-orchestra-lite/48?_=1664781482177
2022-11-03 10:30:39 +01:00
Mathieu
7f3d25964f feat(lite): display storage usage (#6421) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
4b3728e8d8 feat(lite/component): New multicolor modal (#6394) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
5218d6df1a feat(lite/component): Menu component (#6354)
* feat(lite/component): Menu component

* feat(lite/component): Add disabled prop to AppMenu

* feat(lite/component): Add custom placement to AppMenu + Fix trigger color

* feat(lite/component): Update VmsActionBar to use new AppMenu (#6357)

* fix(lite/menu): Doesn't teleport the root menu if no trigger

* Don't disable a menu item having a submenu

* i18n
2022-11-03 10:30:39 +01:00
Thierry Goettelmann
94b2b8ec70 feat(lite/buttons): Add multiple button colors, outlined and transparent (#6393) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
6d1086539e feat(lite/component): UiActionButton component (#6386) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
7f758bbb73 Revert "feat(lite/component): Radio and Checkbox"
This reverts commit abfb6c97a2.
2022-11-03 10:30:39 +01:00
Thierry Goettelmann
62b88200c3 feat(lite/component): Radio and Checkbox 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
ce42883268 feat(lite): tooltips (#6412) 2022-11-03 10:30:39 +01:00
Pierre Donias
6b60cfce4d feat(lite): deploy script (#6413) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
aebb47ad38 feat(lite/component): Linear Chart (#6376) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
41f5634b7a feat(lite): i18n (#6399) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
87ddb01122 feat(lite): use FontAwesome Free (#6405) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
6898eea45e feat(lite): Update missing colors (#6392) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
ba2679d3d7 feat(lite/component): Change style of active items in tree view (#6397) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
971cdaa44f feat(lite): CR feedback (#6341) 2022-11-03 10:30:39 +01:00
Pierre Donias
005d3b5976 feat(lite): placeholders for pool/host/VM name_label (#6391)
Some objects may have an empty `name_label`. This is to avoid confusion in the
tree view.
2022-11-03 10:30:39 +01:00
Thierry Goettelmann
663403cb14 chore(lite): change font-size (#6390) 2022-11-03 10:30:39 +01:00
Pierre Donias
b341e38623 feat(lite): add "coming soon" message on empty views (#6389) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
8246db30cb chore(lite): remove fake cards from Pool Dashboard (#6385) 2022-11-03 10:30:39 +01:00
Pierre Donias
9c9c656620 feat(lite/signin): smaller logo and few tweaks (#6381) 2022-11-03 10:30:39 +01:00
Pierre Donias
f36be0d5e0 feat(lite/nav): use logo without circle (#6382) 2022-11-03 10:30:39 +01:00
Pierre Donias
72090ea8ff feat(lite/dark mode): persistence + icon (#6383) 2022-11-03 10:30:39 +01:00
Pierre Donias
8d64a0a232 feat(lite): use new XO Lite logo (#6379) 2022-11-03 10:30:39 +01:00
Pierre Donias
35974a0a33 fix(lite): connect to window.origin's XAPI in prod (#6377) 2022-11-03 10:30:39 +01:00
Mathieu
3023439028 fix(lite): fix UiCard height (#6373) 2022-11-03 10:30:39 +01:00
Pierre Donias
77f4a09d74 chore(lite): switch from actual routes to hash routes (#6372)
XCP-ng web servers only serve the HTML on /xolite.html. This allows XO Lite to
still work when opened on a route that is different than /.
2022-11-03 10:30:39 +01:00
Pierre Donias
0fc797f7d0 fix(lite): change HTML main element ID from app to root (#6371)
XCP-ng web servers already serve an HTML file with a #root element. This allows
to use the new version of XO Lite without having to change that HTML file on
XCP-ng hosts.
2022-11-03 10:30:39 +01:00
Mathieu
0b02c84e33 feat(lite): xapiStat with fetchStats composable (#6361) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
e03ff0a9be feat(lite/component): update UiButton (#6355) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
d91f1841c0 feat(lite/component): UI Icon utility component (#6353) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
0effc9cfc1 fix(lite): disconnecting (#6346) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
f08cbb458d fix(lite): ESLint config (#6344) 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
b8c9770d43 chore(lite): merge old repo to XO 2022-11-03 10:30:39 +01:00
Julien Fontanet
44ff5d0e4d fix(lite): various fixes 2022-11-03 10:30:39 +01:00
Thierry Goettelmann
ecb580a629 feat(lite): initial Vue.js implementation 2022-11-03 10:30:39 +01:00
Julien Fontanet
0623d837c1 feat: release 5.76.0 2022-10-31 13:59:50 +01:00
Julien Fontanet
f92d1ce4ac chore(CHANGELOG): integrate released changes 2022-10-31 13:24:43 +01:00
Julien Fontanet
88f84069d6 feat(xo-web): 5.106.0 2022-10-31 13:23:49 +01:00
Julien Fontanet
b9b7081184 feat(xo-server): 5.105.0 2022-10-31 13:23:21 +01:00
Julien Fontanet
ce3e0817db feat(@xen-orchestra/proxy): 0.26.4 2022-10-31 13:22:52 +01:00
Julien Fontanet
55b65a8bf6 feat(@xen-orchestra/backups): 0.29.0 2022-10-31 13:22:18 +01:00
Julien Fontanet
6767141661 feat(@xen-orchestra/xapi): 1.5.2 2022-10-31 13:17:26 +01:00
rajaa-b
2eb3b15930 feat(xo-web/new-vm): possibility to destroy cloud config drive after first boot (#6486)
Fixes #6438
2022-10-31 12:25:55 +01:00
Julien Fontanet
b63c4a0d4f fix(xapi/waitObjectState): check if state si already correct 2022-10-28 16:15:53 +02:00
Mathieu
1269ddfeae feat(xo-web/pool): XCP-ng license binding (#6453) 2022-10-28 16:04:37 +02:00
rajaa-b
afd47f5522 fix(xo-web/proxies): remove "Bind license" for proxies without VM UUID (#6472) 2022-10-28 11:19:45 +02:00
Florent BEAUCHAMP
7ede6bdbce feat(backups): use NBD to export VDIs when possible (#6461) 2022-10-27 16:50:56 +02:00
Pierre Donias
03b505e40e feat: technical release (#6488) 2022-10-27 15:18:03 +02:00
Julien Fontanet
ed7ff1fad4 feat(xo-server): allow logging to external syslog
Follow-up of 756d2fe4e
2022-10-27 14:23:00 +02:00
Julien Fontanet
2dda1aecce feat(xo-server): use object to configure log transports
Follow-up of 756d2fe4e
2022-10-27 14:18:23 +02:00
Julien Fontanet
720e363577 test(fs/abstract): use getSyncedHandler 2022-10-27 09:26:49 +02:00
Florent Beauchamp
545a65521a fix(vhd-lib): improve openVhd error handling 2022-10-27 09:26:49 +02:00
Florent Beauchamp
0cf6f94677 test: rework tests following 05161bd4df
Test of cleanVm are still failin , untill we fix the error condition of cleanVm broken vhd removing

- don't use handler to / (need root to run)
- don't create file at the root of the remote (conflict with the metadata and encryption.json)
- test more unhappy paths
2022-10-27 09:26:49 +02:00
Florent Beauchamp
14e205ab69 fix(vhd-cli): sync handler 2022-10-27 09:26:49 +02:00
Florent Beauchamp
c3da87a40c fix(@xen-orchestra/fs): do not create metadata on non encrypted remote
this was creating file in wrong place during test or when running cli
introduced by #05161bd4df5b42e5ecfa0ae11e60c466ab2eabdf
2022-10-27 09:26:49 +02:00
Gabriel Gunullu
5d93b05088 test(cron): from Jest to test (#6485) 2022-10-26 09:41:39 +02:00
Gabriel Gunullu
2cdd33cb7a test(async-map): from Jest to test (#6484) 2022-10-25 16:17:08 +02:00
Julien Fontanet
dc909fdfb0 test(async-each): fix iteratee calls
Introduced by myself in last minute change in a43199b75
2022-10-25 13:14:44 +02:00
Gabriel Gunullu
a43199b754 test(async-each): from Jest to test (#6481) 2022-10-25 12:23:59 +02:00
Gabriel Gunullu
876211879f test(decorate-with): from Tap to test (#6474) 2022-10-24 17:46:44 +02:00
Gabriel Gunullu
fe323b8fe5 test: remove unnecessary ESLint comments (#6479) 2022-10-24 17:46:27 +02:00
Florent BEAUCHAMP
b60f5d593b feat(xo-web/remote): show encryption in remote UI (#6465)
* if remote is disabled we don't know the used algorithm : only show the lock if there is an encryption key
* if remote is enabled : 
  *  if algorithm is undefined or none : show nothing, remote is not encrypted
  * if algorithm is defined to DEFAULT_ENCRYPTION_ALGORITHM : show the lock with the name of the algorithm as a tooltip
  * else show the lock and a warning advising to create a new remote with an up to date algorithm
2022-10-24 16:15:26 +02:00
Gabriel Gunullu
2d4317b681 test(read-chunk): from Jest to test (#6478) 2022-10-24 14:38:56 +02:00
Julien Fontanet
caf0eb3762 chore(eslint): accepts Node 16 features in tests 2022-10-24 11:21:19 +02:00
Gabriel Gunullu
c1aa7b9d8a test(multi-key-map): from Jest to test (#6477) 2022-10-24 10:05:51 +02:00
Gabriel Gunullu
6c6efd9cfb test(disposable): from Jest to test and SinonJS (#6476) 2022-10-24 10:04:48 +02:00
Julien Fontanet
551670a8b9 fix(eslint): disable n/no-unpublished-{import,require} in tests 2022-10-24 09:53:55 +02:00
Gabriel Gunullu
ac75225e7d test(compose): from Jest to test (#6473) 2022-10-21 16:25:25 +02:00
Julien Fontanet
20dbbeb38e feat(npmignore): handle *.test.*js files
This naming scheme is used by `node:test` and its userland implementation `test`.
2022-10-20 17:00:49 +02:00
Julien Fontanet
37dea9980e fix(npmignore): handle .cjs and .mjs files 2022-10-20 16:58:30 +02:00
Gabriel Gunullu
5cec2d4cb0 test(coalesce-calls): from Jest to test (#6470) 2022-10-20 16:46:56 +02:00
Julien Fontanet
ed76fa5141 feat(predicates): not operator 2022-10-20 12:47:02 +02:00
Julien Fontanet
389a765825 fix(mixins/_parseBasicAuth): consider empty password as missing
This makes `username:` recognized as token, just like `username` is.

This fixes token-based authentication in HttpProxy with cURL.
2022-10-20 10:21:09 +02:00
Julien Fontanet
3bad40095a fix(mixins/Config#watch): first run even when undefined
Fixes issue introduced by d157fd352
2022-10-19 18:43:48 +02:00
Gabriel Gunullu
1a51c66028 fix(ci): GitHub actions workflow (#6463) 2022-10-19 12:00:46 +02:00
Florent BEAUCHAMP
05161bd4df feat(fs): use aes256-gcm encryption algorithm (#6447)
Fixes zammad#9788
2022-10-17 11:33:55 +02:00
Florent BEAUCHAMP
db1102750f feat(xo-web): label of vhd directory backup (#6459) 2022-10-13 16:45:03 +02:00
Julien Fontanet
42a974476f feat(@vates/otp): minimal HOTP/TOTP implementation (#6456) 2022-10-12 15:44:43 +02:00
Florent BEAUCHAMP
0dd91c1efe feat(nbd-client): first implementation (#6444) 2022-10-12 14:46:16 +02:00
Julien Fontanet
756d2fe4e7 feat(xo-server): make log transport configurable
See zammad#9799
2022-10-12 14:37:41 +02:00
Julien Fontanet
61c64b49c7 feat(log/configure): can instanciate transport from JSON 2022-10-12 14:37:41 +02:00
Julien Fontanet
c2eb68a31a chore(fuse-vhd): remove unused var/lib
Introduced by 46fe3be32
2022-10-11 16:41:12 +02:00
Julien Fontanet
f1a1b922c7 chore: format with Prettier 2022-10-11 16:40:10 +02:00
Cécile Morange
a2dcceb470 docs(installation): Debian 10 → 11 (#6458)
Signed-off-by: Cécile MORANGE - AtaxyaNetwork <contact@ataxya.net>
2022-10-10 17:29:32 +02:00
Julien Fontanet
1d78fdd673 chore: update dev deps 2022-10-10 15:55:05 +02:00
Julien Fontanet
4a53749ca0 fix(xo-server): Redis via socket
Introduced by 9fab15537

Fixes #6455
2022-10-10 11:35:12 +02:00
ggunullu
7f73ec52d6 fix(docker): Node version incompatible with babel-jest
Introduced by 8fd10bace7
2022-10-07 16:45:54 +02:00
ggunullu
4abb172976 fix(docker): add missing dependency libfuse2
Introduced by 46fe3be322
2022-10-07 16:45:54 +02:00
Julien Fontanet
c52e0a5531 docs(xapi): improve VM Sync Hook example server 2022-10-07 11:52:07 +02:00
Julien Fontanet
0197758780 fix(CHANGELOG): restore unreleased comments
Introduced by 3d3b63a59
2022-10-07 11:23:32 +02:00
Julien Fontanet
e2521b6688 fix(read-chunk): handle already ended stream 2022-10-06 15:20:28 +02:00
Cécile Morange
13f19de1a0 feat(xo-web): replace XenServer by XCP-ng (#6439)
Signed-off-by: Cécile MORANGE <contact@ataxya.net>
2022-10-06 10:52:06 +02:00
Florent Beauchamp
5e589019d0 fix(xo-web): do not throw an error when editing a non encrypted remote
Introduced by dca3f39
2022-10-05 11:08:14 +02:00
Florent Beauchamp
feaad13ac3 fix(xo-web): save region on S3 remote creation
Introduced by f755365
2022-10-05 11:08:14 +02:00
Florent Beauchamp
ab9428a9c4 fix(xo-remote-parser): correctly handle empty S3 region
Introduced by c219ea0
2022-10-05 11:08:14 +02:00
Julien Fontanet
c964a1471a fix(xo-server-transport-nagios): don\'t fail on message with line break
Fixes zammad#9800
2022-10-03 17:18:07 +02:00
Julien Fontanet
424322f7b7 feat(xo-server): 5.103.1 2022-09-30 15:38:40 +02:00
Julien Fontanet
956a4f8b2a feat: release 5.75.0 2022-09-30 14:14:26 +02:00
Florent BEAUCHAMP
d87210e903 fix(xo-server): fix running replication job (#6437)
Introduced by 2d6b827fd
2022-09-28 15:38:54 +02:00
Florent BEAUCHAMP
3d3b63a596 feat: technical release (#6436) 2022-09-26 16:42:47 +02:00
Pierre Donias
4f9636b4c3 fix(xo-web/home/VMs): do not delete VMs when confirm modal has been cancelled (#6435)
See Zammad#9735
Introduced by 11e09e1f87
2022-09-26 15:27:01 +02:00
Mathieu
74c8d56046 feat(xo-web/storage/NFS): ability to specify subdirectory (#6425)
Fixes #3919
2022-09-26 15:17:27 +02:00
Florent BEAUCHAMP
2d6b827fd2 fix(xo-server): ignore disabled remotes when running VM backup (#6430) 2022-09-26 14:50:30 +02:00
Julien Fontanet
f82eb8aeb4 feat(xapi/VM_{checkpoint,snapshot}): HTTP sync hook (#6423) 2022-09-26 12:23:51 +02:00
324 changed files with 18776 additions and 2175 deletions

View File

@@ -28,8 +28,10 @@ module.exports = {
},
},
{
files: ['*.spec.{,c,m}js'],
files: ['*.{spec,test}.{,c,m}js'],
rules: {
'n/no-unpublished-require': 'off',
'n/no-unpublished-import': 'off',
'n/no-unsupported-features/node-builtins': [
'error',
{

View File

@@ -6,7 +6,10 @@ labels: 'status: triaging :triangular_flag_on_post:, type: bug :bug:'
assignees: ''
---
**XOA or XO from the sources?**
1. ⚠️ **If you don't follow this template, the issue will be closed**.
2. ⚠️ **If your issue can't be easily reproduced, please report it [on the forum first](https://xcp-ng.org/forum/category/12/xen-orchestra)**.
Are you using XOA or XO from the sources?
If XOA:
@@ -15,6 +18,7 @@ If XOA:
If XO from the sources:
- Provide **your commit number**. If it's older than a week, we won't investigate
- Don't forget to [read this first](https://xen-orchestra.com/docs/community.html)
- As well as follow [this guide](https://xen-orchestra.com/docs/community.html#report-a-bug)
@@ -38,8 +42,6 @@ If applicable, add screenshots to help explain your problem.
**Environment (please provide the following information):**
- Node: [e.g. 16.12.1]
- xo-server: [e.g. 5.82.3]
- xo-web: [e.g. 5.87.0]
- hypervisor: [e.g. XCP-ng 8.2.0]
**Additional context**

View File

@@ -1,13 +1,12 @@
name: CI
on: [push]
on: push
jobs:
build:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: satackey/action-docker-layer-caching@v0.0.11
# Ignore the failure of a step and avoid terminating the job.
continue-on-error: true
- run: docker-compose -f docker/docker-compose.dev.yml build
- run: docker-compose -f docker/docker-compose.dev.yml up
- name: Checkout
uses: actions/checkout@v3
- name: Build docker image
run: docker-compose -f docker/docker-compose.dev.yml build
- name: Create the container and start the tests
run: docker-compose -f docker/docker-compose.dev.yml up --exit-code-from xo

View File

@@ -1,6 +1,8 @@
'use strict'
/* eslint-env jest */
const { describe, it, beforeEach } = require('test')
const assert = require('assert').strict
const { spy } = require('sinon')
const { asyncEach } = require('./')
@@ -34,12 +36,18 @@ describe('asyncEach', () => {
})
it('works', async () => {
const iteratee = jest.fn(async () => {})
const iteratee = spy(async () => {})
await asyncEach.call(thisArg, iterable, iteratee, { concurrency: 1 })
expect(iteratee.mock.instances).toEqual(Array.from(values, () => thisArg))
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
assert.deepStrictEqual(
iteratee.thisValues,
Array.from(values, () => thisArg)
)
assert.deepStrictEqual(
iteratee.args,
Array.from(values, (value, index) => [value, index, iterable])
)
})
;[1, 2, 4].forEach(concurrency => {
it('respects a concurrency of ' + concurrency, async () => {
@@ -49,7 +57,7 @@ describe('asyncEach', () => {
values,
async () => {
++running
expect(running).toBeLessThanOrEqual(concurrency)
assert.deepStrictEqual(running <= concurrency, true)
await randomDelay()
--running
},
@@ -59,42 +67,52 @@ describe('asyncEach', () => {
})
it('stops on first error when stopOnError is true', async () => {
const tracker = new assert.CallTracker()
const error = new Error()
const iteratee = jest.fn((_, i) => {
const iteratee = tracker.calls((_, i) => {
if (i === 1) {
throw error
}
})
}, 2)
assert.deepStrictEqual(
await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true })),
error
)
expect(await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true }))).toBe(error)
expect(iteratee).toHaveBeenCalledTimes(2)
tracker.verify()
})
it('rejects AggregateError when stopOnError is false', async () => {
const errors = []
const iteratee = jest.fn(() => {
const iteratee = spy(() => {
const error = new Error()
errors.push(error)
throw error
})
const error = await rejectionOf(asyncEach(iterable, iteratee, { stopOnError: false }))
expect(error.errors).toEqual(errors)
expect(iteratee.mock.calls).toEqual(Array.from(values, (value, index) => [value, index, iterable]))
assert.deepStrictEqual(error.errors, errors)
assert.deepStrictEqual(
iteratee.args,
Array.from(values, (value, index) => [value, index, iterable])
)
})
it('can be interrupted with an AbortSignal', async () => {
const tracker = new assert.CallTracker()
const ac = new AbortController()
const iteratee = jest.fn((_, i) => {
const iteratee = tracker.calls((_, i) => {
if (i === 1) {
ac.abort()
}
}, 2)
await assert.rejects(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal }), {
message: 'asyncEach aborted',
})
await expect(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal })).rejects.toThrow(
'asyncEach aborted'
)
expect(iteratee).toHaveBeenCalledTimes(2)
tracker.verify()
})
})
)

View File

@@ -29,6 +29,12 @@
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"sinon": "^14.0.1",
"tap": "^16.3.0",
"test": "^3.2.1"
}
}

View File

@@ -1,6 +1,7 @@
'use strict'
/* eslint-env jest */
const { describe, it } = require('test')
const assert = require('assert')
const { coalesceCalls } = require('./')
@@ -23,13 +24,13 @@ describe('coalesceCalls', () => {
const promise2 = fn(defer2.promise)
defer1.resolve('foo')
expect(await promise1).toBe('foo')
expect(await promise2).toBe('foo')
assert.strictEqual(await promise1, 'foo')
assert.strictEqual(await promise2, 'foo')
const defer3 = pDefer()
const promise3 = fn(defer3.promise)
defer3.resolve('bar')
expect(await promise3).toBe('bar')
assert.strictEqual(await promise3, 'bar')
})
})

View File

@@ -30,6 +30,10 @@
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.2.1"
}
}

View File

@@ -1,6 +1,7 @@
'use strict'
/* eslint-env jest */
const { describe, it } = require('test')
const assert = require('node:assert').strict
const { compose } = require('./')
@@ -9,43 +10,42 @@ const mul3 = x => x * 3
describe('compose()', () => {
it('throws when no functions is passed', () => {
expect(() => compose()).toThrow(TypeError)
expect(() => compose([])).toThrow(TypeError)
assert.throws(() => compose(), TypeError)
assert.throws(() => compose([]), TypeError)
})
it('applies from left to right', () => {
expect(compose(add2, mul3)(5)).toBe(21)
assert.strictEqual(compose(add2, mul3)(5), 21)
})
it('accepts functions in an array', () => {
expect(compose([add2, mul3])(5)).toBe(21)
assert.strictEqual(compose([add2, mul3])(5), 21)
})
it('can apply from right to left', () => {
expect(compose({ right: true }, add2, mul3)(5)).toBe(17)
assert.strictEqual(compose({ right: true }, add2, mul3)(5), 17)
})
it('accepts options with functions in an array', () => {
expect(compose({ right: true }, [add2, mul3])(5)).toBe(17)
assert.strictEqual(compose({ right: true }, [add2, mul3])(5), 17)
})
it('can compose async functions', async () => {
expect(
assert.strictEqual(
await compose(
{ async: true },
async x => x + 2,
async x => x * 3
)(5)
).toBe(21)
)(5),
21
)
})
it('forwards all args to first function', () => {
expect.assertions(1)
const expectedArgs = [Math.random(), Math.random()]
compose(
(...args) => {
expect(args).toEqual(expectedArgs)
assert.deepEqual(args, expectedArgs)
},
// add a second function to avoid the one function special case
Function.prototype
@@ -53,15 +53,13 @@ describe('compose()', () => {
})
it('forwards context to all functions', () => {
expect.assertions(2)
const expectedThis = {}
compose(
function () {
expect(this).toBe(expectedThis)
assert.strictEqual(this, expectedThis)
},
function () {
expect(this).toBe(expectedThis)
assert.strictEqual(this, expectedThis)
}
).call(expectedThis)
})

View File

@@ -19,6 +19,10 @@
"node": ">=7.6"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.2.1"
}
}

View File

@@ -1,7 +1,7 @@
'use strict'
const assert = require('assert')
const { describe, it } = require('tap').mocha
const { describe, it } = require('test')
const { decorateClass, decorateWith, decorateMethodsWith, perInstance } = require('./')

View File

@@ -26,9 +26,9 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test": "tap"
"test": "node--test"
},
"devDependencies": {
"tap": "^16.0.1"
"test": "^3.2.1"
}
}

View File

@@ -1,16 +1,17 @@
'use strict'
/* eslint-env jest */
const { describe, it } = require('test')
const { useFakeTimers, spy, assert } = require('sinon')
const { createDebounceResource } = require('./debounceResource')
jest.useFakeTimers()
const clock = useFakeTimers()
describe('debounceResource()', () => {
it('calls the resource disposer after 10 seconds', async () => {
const debounceResource = createDebounceResource()
const delay = 10e3
const dispose = jest.fn()
const dispose = spy()
const resource = await debounceResource(
Promise.resolve({
@@ -22,10 +23,10 @@ describe('debounceResource()', () => {
resource.dispose()
expect(dispose).not.toBeCalled()
assert.notCalled(dispose)
jest.advanceTimersByTime(delay)
clock.tick(delay)
expect(dispose).toBeCalled()
assert.called(dispose)
})
})

View File

@@ -1,13 +1,14 @@
'use strict'
/* eslint-env jest */
const { describe, it } = require('test')
const { spy, assert } = require('sinon')
const { deduped } = require('./deduped')
describe('deduped()', () => {
it('calls the resource function only once', async () => {
const value = {}
const getResource = jest.fn(async () => ({
const getResource = spy(async () => ({
value,
dispose: Function.prototype,
}))
@@ -17,13 +18,13 @@ describe('deduped()', () => {
const { value: v1 } = await dedupedGetResource()
const { value: v2 } = await dedupedGetResource()
expect(getResource).toHaveBeenCalledTimes(1)
expect(v1).toBe(value)
expect(v2).toBe(value)
assert.calledOnce(getResource)
assert.match(v1, value)
assert.match(v2, value)
})
it('only disposes the source disposable when its all copies dispose', async () => {
const dispose = jest.fn()
const dispose = spy()
const getResource = async () => ({
value: '',
dispose,
@@ -36,35 +37,35 @@ describe('deduped()', () => {
d1()
expect(dispose).not.toHaveBeenCalled()
assert.notCalled(dispose)
d2()
expect(dispose).toHaveBeenCalledTimes(1)
assert.calledOnce(dispose)
})
it('works with sync factory', () => {
const value = {}
const dispose = jest.fn()
const dispose = spy()
const dedupedGetResource = deduped(() => ({ value, dispose }))
const d1 = dedupedGetResource()
expect(d1.value).toBe(value)
assert.match(d1.value, value)
const d2 = dedupedGetResource()
expect(d2.value).toBe(value)
assert.match(d2.value, value)
d1.dispose()
expect(dispose).not.toHaveBeenCalled()
assert.notCalled(dispose)
d2.dispose()
expect(dispose).toHaveBeenCalledTimes(1)
assert.calledOnce(dispose)
})
it('no race condition on dispose before async acquisition', async () => {
const dispose = jest.fn()
const dispose = spy()
const dedupedGetResource = deduped(async () => ({ value: 42, dispose }))
const d1 = await dedupedGetResource()
@@ -73,6 +74,6 @@ describe('deduped()', () => {
d1.dispose()
expect(dispose).not.toHaveBeenCalled()
assert.notCalled(dispose)
})
})

View File

@@ -14,17 +14,22 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.1.1",
"version": "0.1.2",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"dependencies": {
"@vates/multi-key-map": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/log": "^0.4.0",
"ensure-array": "^1.0.0"
},
"devDependencies": {
"sinon": "^14.0.1",
"test": "^3.2.1"
}
}

View File

@@ -4,9 +4,6 @@ const LRU = require('lru-cache')
const Fuse = require('fuse-native')
const { VhdSynthetic } = require('vhd-lib')
const { Disposable, fromCallback } = require('promise-toolbox')
const { createLogger } = require('@xen-orchestra/log')
const { warn } = createLogger('vates:fuse-vhd')
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
@@ -57,9 +54,7 @@ exports.mount = Disposable.factory(async function* mount(handler, diskPath, moun
},
read(path, fd, buf, len, pos, cb) {
if (path === '/vhd0') {
return vhd
.readRawData(pos, len, cache, buf)
.then(cb)
return vhd.readRawData(pos, len, cache, buf).then(cb)
}
throw new Error(`read file ${path} not exists`)
},
@@ -67,5 +62,5 @@ exports.mount = Disposable.factory(async function* mount(handler, diskPath, moun
return new Disposable(
() => fromCallback(() => fuse.unmount()),
fromCallback(() => fuse.mount())
)
)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/fuse-vhd",
"version": "0.0.1",
"version": "1.0.0",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
@@ -18,11 +18,10 @@
"node": ">=10.0"
},
"dependencies": {
"@xen-orchestra/log": "^0.3.0",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.0.1"
"vhd-lib": "^4.1.1"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -1,6 +1,7 @@
'use strict'
/* eslint-env jest */
const { describe, it } = require('test')
const assert = require('node:assert')
const { MultiKeyMap } = require('./')
@@ -28,9 +29,9 @@ describe('MultiKeyMap', () => {
keys.forEach((key, i) => {
// copy the key to make sure the array itself is not the key
expect(map.get(key.slice())).toBe(values[i])
assert.strictEqual(map.get(key.slice()), values[i])
map.delete(key.slice())
expect(map.get(key.slice())).toBe(undefined)
assert.strictEqual(map.get(key.slice()), undefined)
})
})
})

View File

@@ -23,6 +23,10 @@
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"devDependencies": {
"test": "^3.2.1"
}
}

View File

@@ -0,0 +1,16 @@
### `new NdbClient({address, exportname, secure = true, port = 10809})`
create a new nbd client
```js
import NbdClient from '@vates/nbd-client'
const client = new NbdClient({
address: 'MY_NBD_HOST',
exportname: 'MY_SECRET_EXPORT',
cert: 'Server certificate', // optional, will use encrypted link if provided
})
await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```

View File

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

View File

@@ -0,0 +1,47 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/nbd-client
[![Package Version](https://badgen.net/npm/v/@vates/nbd-client)](https://npmjs.org/package/@vates/nbd-client) ![License](https://badgen.net/npm/license/@vates/nbd-client) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/nbd-client)](https://bundlephobia.com/result?p=@vates/nbd-client) [![Node compatibility](https://badgen.net/npm/node/@vates/nbd-client)](https://npmjs.org/package/@vates/nbd-client)
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/nbd-client):
```
> npm install --save @vates/nbd-client
```
## Usage
### `new NdbClient({address, exportname, secure = true, port = 10809})`
create a new nbd client
```js
import NbdClient from '@vates/nbd-client'
const client = new NbdClient({
address: 'MY_NBD_HOST',
exportname: 'MY_SECRET_EXPORT',
cert: 'Server certificate', // optional, will use encrypted link if provided
})
await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
## 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,42 @@
'use strict'
exports.INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
exports.OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
exports.NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
exports.NBD_OPT_EXPORT_NAME = 1
exports.NBD_OPT_ABORT = 2
exports.NBD_OPT_LIST = 3
exports.NBD_OPT_STARTTLS = 5
exports.NBD_OPT_INFO = 6
exports.NBD_OPT_GO = 7
exports.NBD_FLAG_HAS_FLAGS = 1 << 0
exports.NBD_FLAG_READ_ONLY = 1 << 1
exports.NBD_FLAG_SEND_FLUSH = 1 << 2
exports.NBD_FLAG_SEND_FUA = 1 << 3
exports.NBD_FLAG_ROTATIONAL = 1 << 4
exports.NBD_FLAG_SEND_TRIM = 1 << 5
exports.NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
exports.NBD_CMD_FLAG_FUA = 1 << 0
exports.NBD_CMD_FLAG_NO_HOLE = 1 << 1
exports.NBD_CMD_FLAG_DF = 1 << 2
exports.NBD_CMD_FLAG_REQ_ONE = 1 << 3
exports.NBD_CMD_FLAG_FAST_ZERO = 1 << 4
exports.NBD_CMD_READ = 0
exports.NBD_CMD_WRITE = 1
exports.NBD_CMD_DISC = 2
exports.NBD_CMD_FLUSH = 3
exports.NBD_CMD_TRIM = 4
exports.NBD_CMD_CACHE = 5
exports.NBD_CMD_WRITE_ZEROES = 6
exports.NBD_CMD_BLOCK_STATUS = 7
exports.NBD_CMD_RESIZE = 8
exports.NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
exports.NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
exports.NBD_REPLY_ACK = 1
exports.NBD_DEFAULT_PORT = 10809
exports.NBD_DEFAULT_BLOCK_SIZE = 64 * 1024

243
@vates/nbd-client/index.js Normal file
View File

@@ -0,0 +1,243 @@
'use strict'
const assert = require('node:assert')
const { Socket } = require('node:net')
const { connect } = require('node:tls')
const {
INIT_PASSWD,
NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE,
NBD_DEFAULT_PORT,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_OPT_STARTTLS,
NBD_REPLY_ACK,
NBD_REPLY_MAGIC,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
} = require('./constants.js')
const { fromCallback } = require('promise-toolbox')
const { readChunkStrict } = require('@vates/read-chunk')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
module.exports = class NbdClient {
#serverAddress
#serverCert
#serverPort
#serverSocket
#exportName
#exportSize
// 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}
constructor({ address, port = NBD_DEFAULT_PORT, exportname, cert }) {
this.#serverAddress = address
this.#serverPort = port
this.#exportName = exportname
this.#serverCert = cert
}
get exportSize() {
return this.#exportSize
}
async #tlsConnect() {
return new Promise((resolve, reject) => {
this.#serverSocket = connect({
socket: this.#serverSocket,
rejectUnauthorized: false,
cert: this.#serverCert,
})
this.#serverSocket.once('error', reject)
this.#serverSocket.once('secureConnect', () => {
this.#serverSocket.removeListener('error', reject)
resolve()
})
})
}
// mandatory , at least to start the handshake
async #unsecureConnect() {
this.#serverSocket = new Socket()
return new Promise((resolve, reject) => {
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
this.#serverSocket.once('error', reject)
this.#serverSocket.once('connect', () => {
this.#serverSocket.removeListener('error', reject)
resolve()
})
})
}
async connect() {
// first we connect to the serve without tls, and then we upgrade the connection
// to tls during the handshake
await this.#unsecureConnect()
await this.#handshake()
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async disconnect() {
await this.#serverSocket.destroy()
}
// we can use individual read/write from the socket here since there is no concurrency
async #sendOption(option, buffer = Buffer.alloc(0)) {
await this.#write(OPTS_MAGIC)
await this.#writeInt32(option)
await this.#writeInt32(buffer.length)
await this.#write(buffer)
assert.strictEqual(await this.#readInt64(), NBD_OPT_REPLY_MAGIC) // magic number everywhere
assert.strictEqual(await this.#readInt32(), option) // the option passed
assert.strictEqual(await this.#readInt32(), NBD_REPLY_ACK) // ACK
const length = await this.#readInt32()
assert.strictEqual(length, 0) // length
}
// we can use individual read/write from the socket here since there is only one handshake at once, no concurrency
async #handshake() {
assert((await this.#read(8)).equals(INIT_PASSWD))
assert((await this.#read(8)).equals(OPTS_MAGIC))
const flagsBuffer = await this.#read(2)
const flags = flagsBuffer.readInt16BE(0)
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
await this.#writeInt32(NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
if (this.#serverCert !== undefined) {
// upgrade socket to TLS if needed
await this.#sendOption(NBD_OPT_STARTTLS)
await this.#tlsConnect()
}
// send export name we want to access.
// it's implictly closing the negociation phase.
await this.#write(OPTS_MAGIC)
await this.#writeInt32(NBD_OPT_EXPORT_NAME)
const exportNameBuffer = Buffer.from(this.#exportName)
await this.#writeInt32(exportNameBuffer.length)
await this.#write(exportNameBuffer)
// 8 (export size ) + 2 (flags) + 124 zero = 134
// must read all to ensure nothing stays in the buffer
const answer = await this.#read(134)
this.#exportSize = answer.readBigUInt64BE(0)
const transmissionFlags = answer.readInt16BE(8)
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
}
#read(length) {
return readChunkStrict(this.#serverSocket, length)
}
#write(buffer) {
return fromCallback.call(this.#serverSocket, 'write', buffer)
}
async #readInt32() {
const buffer = await this.#read(4)
return buffer.readInt32BE(0)
}
async #readInt64() {
const buffer = await this.#read(8)
return buffer.readBigUInt64BE(0)
}
#writeInt32(int) {
const buffer = Buffer.alloc(4)
buffer.writeInt32BE(int)
return this.#write(buffer)
}
// when one read fail ,stop everything
async #rejectAll(error) {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
await this.disconnect()
}
async #readBlockResponse() {
// ensure at most one read occur in parallel
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const magic = await this.#readInt32()
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = await this.#readInt32()
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = await this.#readInt64()
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
}
this.#commandQueryBacklog.delete(blockQueryId)
const data = await this.#read(query.size)
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
await this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
// we don't need to call readBlockResponse on failure
// since we will empty the backlog
await this.#rejectAll(error)
}
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
// create and send command at once to ensure there is no concurrency issue
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 simple block read
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
buffer.writeBigUInt64BE(queryId, 8)
// byte offset in the raw disk
buffer.writeBigUInt64BE(BigInt(index) * BigInt(size), 16)
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
// 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,
})
// really send the command to the server
this.#write(buffer).catch(reject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
}

View File

@@ -0,0 +1,76 @@
'use strict'
const NbdClient = require('./index.js')
const { spawn } = require('node:child_process')
const fs = require('node:fs/promises')
const { test } = require('tap')
const tmp = require('tmp')
const { pFromCallback } = require('promise-toolbox')
const { asyncEach } = require('@vates/async-each')
const FILE_SIZE = 2 * 1024 * 1024
async function createTempFile(size) {
const tmpPath = await pFromCallback(cb => tmp.file(cb))
const data = Buffer.alloc(size, 0)
for (let i = 0; i < size; i += 4) {
data.writeUInt32BE(i, i)
}
await fs.writeFile(tmpPath, data)
return tmpPath
}
test('it works with unsecured network', async tap => {
const path = await createTempFile(FILE_SIZE)
const nbdServer = spawn(
'nbdkit',
[
'file',
path,
'--newstyle', //
'--exit-with-parent',
'--read-only',
'--export-name=MY_SECRET_EXPORT',
],
{
stdio: ['inherit', 'inherit', 'inherit'],
}
)
const client = new NbdClient({
address: 'localhost',
exportname: 'MY_SECRET_EXPORT',
secure: false,
})
await client.connect()
tap.equal(client.exportSize, BigInt(FILE_SIZE))
const CHUNK_SIZE = 128 * 1024 // non default size
const indexes = []
for (let i = 0; i < FILE_SIZE / CHUNK_SIZE; i++) {
indexes.push(i)
}
// read mutiple blocks in parallel
await asyncEach(
indexes,
async i => {
const block = await client.readBlock(i, CHUNK_SIZE)
let blockOk = true
let firstFail
for (let j = 0; j < CHUNK_SIZE; j += 4) {
const wanted = i * CHUNK_SIZE + j
const found = block.readUInt32BE(j)
blockOk = blockOk && found === wanted
if (!blockOk && firstFail === undefined) {
firstFail = j
}
}
tap.ok(blockOk, `check block ${i} content`)
},
{ concurrency: 8 }
)
await client.disconnect()
nbdServer.kill()
await fs.unlink(path)
})

View File

@@ -0,0 +1,35 @@
{
"private": false,
"name": "@vates/nbd-client",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/nbd-client",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/nbd-client",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.0.0",
"engines": {
"node": ">=14.0"
},
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.2.2"
},
"devDependencies": {
"tap": "^16.3.0",
"tmp": "^0.2.1"
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap *.spec.js"
}
}

130
@vates/otp/.USAGE.md Normal file
View File

@@ -0,0 +1,130 @@
### Usual workflow
> This section presents how this library should be used to implement a classic two factor authentification.
#### Setup
```js
import { generateSecret, generateTotp } from '@vates/otp'
import QrCode from 'qrcode'
// Generates a secret that will be shared by both the service and the user:
const secret = generateSecret()
// Stores the secret in the service:
await currentUser.saveOtpSecret(secret)
// Generates an URI to present to the user
const uri = generateTotpUri({ secret })
// Generates the QR code from the URI to make it easily importable in Authy or Google Authenticator
const qr = await QrCode.toDataURL(uri)
```
#### Authentication
```js
import { verifyTotp } from '@vates/otp'
// Verifies a `token` entered by the user against a `secret` generated during setup.
if (await verifyTotp(token, { secret })) {
console.log('authenticated!')
}
```
### API
#### Secret
```js
import { generateSecret } from '@vates/otp'
const secret = generateSecret()
// 'OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
```
#### HOTP
> This is likely not what you want to use, see TOTP below instead.
```js
import { generateHotp, generateHotpUri, verifyHotp } from '@vates/otp'
// a sequence number, see HOTP specification
const counter = 0
// generate a token
//
// optional params:
// - digits
const token = await generateHotp({ counter, secret })
// '239988'
// verify a token
//
// optional params:
// - digits
const isValid = await verifyHotp(token, { counter, secret })
// true
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
//
// optional params:
// - digits
const uri = generateHotpUri({ counter, label: 'account name', issuer: 'my app', secret })
// 'otpauth://hotp/my%20app:account%20name?counter=0&issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
```
Optional params and their default values:
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
#### TOTP
```js
import { generateTotp, generateTotpUri, verifyTotp } from '@vates/otp'
// generate a token
//
// optional params:
// - digits
// - period
// - timestamp
const token = await generateTotp({ secret })
// '632869'
// verify a token
//
// optional params:
// - digits
// - period
// - timestamp
// - window
const isValid = await verifyTotp(token, { secret })
// true
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
//
// optional params:
// - digits
// - period
const uri = generateTotpUri({ label: 'account name', issuer: 'my app', secret })
// 'otpauth://totp/my%20app:account%20name?issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
```
Optional params and their default values:
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
- `period = 30`: number of seconds a token is valid
- `timestamp = Date.now() / 1e3`: Unix timestamp, in seconds, when this token will be valid, default to now
- `window = 1`: number of periods before and after `timestamp` for which the token is considered valid
#### Verification from URI
```js
import { verifyFromUri } from '@vates/otp'
// Verify the token using all the information contained in the URI
const isValid = await verifyFromUri(token, uri)
// true
```

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

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

163
@vates/otp/README.md Normal file
View File

@@ -0,0 +1,163 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/otp
[![Package Version](https://badgen.net/npm/v/@vates/otp)](https://npmjs.org/package/@vates/otp) ![License](https://badgen.net/npm/license/@vates/otp) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/otp)](https://bundlephobia.com/result?p=@vates/otp) [![Node compatibility](https://badgen.net/npm/node/@vates/otp)](https://npmjs.org/package/@vates/otp)
> Minimal HTOP/TOTP implementation
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/otp):
```
> npm install --save @vates/otp
```
## Usage
### Usual workflow
> This section presents how this library should be used to implement a classic two factor authentification.
#### Setup
```js
import { generateSecret, generateTotp } from '@vates/otp'
import QrCode from 'qrcode'
// Generates a secret that will be shared by both the service and the user:
const secret = generateSecret()
// Stores the secret in the service:
await currentUser.saveOtpSecret(secret)
// Generates an URI to present to the user
const uri = generateTotpUri({ secret })
// Generates the QR code from the URI to make it easily importable in Authy or Google Authenticator
const qr = await QrCode.toDataURL(uri)
```
#### Authentication
```js
import { verifyTotp } from '@vates/otp'
// Verifies a `token` entered by the user against a `secret` generated during setup.
if (await verifyTotp(token, { secret })) {
console.log('authenticated!')
}
```
### API
#### Secret
```js
import { generateSecret } from '@vates/otp'
const secret = generateSecret()
// 'OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
```
#### HOTP
> This is likely not what you want to use, see TOTP below instead.
```js
import { generateHotp, generateHotpUri, verifyHotp } from '@vates/otp'
// a sequence number, see HOTP specification
const counter = 0
// generate a token
//
// optional params:
// - digits
const token = await generateHotp({ counter, secret })
// '239988'
// verify a token
//
// optional params:
// - digits
const isValid = await verifyHotp(token, { counter, secret })
// true
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
//
// optional params:
// - digits
const uri = generateHotpUri({ counter, label: 'account name', issuer: 'my app', secret })
// 'otpauth://hotp/my%20app:account%20name?counter=0&issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
```
Optional params and their default values:
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
#### TOTP
```js
import { generateTotp, generateTotpUri, verifyTotp } from '@vates/otp'
// generate a token
//
// optional params:
// - digits
// - period
// - timestamp
const token = await generateTotp({ secret })
// '632869'
// verify a token
//
// optional params:
// - digits
// - period
// - timestamp
// - window
const isValid = await verifyTotp(token, { secret })
// true
// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator
//
// optional params:
// - digits
// - period
const uri = generateTotpUri({ label: 'account name', issuer: 'my app', secret })
// 'otpauth://totp/my%20app:account%20name?issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH'
```
Optional params and their default values:
- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator
- `period = 30`: number of seconds a token is valid
- `timestamp = Date.now() / 1e3`: Unix timestamp, in seconds, when this token will be valid, default to now
- `window = 1`: number of periods before and after `timestamp` for which the token is considered valid
#### Verification from URI
```js
import { verifyFromUri } from '@vates/otp'
// Verify the token using all the information contained in the URI
const isValid = await verifyFromUri(token, uri)
// true
```
## 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)

111
@vates/otp/index.mjs Normal file
View File

@@ -0,0 +1,111 @@
import { base32 } from 'rfc4648'
import { webcrypto } from 'node:crypto'
const { subtle } = webcrypto
function assert(name, value) {
if (!value) {
throw new TypeError('invalid value for param ' + name)
}
}
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
function generateUri(protocol, label, params) {
assert('label', typeof label === 'string')
assert('secret', typeof params.secret === 'string')
let path = encodeURIComponent(label)
const { issuer } = params
if (issuer !== undefined) {
path = encodeURIComponent(issuer) + ':' + path
}
const query = Object.entries(params)
.filter(_ => _[1] !== undefined)
.map(([key, value]) => key + '=' + encodeURIComponent(value))
.join('&')
return `otpauth://${protocol}/${path}?${query}`
}
export function generateSecret() {
// https://www.rfc-editor.org/rfc/rfc4226 recommends 160 bits (i.e. 20 bytes)
const data = new Uint8Array(20)
webcrypto.getRandomValues(data)
return base32.stringify(data, { pad: false })
}
const DIGITS = 6
// https://www.rfc-editor.org/rfc/rfc4226
export async function generateHotp({ counter, digits = DIGITS, secret }) {
const data = new Uint8Array(8)
new DataView(data.buffer).setBigInt64(0, BigInt(counter), false)
const key = await subtle.importKey(
'raw',
base32.parse(secret, { loose: true }),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign', 'verify']
)
const digest = new DataView(await subtle.sign('HMAC', key, data))
const offset = digest.getUint8(digest.byteLength - 1) & 0xf
const p = digest.getUint32(offset) & 0x7f_ff_ff_ff
return String(p % Math.pow(10, digits)).padStart(digits, '0')
}
export function generateHotpUri({ counter, digits, issuer, label, secret }) {
assert('counter', typeof counter === 'number')
return generateUri('hotp', label, { counter, digits, issuer, secret })
}
export async function verifyHotp(token, opts) {
return token === (await generateHotp(opts))
}
function totpCounter(period = 30, timestamp = Math.floor(Date.now() / 1e3)) {
return Math.floor(timestamp / period)
}
// https://www.rfc-editor.org/rfc/rfc6238.html
export async function generateTotp({ period, timestamp, ...opts }) {
opts.counter = totpCounter(period, timestamp)
return await generateHotp(opts)
}
export function generateTotpUri({ digits, issuer, label, period, secret }) {
return generateUri('totp', label, { digits, issuer, period, secret })
}
export async function verifyTotp(token, { period, timestamp, window = 1, ...opts }) {
const counter = totpCounter(period, timestamp)
const end = counter + window
opts.counter = counter - window
while (opts.counter <= end) {
if (token === (await generateHotp(opts))) {
return true
}
opts.counter += 1
}
return false
}
export async function verifyFromUri(token, uri) {
const url = new URL(uri)
assert('protocol', url.protocol === 'otpauth:')
const { host } = url
const opts = Object.fromEntries(url.searchParams.entries())
if (host === 'hotp') {
return await verifyHotp(token, opts)
}
if (host === 'totp') {
return await verifyTotp(token, opts)
}
assert('host', false)
}

112
@vates/otp/index.spec.mjs Normal file
View File

@@ -0,0 +1,112 @@
import { strict as assert } from 'node:assert'
import { describe, it } from 'tap/mocha'
import {
generateHotp,
generateHotpUri,
generateSecret,
generateTotp,
generateTotpUri,
verifyHotp,
verifyTotp,
} from './index.mjs'
describe('generateSecret', function () {
it('generates a string of 32 chars', async function () {
const secret = generateSecret()
assert.equal(typeof secret, 'string')
assert.equal(secret.length, 32)
})
it('generates a different secret at each call', async function () {
assert.notEqual(generateSecret(), generateSecret())
})
})
describe('HOTP', function () {
it('generate and verify valid tokens', async function () {
for (const [token, opts] of Object.entries({
382752: {
counter: -3088,
secret: 'PJYFSZ3JNVXVQMZXOB2EQYJSKB2HE6TB',
},
163376: {
counter: 30598,
secret: 'GBUDQZ3UKZZGIMRLNVYXA33GMFMEGQKN',
},
})) {
assert.equal(await generateHotp(opts), token)
assert(await verifyHotp(token, opts))
}
})
describe('generateHotpUri', function () {
const opts = {
counter: 59732,
label: 'the label',
secret: 'OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
}
Object.entries({
'without optional params': [
opts,
'otpauth://hotp/the%20label?counter=59732&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
],
'with issuer': [
{ ...opts, issuer: 'the issuer' },
'otpauth://hotp/the%20issuer:the%20label?counter=59732&issuer=the%20issuer&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
],
'with digits': [
{ ...opts, digits: 7 },
'otpauth://hotp/the%20label?counter=59732&digits=7&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
],
}).forEach(([title, [opts, uri]]) => {
it(title, async function () {
assert.strictEqual(generateHotpUri(opts), uri)
})
})
})
})
describe('TOTP', function () {
Object.entries({
'033702': {
secret: 'PJYFSZ3JNVXVQMZXOB2EQYJSKB2HE6TB',
timestamp: 1665416296,
period: 30,
},
107250: {
secret: 'GBUDQZ3UKZZGIMRLNVYXA33GMFMEGQKN',
timestamp: 1665416674,
period: 60,
},
}).forEach(([token, opts]) => {
it('works', async function () {
assert.equal(await generateTotp(opts), token)
assert(await verifyTotp(token, opts))
})
})
describe('generateHotpUri', function () {
const opts = {
label: 'the label',
secret: 'OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
}
Object.entries({
'without optional params': [opts, 'otpauth://totp/the%20label?secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX'],
'with issuer': [
{ ...opts, issuer: 'the issuer' },
'otpauth://totp/the%20issuer:the%20label?issuer=the%20issuer&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
],
'with digits': [
{ ...opts, digits: 7 },
'otpauth://totp/the%20label?digits=7&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX',
],
}).forEach(([title, [opts, uri]]) => {
it(title, async function () {
assert.strictEqual(generateTotpUri(opts), uri)
})
})
})
})

39
@vates/otp/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"private": false,
"name": "@vates/otp",
"description": "Minimal HTOP/TOTP implementation",
"keywords": [
"2fa",
"authenticator",
"hotp",
"otp",
"totp"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/otp",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"main": "index.mjs",
"repository": {
"directory": "@vates/otp",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.0.0",
"engines": {
"node": ">=15"
},
"dependencies": {
"rfc4648": "^1.5.2"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "tap"
},
"devDependencies": {
"tap": "^16.3.0"
}
}

View File

@@ -1,7 +1,7 @@
`undefined` predicates are ignored and `undefined` is returned if all predicates are `undefined`, this permits the most efficient composition:
```js
const compositePredicate = every(undefined, some(predicate2, undefined))
const compositePredicate = not(every(undefined, some(not(predicate2), undefined)))
// ends up as
@@ -36,6 +36,21 @@ isBetween3And10(10)
// → false
```
### `not(predicate)`
> Returns a predicate that returns the negation of the predicate.
```js
const isEven = n => n % 2 === 0
const isOdd = not(isEven)
isOdd(1)
// true
isOdd(2)
// false
```
### `some(predicates)`
> Returns a predicate that returns `true` iff some predicate returns `true`.

View File

@@ -19,7 +19,7 @@ Installation of the [npm package](https://npmjs.org/package/@vates/predicates):
`undefined` predicates are ignored and `undefined` is returned if all predicates are `undefined`, this permits the most efficient composition:
```js
const compositePredicate = every(undefined, some(predicate2, undefined))
const compositePredicate = not(every(undefined, some(not(predicate2), undefined)))
// ends up as
@@ -54,6 +54,21 @@ isBetween3And10(10)
// → false
```
### `not(predicate)`
> Returns a predicate that returns the negation of the predicate.
```js
const isEven = n => n % 2 === 0
const isOdd = not(isEven)
isOdd(1)
// true
isOdd(2)
// false
```
### `some(predicates)`
> Returns a predicate that returns `true` iff some predicate returns `true`.

View File

@@ -51,6 +51,22 @@ exports.every = function every() {
}
}
const notPredicateTag = {}
exports.not = function not(predicate) {
if (isDefinedPredicate(predicate)) {
if (predicate.tag === notPredicateTag) {
return predicate.predicate
}
function notPredicate() {
return !predicate.apply(this, arguments)
}
notPredicate.predicate = predicate
notPredicate.tag = notPredicateTag
return notPredicate
}
}
exports.some = function some() {
const predicates = handleArgs.apply(this, arguments)
const n = predicates.length

View File

@@ -3,20 +3,14 @@
const assert = require('assert/strict')
const { describe, it } = require('tap').mocha
const { every, some } = require('./')
const { every, not, some } = require('./')
const T = () => true
const F = () => false
const testArgsHandling = fn => {
it('returns undefined if all predicates are undefined', () => {
const testArgHandling = fn => {
it('returns undefined if predicate is undefined', () => {
assert.equal(fn(undefined), undefined)
assert.equal(fn([undefined]), undefined)
})
it('returns the predicate if only a single one is passed', () => {
assert.equal(fn(undefined, T), T)
assert.equal(fn([undefined, T]), T)
})
it('throws if it receives a non-predicate', () => {
@@ -24,6 +18,15 @@ const testArgsHandling = fn => {
error.value = 3
assert.throws(() => fn(3), error)
})
}
const testArgsHandling = fn => {
testArgHandling(fn)
it('returns the predicate if only a single one is passed', () => {
assert.equal(fn(undefined, T), T)
assert.equal(fn([undefined, T]), T)
})
it('forwards this and arguments to predicates', () => {
const thisArg = 'qux'
@@ -36,17 +39,21 @@ const testArgsHandling = fn => {
})
}
const runTests = (fn, truthTable) =>
const runTests = (fn, acceptMultiple, truthTable) =>
it('works', () => {
truthTable.forEach(([result, ...predicates]) => {
if (acceptMultiple) {
assert.equal(fn(predicates)(), result)
} else {
assert.equal(predicates.length, 1)
}
assert.equal(fn(...predicates)(), result)
assert.equal(fn(predicates)(), result)
})
})
describe('every', () => {
testArgsHandling(every)
runTests(every, [
runTests(every, true, [
[true, T, T],
[false, T, F],
[false, F, T],
@@ -54,9 +61,22 @@ describe('every', () => {
])
})
describe('not', () => {
testArgHandling(not)
it('returns the original predicate if negated twice', () => {
assert.equal(not(not(T)), T)
})
runTests(not, false, [
[true, F],
[false, T],
])
})
describe('some', () => {
testArgsHandling(some)
runTests(some, [
runTests(some, true, [
[true, T, T],
[true, T, F],
[true, F, T],

View File

@@ -26,7 +26,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.0.0",
"version": "1.1.0",
"engines": {
"node": ">=6"
},

View File

@@ -1,7 +1,9 @@
'use strict'
const readChunk = (stream, size) =>
size === 0
stream.closed || stream.readableEnded
? Promise.resolve(null)
: size === 0
? Promise.resolve(Buffer.alloc(0))
: new Promise((resolve, reject) => {
function onEnd() {

View File

@@ -1,6 +1,7 @@
'use strict'
/* eslint-env jest */
const { describe, it } = require('test')
const assert = require('node:assert').strict
const { Readable } = require('stream')
@@ -11,35 +12,42 @@ makeStream.obj = Readable.from
describe('readChunk', () => {
it('returns null if stream is empty', async () => {
expect(await readChunk(makeStream([]))).toBe(null)
assert.strictEqual(await readChunk(makeStream([])), null)
})
it('returns null if the stream is already ended', async () => {
const stream = await makeStream([])
await readChunk(stream)
assert.strictEqual(await readChunk(stream), null)
})
describe('with binary stream', () => {
it('returns the first chunk of data', async () => {
expect(await readChunk(makeStream(['foo', 'bar']))).toEqual(Buffer.from('foo'))
assert.deepEqual(await readChunk(makeStream(['foo', 'bar'])), Buffer.from('foo'))
})
it('returns a chunk of the specified size (smaller than first)', async () => {
expect(await readChunk(makeStream(['foo', 'bar']), 2)).toEqual(Buffer.from('fo'))
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 2), Buffer.from('fo'))
})
it('returns a chunk of the specified size (larger than first)', async () => {
expect(await readChunk(makeStream(['foo', 'bar']), 4)).toEqual(Buffer.from('foob'))
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 4), Buffer.from('foob'))
})
it('returns less data if stream ends', async () => {
expect(await readChunk(makeStream(['foo', 'bar']), 10)).toEqual(Buffer.from('foobar'))
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 10), Buffer.from('foobar'))
})
it('returns an empty buffer if the specified size is 0', async () => {
expect(await readChunk(makeStream(['foo', 'bar']), 0)).toEqual(Buffer.alloc(0))
assert.deepEqual(await readChunk(makeStream(['foo', 'bar']), 0), Buffer.alloc(0))
})
})
describe('with object stream', () => {
it('returns the first chunk of data verbatim', async () => {
const chunks = [{}, {}]
expect(await readChunk(makeStream.obj(chunks))).toBe(chunks[0])
assert.strictEqual(await readChunk(makeStream.obj(chunks)), chunks[0])
})
})
})
@@ -55,15 +63,15 @@ const rejectionOf = promise =>
describe('readChunkStrict', function () {
it('throws if stream is empty', async () => {
const error = await rejectionOf(readChunkStrict(makeStream([])))
expect(error).toBeInstanceOf(Error)
expect(error.message).toBe('stream has ended without data')
expect(error.chunk).toEqual(undefined)
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(readChunkStrict(makeStream(['foo', 'bar']), 10))
expect(error).toBeInstanceOf(Error)
expect(error.message).toBe('stream has ended with not enough data')
expect(error.chunk).toEqual(Buffer.from('foobar'))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
})

View File

@@ -19,15 +19,19 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "1.0.0",
"version": "1.0.1",
"engines": {
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"devDependencies": {
"test": "^3.2.1"
}
}

View File

@@ -1,6 +1,8 @@
'use strict'
/* eslint-env jest */
const { describe, it } = require('test')
const assert = require('assert').strict
const sinon = require('sinon')
const { asyncMapSettled } = require('./')
@@ -9,26 +11,29 @@ const noop = Function.prototype
describe('asyncMapSettled', () => {
it('works', async () => {
const values = [Math.random(), Math.random()]
const spy = jest.fn(async v => v * 2)
const spy = sinon.spy(async v => v * 2)
const iterable = new Set(values)
// returns an array containing the result of each calls
expect(await asyncMapSettled(iterable, spy)).toEqual(values.map(value => value * 2))
assert.deepStrictEqual(
await asyncMapSettled(iterable, spy),
values.map(value => value * 2)
)
for (let i = 0, n = values.length; i < n; ++i) {
// each call receive the current item as sole argument
expect(spy.mock.calls[i]).toEqual([values[i]])
assert.deepStrictEqual(spy.args[i], [values[i]])
// each call as this bind to the iterable
expect(spy.mock.instances[i]).toBe(iterable)
assert.deepStrictEqual(spy.thisValues[i], iterable)
}
})
it('can use a specified thisArg', () => {
const thisArg = {}
const spy = jest.fn()
const spy = sinon.spy()
asyncMapSettled(['foo'], spy, thisArg)
expect(spy.mock.instances[0]).toBe(thisArg)
assert.deepStrictEqual(spy.thisValues[0], thisArg)
})
it('rejects only when all calls as resolved', async () => {
@@ -55,19 +60,22 @@ describe('asyncMapSettled', () => {
// wait for all microtasks to settle
await new Promise(resolve => setImmediate(resolve))
expect(hasSettled).toBe(false)
assert.strictEqual(hasSettled, false)
defers[1].resolve()
// wait for all microtasks to settle
await new Promise(resolve => setImmediate(resolve))
expect(hasSettled).toBe(true)
await expect(promise).rejects.toBe(error)
assert.strictEqual(hasSettled, true)
await assert.rejects(promise, error)
})
it('issues when latest promise rejects', async () => {
const error = new Error()
await expect(asyncMapSettled([1], () => Promise.reject(error))).rejects.toBe(error)
await assert.rejects(
asyncMapSettled([1], () => Promise.reject(error)),
error
)
})
})

View File

@@ -31,6 +31,11 @@
"lodash": "^4.17.4"
},
"scripts": {
"postversion": "npm publish"
"postversion": "npm publish",
"test": "node--test"
},
"devDependencies": {
"sinon": "^14.0.1",
"test": "^3.2.1"
}
}

View File

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

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.27.4",
"@xen-orchestra/fs": "^3.1.0",
"@xen-orchestra/backups": "^0.29.0",
"@xen-orchestra/fs": "^3.2.0",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "0.7.7",
"version": "0.7.8",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -43,6 +43,7 @@ const DEFAULT_VM_SETTINGS = {
offlineSnapshot: false,
snapshotRetention: 0,
timeout: 0,
useNbd: false,
unconditionalSnapshot: false,
vmTimeout: 0,
}

View File

@@ -76,14 +76,16 @@ const debounceResourceFactory = factory =>
}
class RemoteAdapter {
constructor(handler, { debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy=false } = {}) {
constructor(
handler,
{ debounceResource = res => res, dirMode, vhdDirectoryCompression, useGetDiskLegacy = false } = {}
) {
this._debounceResource = debounceResource
this._dirMode = dirMode
this._handler = handler
this._vhdDirectoryCompression = vhdDirectoryCompression
this._readCacheListVmBackups = synchronized.withKey()(this._readCacheListVmBackups)
this._useGetDiskLegacy = useGetDiskLegacy
}
get handler() {
@@ -324,9 +326,7 @@ class RemoteAdapter {
return this.#useVhdDirectory()
}
async *#getDiskLegacy(diskId) {
const RE_VHDI = /^vhdi(\d+)$/
const handler = this._handler
@@ -358,8 +358,8 @@ class RemoteAdapter {
}
async *getDisk(diskId) {
if(this._useGetDiskLegacy){
yield * this.#getDiskLegacy(diskId)
if (this._useGetDiskLegacy) {
yield* this.#getDiskLegacy(diskId)
return
}
const handler = this._handler
@@ -659,9 +659,8 @@ class RemoteAdapter {
return path
}
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency } = {}) {
async writeVhd(path, input, { checksum = true, validator = noop, writeBlockConcurrency, nbdClient } = {}) {
const handler = this._handler
if (this.#useVhdDirectory()) {
const dataPath = `${dirname(path)}/data/${uuidv4()}.vhd`
await createVhdDirectoryFromStream(handler, dataPath, input, {
@@ -671,6 +670,7 @@ class RemoteAdapter {
await input.task
return validator.apply(this, arguments)
},
nbdClient,
})
await VhdAbstract.createAlias(handler, path, dataPath)
} else {

View File

@@ -1,6 +1,7 @@
'use strict'
/* eslint-env jest */
const { beforeEach, afterEach, test, describe } = require('test')
const assert = require('assert').strict
const rimraf = require('rimraf')
const tmp = require('tmp')
@@ -14,9 +15,8 @@ const { VhdFile, Constants, VhdDirectory, VhdAbstract } = require('vhd-lib')
const { checkAliases } = require('./_cleanVm')
const { dirname, basename } = require('path')
let tempDir, adapter, handler, jobId, vdiId, basePath
jest.setTimeout(60000)
let tempDir, adapter, handler, jobId, vdiId, basePath, relativePath
const rootPath = 'xo-vm-backups/VMUUID/'
beforeEach(async () => {
tempDir = await pFromCallback(cb => tmp.dir(cb))
@@ -25,7 +25,8 @@ beforeEach(async () => {
adapter = new RemoteAdapter(handler)
jobId = uniqueId()
vdiId = uniqueId()
basePath = `vdis/${jobId}/${vdiId}`
relativePath = `vdis/${jobId}/${vdiId}`
basePath = `${rootPath}/${relativePath}`
await fs.mkdirp(`${tempDir}/${basePath}`)
})
@@ -76,18 +77,18 @@ test('It remove broken vhd', async () => {
// todo also tests a directory and an alias
await handler.writeFile(`${basePath}/notReallyAVhd.vhd`, 'I AM NOT A VHD')
expect((await handler.list(basePath)).length).toEqual(1)
assert.equal((await handler.list(basePath)).length, 1)
let loggued = ''
const logInfo = message => {
loggued += message
}
await adapter.cleanVm('/', { remove: false, logInfo, logWarn: logInfo, lock: false })
expect(loggued).toEqual(`VHD check error`)
await adapter.cleanVm(rootPath, { remove: false, logInfo, logWarn: logInfo, lock: false })
assert.equal(loggued, `VHD check error`)
// not removed
expect((await handler.list(basePath)).length).toEqual(1)
assert.deepEqual(await handler.list(basePath), ['notReallyAVhd.vhd'])
// really remove it
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: () => {}, lock: false })
expect((await handler.list(basePath)).length).toEqual(0)
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: () => {}, lock: false })
assert.deepEqual(await handler.list(basePath), [])
})
test('it remove vhd with missing or multiple ancestors', async () => {
@@ -121,10 +122,10 @@ test('it remove vhd with missing or multiple ancestors', async () => {
const logInfo = message => {
loggued += message + '\n'
}
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: logInfo, lock: false })
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
const deletedOrphanVhd = loggued.match(/deleting orphan VHD/g) || []
expect(deletedOrphanVhd.length).toEqual(1) // only one vhd should have been deleted
assert.equal(deletedOrphanVhd.length, 1) // only one vhd should have been deleted
// we don't test the filew on disk, since they will all be marker as unused and deleted without a metadata.json file
})
@@ -132,12 +133,12 @@ test('it remove vhd with missing or multiple ancestors', async () => {
test('it remove backup meta data referencing a missing vhd in delta backup', async () => {
// create a metadata file marking child and orphan as ok
await handler.writeFile(
`metadata.json`,
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/orphan.vhd`,
`${basePath}/child.vhd`,
`${relativePath}/orphan.vhd`,
`${relativePath}/child.vhd`,
// abandonned.json is not here
],
})
@@ -160,39 +161,39 @@ test('it remove backup meta data referencing a missing vhd in delta backup', asy
const logInfo = message => {
loggued += message + '\n'
}
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: logInfo, lock: false })
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
let matched = loggued.match(/deleting unused VHD/g) || []
expect(matched.length).toEqual(1) // only one vhd should have been deleted
assert.equal(matched.length, 1) // only one vhd should have been deleted
// a missing vhd cause clean to remove all vhds
await handler.writeFile(
`metadata.json`,
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/deleted.vhd`, // in metadata but not in vhds
`${basePath}/orphan.vhd`,
`${basePath}/child.vhd`,
`deleted.vhd`, // in metadata but not in vhds
`orphan.vhd`,
`child.vhd`,
// abandonned.vhd is not here anymore
],
}),
{ flags: 'w' }
)
loggued = ''
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: () => {}, lock: false })
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: () => {}, lock: false })
matched = loggued.match(/deleting unused VHD/g) || []
expect(matched.length).toEqual(2) // all vhds (orphan and child ) should have been deleted
assert.equal(matched.length, 2) // all vhds (orphan and child ) should have been deleted
})
test('it merges delta of non destroyed chain', async () => {
await handler.writeFile(
`metadata.json`,
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
size: 12000, // a size too small
vhds: [
`${basePath}/grandchild.vhd`, // grand child should not be merged
`${basePath}/child.vhd`,
`${relativePath}/grandchild.vhd`, // grand child should not be merged
`${relativePath}/child.vhd`,
// orphan is not here, he should be merged in child
],
})
@@ -219,33 +220,33 @@ test('it merges delta of non destroyed chain', async () => {
const logInfo = message => {
loggued.push(message)
}
await adapter.cleanVm('/', { remove: true, logInfo, logWarn: logInfo, lock: false })
expect(loggued[0]).toEqual(`incorrect backup size in metadata`)
await adapter.cleanVm(rootPath, { remove: true, logInfo, logWarn: logInfo, lock: false })
assert.equal(loggued[0], `incorrect backup size in metadata`)
loggued = []
await adapter.cleanVm('/', { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
await adapter.cleanVm(rootPath, { remove: true, merge: true, logInfo, logWarn: () => {}, lock: false })
const [merging] = loggued
expect(merging).toEqual(`merging VHD chain`)
assert.equal(merging, `merging VHD chain`)
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
// size should be the size of children + grand children after the merge
expect(metadata.size).toEqual(209920)
assert.equal(metadata.size, 209920)
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
const remainingVhds = await handler.list(basePath)
expect(remainingVhds.length).toEqual(2)
expect(remainingVhds.includes('child.vhd')).toEqual(true)
expect(remainingVhds.includes('grandchild.vhd')).toEqual(true)
assert.equal(remainingVhds.length, 2)
assert.equal(remainingVhds.includes('child.vhd'), true)
assert.equal(remainingVhds.includes('grandchild.vhd'), true)
})
test('it finish unterminated merge ', async () => {
await handler.writeFile(
`metadata.json`,
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
size: 209920,
vhds: [`${basePath}/orphan.vhd`, `${basePath}/child.vhd`],
vhds: [`${relativePath}/orphan.vhd`, `${relativePath}/child.vhd`],
})
)
@@ -271,13 +272,13 @@ test('it finish unterminated merge ', async () => {
})
)
await adapter.cleanVm('/', { remove: true, merge: true, logWarn: () => {}, lock: false })
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
// merging is already tested in vhd-lib, don't retest it here (and theses vhd are as empty as my stomach at 12h12)
// only check deletion
const remainingVhds = await handler.list(basePath)
expect(remainingVhds.length).toEqual(1)
expect(remainingVhds.includes('child.vhd')).toEqual(true)
assert.equal(remainingVhds.length, 1)
assert.equal(remainingVhds.includes('child.vhd'), true)
})
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
@@ -367,22 +368,22 @@ describe('tests multiple combination ', () => {
// the metadata file
await handler.writeFile(
`metadata.json`,
`${rootPath}/metadata.json`,
JSON.stringify({
mode: 'delta',
vhds: [
`${basePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
`${basePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
`${basePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
`${relativePath}/grandchild.vhd` + (useAlias ? '.alias.vhd' : ''), // grand child should not be merged
`${relativePath}/child.vhd` + (useAlias ? '.alias.vhd' : ''),
`${relativePath}/clean.vhd` + (useAlias ? '.alias.vhd' : ''),
],
})
)
await adapter.cleanVm('/', { remove: true, merge: true, logWarn: () => {}, lock: false })
await adapter.cleanVm(rootPath, { remove: true, merge: true, logWarn: () => {}, lock: false })
const metadata = JSON.parse(await handler.readFile(`metadata.json`))
const metadata = JSON.parse(await handler.readFile(`${rootPath}/metadata.json`))
// size should be the size of children + grand children + clean after the merge
expect(metadata.size).toEqual(vhdMode === 'file' ? 314880 : undefined)
assert.deepEqual(metadata.size, vhdMode === 'file' ? 314880 : undefined)
// broken vhd, non referenced, abandonned should be deleted ( alias and data)
// ancestor and child should be merged
@@ -392,19 +393,19 @@ describe('tests multiple combination ', () => {
if (useAlias) {
const dataSurvivors = await handler.list(basePath + '/data')
// the goal of the alias : do not move a full folder
expect(dataSurvivors).toContain('ancestor.vhd')
expect(dataSurvivors).toContain('grandchild.vhd')
expect(dataSurvivors).toContain('cleanAncestor.vhd')
expect(survivors).toContain('clean.vhd.alias.vhd')
expect(survivors).toContain('child.vhd.alias.vhd')
expect(survivors).toContain('grandchild.vhd.alias.vhd')
expect(survivors.length).toEqual(4) // the 3 ok + data
expect(dataSurvivors.length).toEqual(3) // the 3 ok + data
assert.equal(dataSurvivors.includes('ancestor.vhd'), true)
assert.equal(dataSurvivors.includes('grandchild.vhd'), true)
assert.equal(dataSurvivors.includes('cleanAncestor.vhd'), true)
assert.equal(survivors.includes('clean.vhd.alias.vhd'), true)
assert.equal(survivors.includes('child.vhd.alias.vhd'), true)
assert.equal(survivors.includes('grandchild.vhd.alias.vhd'), true)
assert.equal(survivors.length, 4) // the 3 ok + data
assert.equal(dataSurvivors.length, 3)
} else {
expect(survivors).toContain('clean.vhd')
expect(survivors).toContain('child.vhd')
expect(survivors).toContain('grandchild.vhd')
expect(survivors.length).toEqual(3)
assert.equal(survivors.includes('clean.vhd'), true)
assert.equal(survivors.includes('child.vhd'), true)
assert.equal(survivors.includes('grandchild.vhd'), true)
assert.equal(survivors.length, 3)
}
})
}
@@ -414,9 +415,9 @@ describe('tests multiple combination ', () => {
test('it cleans orphan merge states ', async () => {
await handler.writeFile(`${basePath}/.orphan.vhd.merge.json`, '')
await adapter.cleanVm('/', { remove: true, logWarn: () => {}, lock: false })
await adapter.cleanVm(rootPath, { remove: true, logWarn: () => {}, lock: false })
expect(await handler.list(basePath)).toEqual([])
assert.deepEqual(await handler.list(basePath), [])
})
test('check Aliases should work alone', async () => {
@@ -437,8 +438,8 @@ test('check Aliases should work alone', async () => {
// only ok have suvived
const alias = (await handler.list('vhds')).filter(f => f.endsWith('.vhd'))
expect(alias.length).toEqual(1)
assert.equal(alias.length, 1)
const data = await handler.list('vhds/data')
expect(data.length).toEqual(1)
assert.equal(data.length, 1)
})

View File

@@ -8,24 +8,26 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.27.4",
"version": "0.29.0",
"engines": {
"node": ">=14.6"
},
"scripts": {
"postversion": "npm publish --access public"
"postversion": "npm publish --access public",
"test": "node--test"
},
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/cached-dns.lookup": "^1.0.0",
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.1",
"@vates/fuse-vhd": "^0.0.1",
"@vates/disposable": "^0.1.2",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "*",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^3.1.0",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/fs": "^3.2.0",
"@xen-orchestra/log": "^0.4.0",
"@xen-orchestra/template": "^0.1.0",
"compare-versions": "^5.0.1",
"d3-time-format": "^3.0.0",
@@ -40,15 +42,17 @@
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"uuid": "^9.0.0",
"vhd-lib": "^4.0.1",
"vhd-lib": "^4.1.1",
"yazl": "^2.5.1"
},
"devDependencies": {
"rimraf": "^3.0.2",
"sinon": "^14.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^1.4.2"
"@xen-orchestra/xapi": "^1.5.2"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -19,8 +19,9 @@ 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 { warn } = createLogger('xo:backups:DeltaBackupWriter')
const { debug, warn } = createLogger('xo:backups:DeltaBackupWriter')
exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
async checkBaseVdis(baseUuidToSrcVdi) {
@@ -199,12 +200,30 @@ exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(Ab
await checkVhd(handler, parentPath)
}
const vdiRef = vm.$xapi.getObject(vdi.uuid).$ref
let nbdClient
if (!this._backup.config.useNbd) {
// 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)
nbdClient = new NbdClient(nbdInfo)
await nbdClient.connect()
debug(`got nbd connection `, { vdi: vdi.uuid })
} catch (error) {
nbdClient = undefined
debug(`can't connect to nbd server or no server available`, { error })
}
}
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

@@ -1,24 +1,20 @@
/* eslint-env jest */
'use strict'
const test = require('test')
const assert = require('assert').strict
const sinon = require('sinon')
const { createSchedule } = require('./')
jest.useFakeTimers()
const clock = sinon.useFakeTimers()
const wrap = value => () => value
describe('issues', () => {
test('issues', async t => {
let originalDateNow
beforeAll(() => {
originalDateNow = Date.now
})
afterAll(() => {
Date.now = originalDateNow
originalDateNow = undefined
})
originalDateNow = Date.now
test('stop during async execution', async () => {
await t.test('stop during async execution', async () => {
let nCalls = 0
let resolve, promise
@@ -35,20 +31,20 @@ describe('issues', () => {
job.start()
Date.now = wrap(+schedule.next(1)[0])
jest.runAllTimers()
clock.runAll()
expect(nCalls).toBe(1)
assert.strictEqual(nCalls, 1)
job.stop()
resolve()
await promise
jest.runAllTimers()
expect(nCalls).toBe(1)
clock.runAll()
assert.strictEqual(nCalls, 1)
})
test('stop then start during async job execution', async () => {
await t.test('stop then start during async job execution', async () => {
let nCalls = 0
let resolve, promise
@@ -65,9 +61,9 @@ describe('issues', () => {
job.start()
Date.now = wrap(+schedule.next(1)[0])
jest.runAllTimers()
clock.runAll()
expect(nCalls).toBe(1)
assert.strictEqual(nCalls, 1)
job.stop()
job.start()
@@ -76,7 +72,10 @@ describe('issues', () => {
await promise
Date.now = wrap(+schedule.next(1)[0])
jest.runAllTimers()
expect(nCalls).toBe(2)
clock.runAll()
assert.strictEqual(nCalls, 2)
})
Date.now = originalDateNow
originalDateNow = undefined
})

View File

@@ -1,7 +1,8 @@
/* eslint-env jest */
'use strict'
const { describe, it } = require('test')
const assert = require('assert').strict
const mapValues = require('lodash/mapValues')
const moment = require('moment-timezone')
@@ -25,24 +26,24 @@ describe('next()', () => {
},
([pattern, result], title) =>
it(title, () => {
expect(N(pattern)).toBe(result)
assert.strictEqual(N(pattern), result)
})
)
it('select first between month-day and week-day', () => {
expect(N('* * 10 * wen')).toBe('2018-04-10T00:00')
expect(N('* * 12 * wen')).toBe('2018-04-11T00:00')
assert.strictEqual(N('* * 10 * wen'), '2018-04-10T00:00')
assert.strictEqual(N('* * 12 * wen'), '2018-04-11T00:00')
})
it('select the last available day of a month', () => {
expect(N('* * 29 feb *')).toBe('2020-02-29T00:00')
assert.strictEqual(N('* * 29 feb *'), '2020-02-29T00:00')
})
it('fails when no solutions has been found', () => {
expect(() => N('0 0 30 feb *')).toThrow('no solutions found for this schedule')
assert.throws(() => N('0 0 30 feb *'), { message: 'no solutions found for this schedule' })
})
it('select the first sunday of the month', () => {
expect(N('* * * * 0', '2018-03-31T00:00')).toBe('2018-04-01T00:00')
assert.strictEqual(N('* * * * 0', '2018-03-31T00:00'), '2018-04-01T00:00')
})
})

View File

@@ -38,6 +38,11 @@
"moment-timezone": "^0.5.14"
},
"scripts": {
"postversion": "npm publish"
"postversion": "npm publish",
"test": "node--test"
},
"devDependencies": {
"sinon": "^14.0.1",
"test": "^3.2.1"
}
}

View File

@@ -1,49 +0,0 @@
/* eslint-env jest */
'use strict'
const parse = require('./parse')
describe('parse()', () => {
it('works', () => {
expect(parse('0 0-10 */10 jan,2,4-11/3 *')).toEqual({
minute: [0],
hour: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
dayOfMonth: [1, 11, 21, 31],
month: [0, 2, 4, 7, 10],
})
})
it('correctly parse months', () => {
expect(parse('* * * 0,11 *')).toEqual({
month: [0, 11],
})
expect(parse('* * * jan,dec *')).toEqual({
month: [0, 11],
})
})
it('correctly parse days', () => {
expect(parse('* * * * mon,sun')).toEqual({
dayOfWeek: [0, 1],
})
})
it('reports missing integer', () => {
expect(() => parse('*/a')).toThrow('minute: missing integer at character 2')
expect(() => parse('*')).toThrow('hour: missing integer at character 1')
})
it('reports invalid aliases', () => {
expect(() => parse('* * * jan-foo *')).toThrow('month: missing alias or integer at character 10')
})
it('dayOfWeek: 0 and 7 bind to sunday', () => {
expect(parse('* * * * 0')).toEqual({
dayOfWeek: [0],
})
expect(parse('* * * * 7')).toEqual({
dayOfWeek: [0],
})
})
})

View File

@@ -0,0 +1,50 @@
'use strict'
const { describe, it } = require('test')
const assert = require('assert').strict
const parse = require('./parse')
describe('parse()', () => {
it('works', () => {
assert.deepStrictEqual(parse('0 0-10 */10 jan,2,4-11/3 *'), {
minute: [0],
hour: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
dayOfMonth: [1, 11, 21, 31],
month: [0, 2, 4, 7, 10],
})
})
it('correctly parse months', () => {
assert.deepStrictEqual(parse('* * * 0,11 *'), {
month: [0, 11],
})
assert.deepStrictEqual(parse('* * * jan,dec *'), {
month: [0, 11],
})
})
it('correctly parse days', () => {
assert.deepStrictEqual(parse('* * * * mon,sun'), {
dayOfWeek: [0, 1],
})
})
it('reports missing integer', () => {
assert.throws(() => parse('*/a'), { message: 'minute: missing integer at character 2' })
assert.throws(() => parse('*'), { message: 'hour: missing integer at character 1' })
})
it('reports invalid aliases', () => {
assert.throws(() => parse('* * * jan-foo *'), { message: 'month: missing alias or integer at character 10' })
})
it('dayOfWeek: 0 and 7 bind to sunday', () => {
assert.deepStrictEqual(parse('* * * * 0'), {
dayOfWeek: [0],
})
assert.deepStrictEqual(parse('* * * * 7'), {
dayOfWeek: [0],
})
})
})

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "3.1.0",
"version": "3.2.0",
"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",
@@ -28,9 +28,9 @@
"@vates/async-each": "^1.0.0",
"@vates/coalesce-calls": "^0.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.0.0",
"@vates/read-chunk": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.3.0",
"@xen-orchestra/log": "^0.4.0",
"bind-property-descriptor": "^2.0.0",
"decorator-synchronized": "^0.6.0",
"execa": "^5.0.0",
@@ -40,10 +40,9 @@
"lodash": "^4.17.4",
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"pumpify": "^2.0.1",
"readable-stream": "^4.1.0",
"through2": "^4.0.2",
"xo-remote-parser": "^0.9.1"
"xo-remote-parser": "^0.9.2"
},
"devDependencies": {
"@babel/cli": "^7.0.0",
@@ -54,7 +53,8 @@
"babel-plugin-lodash": "^3.3.2",
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^3.0.0"
"rimraf": "^3.0.0",
"tmp": "^0.2.1"
},
"scripts": {
"build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/",

View File

@@ -1,8 +1,15 @@
const { pipeline } = require('node:stream')
const { readChunk } = require('@vates/read-chunk')
const crypto = require('crypto')
const pumpify = require('pumpify')
function getEncryptor(key) {
export const DEFAULT_ENCRYPTION_ALGORITHM = 'aes-256-gcm'
export const UNENCRYPTED_ALGORITHM = 'none'
export function isLegacyEncryptionAlgorithm(algorithm) {
return algorithm !== UNENCRYPTED_ALGORITHM && algorithm !== DEFAULT_ENCRYPTION_ALGORITHM
}
function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
if (key === undefined) {
return {
id: 'NULL_ENCRYPTOR',
@@ -15,43 +22,100 @@ function getEncryptor(key) {
decryptStream: stream => stream,
}
}
const algorithm = 'aes-256-cbc'
const ivLength = 16
const info = crypto.getCipherInfo(algorithm, { keyLength: key.length })
if (info === undefined) {
const error = new Error(
`Either the algorithm ${algorithm} is not available, or the key length ${
key.length
} is incorrect. Supported algorithm are ${crypto.getCiphers()}`
)
error.code = 'BAD_ALGORITHM'
throw error
}
const { ivLength, mode } = info
const authTagLength = ['gcm', 'ccm', 'ocb'].includes(mode) ? 16 : 0
function encryptStream(input) {
const iv = crypto.randomBytes(ivLength)
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
const encrypted = pumpify(input, cipher)
encrypted.unshift(iv)
return encrypted
return pipeline(
input,
async function* (source) {
const iv = crypto.randomBytes(ivLength)
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
yield iv
for await (const data of source) {
yield cipher.update(data)
}
yield cipher.final()
// must write the auth tag at the end of the encryption stream
if (authTagLength > 0) {
yield cipher.getAuthTag()
}
},
() => {}
)
}
async function decryptStream(encryptedStream) {
const iv = await readChunk(encryptedStream, ivLength)
const cipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv)
/**
* WARNING
*
* the crytped size has an initializtion vector + a padding at the end
* whe can't predict the decrypted size from the start of the encrypted size
* thus, we can't set decrypted.length reliably
*
*/
return pumpify(encryptedStream, cipher)
function decryptStream(encryptedStream) {
return pipeline(
encryptedStream,
async function* (source) {
/**
* WARNING
*
* the crypted size has an initializtion vector + eventually an auth tag + a padding at the end
* whe can't predict the decrypted size from the start of the encrypted size
* thus, we can't set decrypted.length reliably
*
*/
const iv = await readChunk(source, ivLength)
const cipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv)
let authTag = Buffer.alloc(0)
for await (const data of source) {
if (data.length >= authTagLength) {
// fast path, no buffer concat
yield cipher.update(authTag)
authTag = data.slice(data.length - authTagLength)
yield cipher.update(data.slice(0, data.length - authTagLength))
} else {
// slower since there is a concat
const fullData = Buffer.concat([authTag, data])
const fullDataLength = fullData.length
if (fullDataLength > authTagLength) {
authTag = fullData.slice(fullDataLength - authTagLength)
yield cipher.update(fullData.slice(0, fullDataLength - authTagLength))
} else {
authTag = fullData
}
}
}
if (authTagLength > 0) {
cipher.setAuthTag(authTag)
}
yield cipher.final()
},
() => {}
)
}
function encryptData(buffer) {
const iv = crypto.randomBytes(ivLength)
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), iv)
const encrypted = cipher.update(buffer)
return Buffer.concat([iv, encrypted, cipher.final()])
return Buffer.concat([iv, encrypted, cipher.final(), authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0)])
}
function decryptData(buffer) {
const iv = buffer.slice(0, ivLength)
const encrypted = buffer.slice(ivLength)
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv)
let encrypted
if (authTagLength > 0) {
const authTag = buffer.slice(buffer.length - authTagLength)
decipher.setAuthTag(authTag)
encrypted = buffer.slice(ivLength, buffer.length - authTagLength)
} else {
encrypted = buffer.slice(ivLength)
}
const decrypted = decipher.update(encrypted)
return Buffer.concat([decrypted, decipher.final()])
}

View File

@@ -0,0 +1,50 @@
/* eslint-env jest */
import { Readable } from 'node:stream'
import { _getEncryptor } from './_encryptor'
import crypto from 'crypto'
const algorithms = ['none', 'aes-256-cbc', 'aes-256-gcm']
function streamToBuffer(stream) {
return new Promise(resolve => {
const bufs = []
stream.on('data', function (d) {
bufs.push(d)
})
stream.on('end', function () {
resolve(Buffer.concat(bufs))
})
})
}
algorithms.forEach(algorithm => {
describe(`test algorithm ${algorithm}`, () => {
const key = algorithm === 'none' ? undefined : '73c1838d7d8a6088ca2317fb5f29cd91'
const encryptor = _getEncryptor(algorithm, key)
const buffer = crypto.randomBytes(1024 * 1024 + 1)
it('handle buffer', () => {
const encrypted = encryptor.encryptData(buffer)
if (algorithm !== 'none') {
expect(encrypted.equals(buffer)).toEqual(false) // encrypted should be different
// ivlength, auth tag, padding
expect(encrypted.length).not.toEqual(buffer.length)
}
const decrypted = encryptor.decryptData(encrypted)
expect(decrypted.equals(buffer)).toEqual(true)
})
it('handle stream', async () => {
const stream = Readable.from(buffer)
stream.length = buffer.length
const encrypted = encryptor.encryptStream(stream)
if (algorithm !== 'none') {
expect(encrypted.length).toEqual(undefined)
}
const decrypted = encryptor.decryptStream(encrypted)
const decryptedBuffer = await streamToBuffer(decrypted)
expect(decryptedBuffer.equals(buffer)).toEqual(true)
})
})
})

View File

@@ -12,7 +12,7 @@ import { synchronized } from 'decorator-synchronized'
import { basename, dirname, normalize as normalizePath } from './path'
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
import { _getEncryptor } from './_encryptor'
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
const { info, warn } = createLogger('@xen-orchestra:fs')
@@ -68,7 +68,15 @@ class PrefixWrapper {
}
export default class RemoteHandlerAbstract {
_encryptor
#encryptor
get _encryptor() {
if (this.#encryptor === undefined) {
throw new Error(`Can't access to encryptor before remote synchronization`)
}
return this.#encryptor
}
constructor(remote, options = {}) {
if (remote.url === 'test://') {
this._remote = remote
@@ -79,7 +87,6 @@ export default class RemoteHandlerAbstract {
}
}
;({ highWaterMark: this._highWaterMark, timeout: this._timeout = DEFAULT_TIMEOUT } = options)
this._encryptor = _getEncryptor(this._remote.encryptionKey)
const sharedLimit = limitConcurrency(options.maxParallelOperations ?? DEFAULT_MAX_PARALLEL_OPERATIONS)
this.closeFile = sharedLimit(this.closeFile)
@@ -330,44 +337,54 @@ export default class RemoteHandlerAbstract {
}
async _createMetadata() {
const encryptionAlgorithm = this._remote.encryptionKey === undefined ? 'none' : DEFAULT_ENCRYPTION_ALGORITHM
this.#encryptor = _getEncryptor(encryptionAlgorithm, this._remote.encryptionKey)
await Promise.all([
this._writeFile(
normalizePath(ENCRYPTION_DESC_FILENAME),
JSON.stringify({ algorithm: this._encryptor.algorithm }),
{
flags: 'w',
}
), // not encrypted
this._writeFile(normalizePath(ENCRYPTION_DESC_FILENAME), JSON.stringify({ algorithm: encryptionAlgorithm }), {
flags: 'w',
}), // not encrypted
this.writeFile(ENCRYPTION_METADATA_FILENAME, `{"random":"${randomUUID()}"}`, { flags: 'w' }), // encrypted
])
}
async _checkMetadata() {
let encryptionAlgorithm = 'none'
let data
try {
// this file is not encrypted
const data = await this._readFile(normalizePath(ENCRYPTION_DESC_FILENAME))
JSON.parse(data)
data = await this._readFile(normalizePath(ENCRYPTION_DESC_FILENAME), 'utf-8')
const json = JSON.parse(data)
encryptionAlgorithm = json.algorithm
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
encryptionAlgorithm = this._remote.encryptionKey === undefined ? 'none' : DEFAULT_ENCRYPTION_ALGORITHM
}
try {
this.#encryptor = _getEncryptor(encryptionAlgorithm, this._remote.encryptionKey)
// this file is encrypted
const data = await this.readFile(ENCRYPTION_METADATA_FILENAME)
const data = await this.readFile(ENCRYPTION_METADATA_FILENAME, 'utf-8')
JSON.parse(data)
} catch (error) {
if (error.code === 'ENOENT' || (await this._canWriteMetadata())) {
info('will update metadata of this remote')
return this._createMetadata()
// can be enoent, bad algorithm, or broeken json ( bad key or algorithm)
if (encryptionAlgorithm !== 'none') {
if (await this._canWriteMetadata()) {
// any other error , but on empty remote => update with remote settings
info('will update metadata of this remote')
return this._createMetadata()
} else {
warn(
`The encryptionKey settings of this remote does not match the key used to create it. You won't be able to read any data from this remote`,
{ error }
)
// will probably send a ERR_OSSL_EVP_BAD_DECRYPT if key is incorrect
throw error
}
}
warn(
`The encryptionKey settings of this remote does not match the key used to create it. You won't be able to read any data from this remote`,
{ error }
)
// will probably send a ERR_OSSL_EVP_BAD_DECRYPT if key is incorrect
throw error
}
}

View File

@@ -1,21 +1,29 @@
/* eslint-env jest */
import { TimeoutError } from 'promise-toolbox'
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
import { Disposable, pFromCallback, TimeoutError } from 'promise-toolbox'
import { getSyncedHandler } from '.'
import AbstractHandler from './abstract'
import fs from 'fs-extra'
import rimraf from 'rimraf'
import tmp from 'tmp'
const TIMEOUT = 10e3
class TestHandler extends AbstractHandler {
constructor(impl) {
super({ url: 'test://' }, { timeout: TIMEOUT })
Object.defineProperty(this, 'isEncrypted', {
get: () => false, // encryption is tested separatly
})
Object.keys(impl).forEach(method => {
this[`_${method}`] = impl[method]
})
}
}
const noop = Function.prototype
jest.useFakeTimers()
describe('closeFile()', () => {
@@ -101,3 +109,112 @@ describe('rmdir()', () => {
await expect(promise).rejects.toThrowError(TimeoutError)
})
})
describe('encryption', () => {
let dir
beforeEach(async () => {
dir = await pFromCallback(cb => tmp.dir(cb))
})
afterAll(async () => {
await pFromCallback(cb => rimraf(dir, cb))
})
it('sync should NOT create metadata if missing (not encrypted)', async () => {
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
expect(await fs.readdir(dir)).toEqual([])
})
it('sync should create metadata if missing (encrypted)', async () => {
await Disposable.use(
getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd00"` }),
noop
)
expect(await fs.readdir(dir)).toEqual(['encryption.json', 'metadata.json'])
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
// encrypted , should not be parsable
expect(async () => JSON.parse(await fs.readFile(`${dir}/metadata.json`))).rejects.toThrowError()
})
it('sync should not modify existing metadata', async () => {
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "none"}`)
await fs.writeFile(`${dir}/metadata.json`, `{"random": "NOTSORANDOM"}`)
await Disposable.use(await getSyncedHandler({ url: `file://${dir}` }), noop)
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual('none')
const metadata = JSON.parse(await fs.readFile(`${dir}/metadata.json`, 'utf-8'))
expect(metadata.random).toEqual('NOTSORANDOM')
})
it('should modify metadata if empty', async () => {
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
// nothing created without encryption
await Disposable.use(
getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd00"` }),
noop
)
let encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual('none')
})
it(
'sync should work with encrypted',
Disposable.wrap(async function* () {
const encryptor = _getEncryptor(DEFAULT_ENCRYPTION_ALGORITHM, '73c1838d7d8a6088ca2317fb5f29cd91')
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "${DEFAULT_ENCRYPTION_ALGORITHM}"}`)
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
const handler = yield getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` })
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
expect(encryption.algorithm).toEqual(DEFAULT_ENCRYPTION_ALGORITHM)
const metadata = JSON.parse(await handler.readFile(`./metadata.json`))
expect(metadata.random).toEqual('NOTSORANDOM')
})
)
it('sync should fail when changing key on non empty remote ', async () => {
const encryptor = _getEncryptor(DEFAULT_ENCRYPTION_ALGORITHM, '73c1838d7d8a6088ca2317fb5f29cd91')
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "${DEFAULT_ENCRYPTION_ALGORITHM}"}`)
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
// different key but empty remote => ok
await Disposable.use(
getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd00"` }),
noop
)
// remote is now non empty : can't modify key anymore
await fs.writeFile(`${dir}/nonempty.json`, 'content')
await expect(
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd10"` }), noop)
).rejects.toThrowError()
})
it('sync should fail when changing algorithm', async () => {
// encrypt with a non default algorithm
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gmc"}`)
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
// remote is now non empty : can't modify key anymore
await fs.writeFile(`${dir}/nonempty.json`, 'content')
await expect(
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` }), noop)
).rejects.toThrowError()
})
})

View File

@@ -5,6 +5,7 @@ import RemoteHandlerLocal from './local'
import RemoteHandlerNfs from './nfs'
import RemoteHandlerS3 from './s3'
import RemoteHandlerSmb from './smb'
export { DEFAULT_ENCRYPTION_ALGORITHM, UNENCRYPTED_ALGORITHM, isLegacyEncryptionAlgorithm } from './_encryptor'
const HANDLERS = {
file: RemoteHandlerLocal,

View File

@@ -0,0 +1 @@
VITE_XO_HOST=

View File

@@ -0,0 +1,28 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
globals: {
XO_LITE_GIT_HEAD: true,
XO_LITE_VERSION: true,
},
root: true,
env: {
node: true,
},
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier",
],
plugins: ["@limegrass/import-alias"],
rules: {
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@limegrass/import-alias/import-alias": [
"error",
{ aliasConfigPath: require("path").join(__dirname, "tsconfig.json") },
],
},
};

View File

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

View File

@@ -0,0 +1,15 @@
module.exports = {
importOrder: [
"^[^/]+$",
"<THIRD_PARTY_MODULES>",
"^@/components/(.*)$",
"^@/composables/(.*)$",
"^@/libs/(.*)$",
"^@/router/(.*)$",
"^@/stores/(.*)$",
"^@/views/(.*)$",
],
importOrderSeparation: false,
importOrderSortSpecifiers: true,
importOrderParserPlugins: ["typescript", "decorators-legacy"],
};

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -0,0 +1,11 @@
# ChangeLog
## **0.2.0**
- Invalidate sessionId token after logout (PR [#6480](https://github.com/vatesfr/xen-orchestra/pull/6480))
- Settings page (PR [#6418](https://github.com/vatesfr/xen-orchestra/pull/6418))
- Uncollapse hosts in the tree by default (PR [#6428](https://github.com/vatesfr/xen-orchestra/pull/6428))
## **0.1.0**
- Initial implementation

View File

@@ -0,0 +1,247 @@
# POC XO Lite
- Clone
- Copy `.env.dist` to `.env` and set vars
- `yarn`
- `yarn dev`
## Conventions
### File names
| Type | Format | Exemple |
| ---------- | ---------------------------------------- | ----------------------------------- |
| Component | `components/<PascalCase>.vue` | `components/FooBar.vue` |
| View | `views/<PascalCase>View.vue` | `views/FooBarView.vue` |
| Composable | `composables/<kebab-case>.composable.ts` | `composables/foo-bar.composable.ts` |
| Store | `stores/<kebab-case>.store.ts` | `stores/foo-bar.store.ts` |
| Other | `libs/<kebab-case>.ts` | `libs/foo-bar.ts` |
For components and views, prepend the subdirectories names to the resulting filename.
Example: `components/foo/bar/FooBarBaz.vue`
### Vue Components
Use Vue Single File Components (`*.vue`).
Insert blocks in the following order: `template`, `script` then `style`.
#### Template
Use HTML.
If your component only has one root element, add the component name as a class.
```vue
<!-- MyComponent.vue -->
<template>
<div class="my-component">...</div>
</template>
```
#### Script
Use composition API + TypeScript + `setup` attribute (`<script lang="ts" setup>`).
Note: When reading Vue official doc, don't forget to set "API Preference" toggle (in the upper left) on "Composition".
```vue
<script lang="ts" setup>
import { computed, ref } from "vue";
const props = defineProps<{
greetings: string;
}>();
const firstName = ref("");
const lastName = ref("");
const fullName = computed(
() => `${props.greetings} ${firstName.value} ${lastName.value}`
);
</script>
```
#### CSS
Always use `scoped` attribute (`<style scoped>`).
Nested rules are allowed.
Vue variables can be interpolated with `v-bind`.
```vue
<script lang="ts" setup>
import { ref } from "vue";
const fontSize = ref("2rem");
</script>
<style scoped>
.my-item {
.nested {
font-size: v-bind(fontSize);
}
}
</style>
```
### Icons
This project is using Font Awesome 6 Free.
Here is how to use an icon in your template.
Note: `FontAwesomeIcon` is a global component that does not need to be imported.
```vue
<template>
<div>
<FontAwesomeIcon :icon="faDisplay" />
</div>
</template>
<script lang="ts" setup>
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
</script>
```
#### Font weight <=> Style name
Here is the equivalent between font weight and style name.
| Style name | Font weight |
| ---------- | ----------- |
| Solid | 900 |
| Regular | 400 |
| Light | 300 |
| Thin | 100 |
### CSS
Always use `rem` unit (`1rem` = `10px`)
### Store
Use Pinia store with setup function.
State are `ref`
Getters are `computed`
Actions/Mutations are simple functions
#### Naming convention
For a `foobar` store, create a `store/foobar.store.ts` then use `defineStore('foobar', setupFunc)`
#### Example
```typescript
import { computed, ref } from "vue";
export const useFoobarStore = defineStore("foobar", () => {
const aStateVar = ref(0);
const otherStateVar = ref(0);
const aGetter = computed(() => aStateVar.value * 2);
const anAction = () => (otherStateVar.value += 10);
return {
aStateVar,
otherStateVar,
aGetter,
anAction,
};
});
```
#### Xen Api Collection Stores
When creating a store for a Xen Api objects collection, use the `createXenApiCollectionStoreContext` helper.
```typescript
export const useConsoleStore = defineStore("console", () =>
createXenApiCollectionStoreContext("console")
);
```
##### Extending the base context
Here is how to extend the base context:
```typescript
import { computed } from "vue";
export const useFoobarStore = defineStore("foobar", () => {
const baseContext = createXenApiCollectionStoreContext("foobar");
const myCustomGetter = computed(() => baseContext.ids.reverse());
return {
...baseContext,
myCustomGetter,
};
});
```
### I18n
Internationalization of the app is done with [Vue-i18n](https://vue-i18n.intlify.dev/).
Locale files are located in `src/locales` directory.
Source of truth is `en-US.json` file.
To quickly check if there are missing translations in other locale files, open `main.ts` and check the `messages`
property of `createI18n()` for TypeScript error.
#### Example
```json
{
"hello": "Hello",
"hello_name": "Hello {name}",
"hello_linked": "@:hello_name how are you?",
"hello_plural": "No hello | Hello to you | Hello to {count} persons"
}
```
```html
<!-- String -->
<p>{{ $t("hello") }}</p>
<!-- Hello -->
<p>{{ $t("hello_name", { name: "World" }) }}</p>
<!-- Hello World -->
<p>{{ $t("hello_linked", { name: "World" }) }}</p>
<!-- Hello World how are you? -->
<p>{{ $tc("hello_plural", 0) }}</p>
<!-- No hello -->
<p>{{ $tc("hello_plural", 1) }}</p>
<!-- Hello to you -->
<p>{{ $tc("hello_plural", 4) }}</p>
<!-- Hello to 4 persons -->
<!-- Date and time -->
<p>{{ $d(date, "date_short") }}</p>
<!-- 9/10/2022 -->
<p>{{ $d(date, "date_medium") }}</p>
<!-- Sep 10, 2022 -->
<p>{{ $d(date, "date_long") }}</p>
<!-- September 10, 2022 -->
<p>{{ $d(date, "datetime_short") }}</p>
<!-- 9/10/2022, 06:30 PM -->
<p>{{ $d(date, "datetime_medium") }}</p>
<!-- Sep 10, 2022, 06:30 PM -->
<p>{{ $d(date, "datetime_long") }}</p>
<!-- September 10, 2022 at 06:30 PM -->
<p>{{ $d(date, "time") }}</p>
<!-- 06:30 PM -->
<!-- Number -->
<p>{{ $n(1234567.898765) }}</p>
<!-- 1,234,567.899 -->
```

5
@xen-orchestra/lite/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="json-rpc-2.0/dist" />
declare const XO_LITE_VERSION: string;
declare const XO_LITE_GIT_HEAD: string;

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
{
"name": "@xen-orchestra/lite",
"version": "0.1.0",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
"preview": "vite preview --port 4173",
"build-only": "GIT_HEAD=$(git rev-parse HEAD) vite build",
"deploy": "./scripts/deploy.sh",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/vue-fontawesome": "^3.0.1",
"@novnc/novnc": "^1.3.0",
"@types/d3-time-format": "^4.0.0",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^8.7.5",
"complex-matcher": "^0.7.0",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"human-format": "^1.0.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",
"pinia": "^2.0.14",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.2.37",
"vue-echarts": "^6.2.3",
"vue-i18n": "9",
"vue-router": "^4.0.16"
},
"devDependencies": {
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@limegrass/eslint-plugin-import-alias": "^1.0.5",
"@rushstack/eslint-patch": "^1.1.0",
"@trivago/prettier-plugin-sort-imports": "^3.2.0",
"@types/node": "^16.11.41",
"@vitejs/plugin-vue": "^2.3.3",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/tsconfig": "^0.1.3",
"eslint-plugin-vue": "^9.0.0",
"npm-run-all": "^4.1.5",
"postcss-nested": "^5.0.6",
"typescript": "~4.7.4",
"vite": "^2.9.12",
"vue-tsc": "^0.38.1"
},
"private": true,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/lite",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@xen-orchestra/lite",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=8.10"
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,67 @@
#!/bin/bash
set -euo pipefail
if [ $# -ne 1 ]
then
echo "Usage: ./deploy.sh <LDAP username>"
exit 1
fi
USERNAME=$1
DIST="dist"
BASE="https://lite.xen-orchestra.com/dist"
SERVER="www-xo.gpn.vates.fr"
echo "Building XO Lite"
(cd ../.. && yarn)
yarn build-only --base="$BASE"
echo "Deploying XO Lite from $DIST"
echo "\"use strict\";
(function () {
const d = document;
function js(file) {
const s = d.createElement(\"script\");
s.defer = \"defer\";
s.type = \"module\";
s.crossOrigin = \"anonymous\";
s.src = file;
d.body.appendChild(s);
}
$(
for filename in "$DIST"/assets/*.js; do
echo " js(\"$BASE/assets/$(basename $filename)\");"
done
)
function css(file) {
const s = d.createElement(\"link\");
s.rel = \"stylesheet\";
s.href = file;
d.head.appendChild(s);
}
$(
for filename in "$DIST"/assets/*.css; do
echo " css(\"$BASE/assets/$(basename $filename)\");"
done
)
})();" > "$DIST/index.js"
rsync \
-r --delete --delete-excluded --exclude=index.html \
"$DIST"/ \
"$USERNAME@$SERVER:xo-lite"
echo "XO Lite files sent to server"
echo "→ Connect to the server using:"
echo -e "\tssh $USERNAME@$SERVER"
echo "→ Log in as xo-lite using"
echo -e "\tsudo -su xo-lite"
echo "→ Then run the following command to move the files to the \`latest\` folder:"
echo -e "\trsync -r --delete --exclude=index.html /home/$USERNAME/xo-lite/ /home/xo-lite/public/latest"

View File

@@ -0,0 +1,111 @@
<template>
<UiModal
v-if="isSslModalOpen"
color="error"
:icon="faServer"
@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" target="_blank" rel="noopener">{{ url.href }}</a>
</li>
</ul>
</UiModal>
<div v-if="!xenApiStore.isConnected">
<AppLogin />
</div>
<div v-else>
<AppHeader />
<div style="display: flex">
<nav class="nav">
<InfraPoolList />
</nav>
<main class="main">
<RouterView />
</main>
</div>
<AppTooltips />
</div>
</template>
<script lang="ts" setup>
import { difference } from "lodash";
import { computed, ref, watch, watchEffect } from "vue";
import favicon from "@/assets/favicon.svg";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import InfraPoolList from "@/components/infra/InfraPoolList.vue";
import UiModal from "@/components/ui/UiModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useHostStore } from "@/stores/host.store";
import { useXenApiStore } from "@/stores/xen-api.store";
const unreachableHostsUrls = ref<URL[]>([]);
const clearUnreachableHostsUrls = () => (unreachableHostsUrls.value = []);
let link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']");
if (link == null) {
link = document.createElement("link");
link.rel = "icon";
document.getElementsByTagName("head")[0].appendChild(link);
}
link.href = favicon;
document.title = "XO Lite";
if (window.localStorage?.getItem("colorMode") !== "light") {
document.documentElement.classList.add("dark");
}
const xenApiStore = useXenApiStore();
const hostStore = useHostStore();
useChartTheme();
watchEffect(() => {
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)
);
});
}
);
const isSslModalOpen = computed(() => unreachableHostsUrls.value.length > 0);
</script>
<style lang="postcss">
@import "@/assets/base.css";
.nav {
overflow: auto;
width: 37rem;
max-width: 37rem;
height: calc(100vh - 9rem);
padding: 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
}
.main {
overflow: auto;
flex: 1;
height: calc(100vh - 9rem);
background-color: var(--background-color-secondary);
}
</style>

View File

@@ -0,0 +1,27 @@
@import "reset.css";
@import "theme.css";
/* TODO Serve fonts locally */
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");
body {
min-height: 100vh;
font-size: 1.3rem;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--color-blue-scale-100);
}
a {
color: var(--color-extra-blue-base);
}
code {
font-family: monospace;
}
.card-view {
padding: 1.2rem;
display: flex;
gap: 2rem;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,28 @@
html {
box-sizing: border-box;
font-size: 10px;
}
*,
*::before,
*::after {
box-sizing: inherit;
margin: 0;
position: relative;
font-family: Poppins, sans-serif;
}
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
margin: 0;
padding: 0;
font-weight: normal;
}
ol, ul {
list-style: none;
}
img {
max-width: 100%;
height: auto;
}

View File

@@ -0,0 +1,75 @@
: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-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-d20: #438463;
--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-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;
--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);
}
: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-500: #000000;
--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;
--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);
}

View File

@@ -0,0 +1,101 @@
<template>
<AppMenu placement="bottom-end" shadow>
<template #trigger="{ open, isOpen }">
<button :class="{ active: isOpen }" class="account-button" @click="open">
<UiIcon :icon="faCircleUser" class="user-icon" />
<UiIcon :icon="faAngleDown" class="dropdown-icon" />
</button>
</template>
<MenuItem :icon="faGear" @click="openSettings">{{
$t("settings")
}}</MenuItem>
<MenuItem :icon="faMessage" @click="openFeedbackUrl">
{{ $t("send-us-feedback") }}
</MenuItem>
<MenuItem
:icon="faArrowRightFromBracket"
class="menu-item-logout"
@click="logout"
>
{{ $t("log-out") }}
</MenuItem>
</AppMenu>
</template>
<script lang="ts" setup>
import { nextTick } from "vue";
import { useRouter } from "vue-router";
import {
faAngleDown,
faArrowRightFromBracket,
faCircleUser,
faGear,
faMessage,
} from "@fortawesome/free-solid-svg-icons";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const router = useRouter();
const logout = () => {
const xenApiStore = useXenApiStore();
xenApiStore.disconnect();
nextTick(() => router.push({ name: "home" }));
};
const openFeedbackUrl = () => {
window.open(
"https://xcp-ng.org/forum/topic/4731/xen-orchestra-lite",
"_blank",
"noopener"
);
};
const openSettings = () => router.push({ name: "settings" });
</script>
<style scoped>
.account-button {
display: flex;
align-items: center;
padding: 1rem;
color: var(--color-blue-scale-100);
border: none;
border-radius: 0.8rem;
background-color: var(--background-color-secondary);
gap: 0.8rem;
&:disabled {
color: var(--color-blue-scale-400);
}
&:not(:disabled) {
cursor: pointer;
&:hover,
&:active,
&.active {
background-color: var(--background-color-primary);
}
&:active,
&.active {
color: var(--color-extra-blue-base);
}
}
}
.user-icon {
font-size: 2.4rem;
}
.dropdown-icon {
font-size: 1.6rem;
}
.menu-item-logout {
color: var(--color-red-vates-base);
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<header class="app-header">
<RouterLink :to="{ name: 'home' }">
<img alt="XO Lite" src="../assets/logo.svg" />
</RouterLink>
<slot />
<div class="right">
<AccountButton />
</div>
</header>
</template>
<script lang="ts" setup>
import AccountButton from '@/components/AccountButton.vue'
</script>
<style lang="postcss" scoped>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 8rem;
padding: 1rem;
border-bottom: 0.1rem solid var(--color-blue-scale-400);
background-color: var(--background-color-secondary);
img {
width: 4rem;
}
}
.right {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="app-login form-container">
<form @submit.prevent="handleSubmit">
<img alt="XO Lite" src="../assets/logo-title.svg" />
<input v-model="login" name="login" readonly type="text" />
<input
v-model="password"
:readonly="isConnecting"
name="password"
:placeholder="$t('password')"
type="password"
/>
<UiButton :busy="isConnecting" type="submit">
{{ $t("login") }}
</UiButton>
</form>
</div>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { onMounted, ref } from "vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const xenApiStore = useXenApiStore();
const { isConnecting } = storeToRefs(xenApiStore);
const login = ref("root");
const password = ref("");
onMounted(() => {
xenApiStore.reconnect();
});
async function handleSubmit() {
await xenApiStore.connect(login.value, password.value);
}
</script>
<style lang="postcss" scoped>
.form-container {
display: flex;
align-items: center;
flex: 1;
justify-content: center;
min-height: 100vh;
max-width: 100vw;
background-color: var(--background-color-primary);
}
form {
display: flex;
min-width: 30em;
max-width: 100%;
align-items: center;
flex-direction: column;
justify-content: center;
margin: 0 auto;
padding: 8.5rem;
background-color: var(--background-color-secondary);
}
h1 {
font-size: 4.8rem;
font-weight: 900;
line-height: 7.2rem;
margin-bottom: 4.2rem;
}
img {
width: 40rem;
margin-bottom: 5rem;
}
label {
font-size: 120%;
font-weight: bold;
margin: 1.5rem 0 0.5rem 0;
}
input {
width: 45rem;
max-width: 100%;
margin-bottom: 1rem;
padding: 1rem 1.5rem;
border: 1px solid var(--color-blue-scale-400);
border-radius: 0.8rem;
background-color: white;
}
button {
margin-top: 3rem;
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<div v-if="!isDisabled" ref="tooltipElement" class="app-tooltip">
<span class="triangle" />
<span class="label">{{ content }}</span>
</div>
</template>
<script lang="ts" setup>
import { isEmpty, isFunction, 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;
options: TooltipOptions;
}>();
const tooltipElement = ref<HTMLElement>();
const content = computed(() =>
isString(props.options) ? props.options : props.options.content
);
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"
);
watchEffect(() => {
if (tooltipElement.value) {
place(props.target, tooltipElement.value, {
placement: placement.value,
});
}
});
</script>
<style lang="postcss" scoped>
.app-tooltip {
font-size: 1.6rem;
font-weight: 400;
position: relative;
display: inline-flex;
padding: 0.3125em 0.5em;
pointer-events: none;
color: var(--color-blue-scale-500);
border-radius: 0.5em;
background-color: var(--color-blue-scale-100);
}
.triangle {
position: absolute;
overflow: hidden;
width: 1.875em;
height: 1.875em;
}
[data-placement^="top"] {
margin-bottom: 0.625em;
.triangle {
bottom: -1.75em;
transform: rotate(180deg);
}
}
[data-placement^="right"] {
margin-left: 0.625em;
.triangle {
left: -1.75em;
transform: rotate(270deg);
}
}
[data-placement^="bottom"] {
margin-top: 0.625em;
.triangle {
top: -1.75em;
}
}
[data-placement^="left"] {
margin-right: 0.625em;
.triangle {
right: -1.75em;
transform: rotate(90deg);
}
}
[data-placement="top-start"] .triangle {
left: 0;
}
[data-placement="top-center"] .triangle {
left: 50%;
margin-left: -0.9375em;
}
[data-placement="top-end"] .triangle {
right: 0;
}
[data-placement="left-start"] .triangle {
top: -0.25em;
}
[data-placement="left-center"] .triangle {
top: 50%;
margin-top: -0.9375em;
}
[data-placement="left-end"] .triangle {
bottom: -0.25em;
}
[data-placement="right-start"] .triangle {
top: -0.25em;
}
[data-placement="right-center"] .triangle {
top: 50%;
margin-top: -0.9375em;
}
[data-placement="right-end"] .triangle {
bottom: -0.25em;
}
[data-placement="bottom-center"] .triangle {
left: 50%;
margin-left: -0.9375em;
}
[data-placement="bottom-end"] .triangle {
right: 0;
}
.triangle::after {
display: block;
width: 100%;
height: 100%;
margin-top: 1.875em;
content: "";
transform: rotate(45deg) skew(20deg, 20deg);
border-radius: 0.3125em;
background-color: var(--color-blue-scale-100);
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<AppTooltip
v-for="tooltip in tooltips"
:key="tooltip.key"
:options="tooltip.options"
:target="tooltip.target"
/>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import AppTooltip from "@/components/AppTooltip.vue";
import { useTooltipStore } from "@/stores/tooltip.store";
const tooltipStore = useTooltipStore();
const { tooltips } = storeToRefs(tooltipStore);
</script>
<style scoped></style>

View File

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

View File

@@ -0,0 +1,256 @@
<template>
<div class="collection-filter-row">
<span class="or">{{ $t("or") }}</span>
<FormWidget v-if="newFilter.isAdvanced" class="form-widget-advanced">
<input v-model="newFilter.content" />
</FormWidget>
<template v-else>
<FormWidget :before="currentFilterIcon">
<select v-model="newFilter.builder.property">
<option v-if="!newFilter.builder.property" value="">
- {{ $t("property") }} -
</option>
<option
v-for="(filter, property) in availableFilters"
:key="property"
:value="property"
>
{{ filter.label ?? property }}
</option>
</select>
</FormWidget>
<template v-if="hasComparisonSelect">
<FormWidget v-if="currentFilter?.type === 'string'">
<select v-model="newFilter.builder.negate">
<option :value="false">does</option>
<option :value="true">does not</option>
</select>
</FormWidget>
<FormWidget v-if="hasComparisonSelect">
<select v-model="newFilter.builder.comparison">
<option
v-for="(label, type) in comparisons"
:key="type"
:value="type"
>
{{ label }}
</option>
</select>
</FormWidget>
</template>
<FormWidget
v-if="hasValueInput"
:after="valueInputAfter"
:before="valueInputBefore"
>
<input v-model="newFilter.builder.value" />
</FormWidget>
<template v-else-if="currentFilter?.type === 'enum'">
<FormWidget>
<select v-model="newFilter.builder.negate">
<option :value="false">is</option>
<option :value="true">is not</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newFilter.builder.value">
<option v-if="!newFilter.builder.value" value="" />
<option v-for="choice in enumChoices" :key="choice" :value="choice">
{{ choice }}
</option>
</select>
</FormWidget>
</template>
</template>
<UiActionButton
v-if="!newFilter.isAdvanced"
@click="enableAdvancedMode"
:icon="faPencil"
/>
<UiActionButton @click="emit('remove', newFilter.id)" :icon="faRemove" />
</div>
</template>
<script lang="ts" setup>
import { computed, watch } from "vue";
import type {
Filter,
FilterComparisonType,
FilterComparisons,
FilterType,
Filters,
NewFilter,
} from "@/types/filter";
import { faPencil, faRemove } from "@fortawesome/free-solid-svg-icons";
import { useVModel } from "@vueuse/core";
import FormWidget from "@/components/FormWidget.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import { buildComplexMatcherNode } from "@/libs/complex-matcher.utils";
import { getFilterIcon } from "@/libs/utils";
const props = defineProps<{
availableFilters: Filters;
modelValue: NewFilter;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: NewFilter): void;
(event: "remove", filterId: number): void;
}>();
const newFilter = useVModel(props, "modelValue", emit);
const getDefaultComparisonType = () => {
const defaultTypes: { [key in FilterType]: FilterComparisonType } = {
string: "stringContains",
boolean: "booleanTrue",
number: "numberEquals",
enum: "stringEquals",
};
return defaultTypes[
props.availableFilters[newFilter.value.builder.property].type
];
};
watch(
() => newFilter.value.builder.property,
() => {
newFilter.value.builder.comparison = getDefaultComparisonType();
newFilter.value.builder.value = "";
newFilter.value.builder.negate = false;
}
);
const currentFilter = computed<Filter>(
() => props.availableFilters[newFilter.value.builder.property]
);
const currentFilterIcon = computed(() => getFilterIcon(currentFilter.value));
const hasValueInput = computed(() =>
["string", "number"].includes(currentFilter.value?.type)
);
const hasComparisonSelect = computed(
() => newFilter.value.builder.property && currentFilter.value?.type !== "enum"
);
const enumChoices = computed(() => {
if (!newFilter.value.builder.property) {
return [];
}
const availableFilter =
props.availableFilters[newFilter.value.builder.property];
if (availableFilter.type !== "enum") {
return [];
}
return availableFilter.choices;
});
const generatedFilter = computed(() => {
if (newFilter.value.isAdvanced) {
return newFilter.value.content;
}
if (!newFilter.value.builder.comparison) {
return "";
}
try {
const node = buildComplexMatcherNode(
newFilter.value.builder.comparison,
newFilter.value.builder.property,
newFilter.value.builder.value,
newFilter.value.builder.negate
);
if (node) {
return node.toString();
}
return "";
} catch (e) {
return "";
}
});
const enableAdvancedMode = () => {
newFilter.value.content = generatedFilter.value;
newFilter.value.isAdvanced = true;
};
watch(generatedFilter, (value) => {
newFilter.value.content = value;
});
const comparisons = computed<FilterComparisons>(() => {
const comparisonsByType = {
string: {
stringContains: "contain",
stringEquals: "equal",
stringStartsWith: "start with",
stringEndsWith: "end with",
stringMatchesRegex: "match regex",
},
boolean: {
booleanTrue: "is true",
booleanFalse: "is false",
},
number: {
numberLessThan: "<",
numberLessThanOrEquals: "<=",
numberEquals: "=",
numberGreaterThanOrEquals: ">=",
numberGreaterThan: ">",
},
enum: {},
};
return comparisonsByType[currentFilter.value.type];
});
const valueInputBefore = computed(() => {
return newFilter.value.builder.comparison === "stringMatchesRegex"
? "/"
: undefined;
});
const valueInputAfter = computed(() => {
return newFilter.value.builder.comparison === "stringMatchesRegex"
? "/i"
: undefined;
});
</script>
<style lang="postcss" scoped>
.collection-filter-row {
display: flex;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--background-color-secondary);
gap: 1rem;
.or {
text-transform: uppercase;
}
&:only-child {
.or,
.remove {
display: none;
}
}
&:first-child .or {
visibility: hidden;
}
}
.form-widget-advanced {
flex: 1;
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<UiFilterGroup class="collection-sorter">
<UiFilter
v-for="[property, isAscending] in activeSorts"
:key="property"
@edit="emit('toggleSortDirection', property)"
@remove="emit('removeSort', property)"
>
<span class="property">
<UiIcon :icon="isAscending ? faCaretUp : faCaretDown" />
{{ property }}
</span>
</UiFilter>
<UiActionButton :icon="faPlus" class="add-sort" @click="open">
{{ $t("add-sort") }}
</UiActionButton>
</UiFilterGroup>
<UiModal v-if="isOpen" @submit.prevent="handleSubmit" :icon="faSort">
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">
<option v-if="!newSortProperty"></option>
<option
v-for="(sort, property) in availableSorts"
:key="property"
:value="property"
>
{{ sort.label ?? property }}
</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newSortIsAscending">
<option :value="true">{{ $t("ascending") }}</option>
<option :value="false">{{ $t("descending") }}</option>
</select>
</FormWidget>
</div>
<template #buttons>
<UiButton type="submit">{{ $t("add") }}</UiButton>
<UiButton outlined @click="handleCancel">
{{ $t("cancel") }}
</UiButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import type { ActiveSorts, Sorts } from "@/types/sort";
import {
faCaretDown,
faCaretUp,
faPlus,
faSort,
} from "@fortawesome/free-solid-svg-icons";
import FormWidget from "@/components/FormWidget.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
import UiModal from "@/components/ui/UiModal.vue";
import useModal from "@/composables/modal.composable";
defineProps<{
availableSorts: Sorts;
activeSorts: ActiveSorts;
}>();
const emit = defineEmits<{
(event: "toggleSortDirection", property: string): void;
(event: "addSort", property: string, isAscending: boolean): void;
(event: "removeSort", property: string): void;
}>();
const { isOpen, open, close } = useModal();
const newSortProperty = ref();
const newSortIsAscending = ref<boolean>(true);
const reset = () => {
newSortProperty.value = undefined;
newSortIsAscending.value = true;
};
const handleSubmit = () => {
emit("addSort", newSortProperty.value, newSortIsAscending.value);
reset();
close();
};
const handleCancel = () => {
reset();
close();
};
</script>
<style lang="postcss" scoped>
.form-widgets {
display: flex;
gap: 1rem;
}
.property {
display: inline-flex;
align-items: center;
gap: 0.7rem;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="filter-and-sort">
<CollectionFilter
v-if="availableFilters !== undefined"
:active-filters="filters"
:available-filters="availableFilters"
@add-filter="addFilter"
@remove-filter="removeFilter"
/>
<CollectionSorter
v-if="availableSorts !== undefined"
:active-sorts="sorts"
:available-sorts="availableSorts"
@add-sort="addSort"
@remove-sort="removeSort"
@toggle-sort-direction="toggleSortDirection"
/>
</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>
</template>
<script lang="ts" setup>
import { computed, toRef, watch } from "vue";
import type { Filters } from "@/types/filter";
import type { Sorts } from "@/types/sort";
import CollectionFilter from "@/components/CollectionFilter.vue";
import CollectionSorter from "@/components/CollectionSorter.vue";
import UiTable from "@/components/ui/UiTable.vue";
import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionSorter from "@/composables/collection-sorter.composable";
import useFilteredCollection from "@/composables/filtered-collection.composable";
import useMultiSelect from "@/composables/multi-select.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
const props = defineProps<{
modelValue?: string[];
availableFilters?: Filters;
availableSorts?: Sorts;
collection: Record<string, any>[];
idProperty: string;
}>();
const emit = defineEmits<{
(event: "update:modelValue", selectedRefs: string[]): void;
}>();
const isSelectable = computed(() => props.modelValue !== undefined);
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } =
useCollectionSorter();
const filteredCollection = useFilteredCollection(
toRef(props, "collection"),
predicate
);
const filteredAndSortedCollection = useSortedCollection(
filteredCollection,
compareFn
);
const usableRefs = computed(() =>
props.collection.map((item) => item[props.idProperty])
);
const selectableRefs = computed(() =>
filteredAndSortedCollection.value.map((item) => item[props.idProperty])
);
const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs);
watch(selected, (selected) => emit("update:modelValue", selected), {
immediate: true,
});
</script>
<style lang="postcss" scoped>
.filter-and-sort {
display: flex;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<th>
<div class="content">
<span class="label">
<UiIcon :icon="icon" />
<slot />
</span>
</div>
</th>
</template>
<script lang="ts" setup>
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import UiIcon from "@/components/ui/UiIcon.vue";
defineProps<{
icon?: IconDefinition;
}>();
</script>
<style lang="postcss" scoped>
.content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.label {
display: flex;
align-items: center;
gap: 1rem;
}
.filter-icon {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<label class="form-widget">
<span v-if="label || $slots.label" class="label">
<slot name="label">
{{ label }}
</slot>
</span>
<span class="widget">
<span v-if="before || $slots.before" class="before">
<slot name="before">
<UiIcon v-if="isIcon(before)" :icon="before" fixed-width />
<template v-else>{{ before }}</template>
</slot>
</span>
<span class="element">
<slot />
</span>
<span v-if="after || $slots.after" class="after">
<slot name="after">
<UiIcon v-if="isIcon(after)" :icon="after" fixed-width />
<template v-else>{{ after }}</template>
</slot>
</span>
</span>
</label>
</template>
<script lang="ts" setup>
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import UiIcon from "@/components/ui/UiIcon.vue";
defineProps<{
before?: IconDefinition | string | object; // "object" added as workaround
after?: IconDefinition | string | object; // See https://github.com/vuejs/core/issues/4294
label?: string;
inline?: boolean;
}>();
const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
typeof maybeIcon === "object";
</script>
<style lang="postcss" scoped>
.form-widget {
display: inline-flex;
align-items: stretch;
gap: 1rem;
font-size: 1.6rem;
height: 3.8rem;
}
.widget {
display: inline-flex;
flex: 1;
align-items: stretch;
overflow: hidden;
padding: 0 0.7rem;
border: 1px solid var(--color-blue-scale-400);
border-radius: 0.8rem;
background-color: var(--color-blue-scale-500);
box-shadow: var(--shadow-100);
gap: 0.1rem;
&:focus-within {
outline: 1px solid var(--color-extra-blue-l40);
}
}
.label {
display: flex;
align-items: center;
}
.form-widget:hover .widget {
border-color: var(--color-extra-blue-l60);
}
.element {
display: flex;
flex: 1;
align-items: center;
}
.before,
.after {
display: flex;
align-items: center;
padding: 0 0.3rem;
}
:slotted(input),
:slotted(select),
:slotted(textarea) {
font-size: inherit;
border: none;
outline: none;
color: var(--color-blue-scale-100);
background-color: var(--color-blue-scale-500);
flex: 1;
&:disabled {
opacity: 0.5;
}
}
:slotted(input[type="checkbox"]) {
font: inherit;
display: grid;
flex: 1.5rem 0 0;
width: 1.15em;
height: 1.15em;
margin: 0;
padding: 0;
transform: translateY(-0.075em);
color: currentColor;
border-radius: 0.15em;
background-color: #fff;
appearance: none;
place-content: center;
&::before {
width: 0.65em;
height: 0.65em;
content: "";
transition: 120ms transform ease-in-out;
transform: scale(0);
transform-origin: center;
box-shadow: inset 1em 1em blue;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
&:checked::before {
transform: scale(1.4);
}
&:disabled {
cursor: not-allowed;
color: var(--color-blue-scale-200);
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<UiIcon :class="className" :icon="icon" class="power-state-icon" />
</template>
<script lang="ts" setup>
import { computed } from "vue";
import {
faMoon,
faPause,
faPlay,
faQuestion,
faStop,
} from "@fortawesome/free-solid-svg-icons";
import UiIcon from "@/components/ui/UiIcon.vue";
import type { PowerState } from "@/libs/xen-api";
const props = defineProps<{
state: PowerState;
}>();
const icons = {
Running: faPlay,
Paused: faPause,
Suspended: faMoon,
Halted: faStop,
};
const icon = computed(() => icons[props.state] ?? faQuestion);
const className = computed(() => `state-${props.state.toLocaleLowerCase()}`);
</script>
<style lang="postcss" scoped>
.power-state-icon {
color: var(--color-extra-blue-d60);
&.state-running {
color: var(--color-green-infra-base);
}
&.state-paused {
color: var(--color-blue-scale-300);
}
&.state-suspended {
color: var(--color-extra-blue-d20);
}
}
</style>

View File

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

View File

@@ -0,0 +1,71 @@
<template>
<svg
class="progress-circle"
viewBox="0 0 36 36"
xmlns="http://www.w3.org/2000/svg"
>
<path
class="progress-circle-background"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
class="progress-circle-fill"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<text class="progress-circle-text" text-anchor="middle" x="50%" y="50%">
{{ progress }}%
</text>
</svg>
</template>
<script lang="ts" setup>
import { computed } from "vue";
interface Props {
value: number;
maxValue?: number;
}
const props = withDefaults(defineProps<Props>(), {
maxValue: 100,
});
const progress = computed(() => {
if (props.maxValue === 0) {
return 0;
}
return Math.round((props.value / props.maxValue) * 100);
});
</script>
<style lang="postcss" scoped>
.progress-circle-fill {
animation: progress 1s ease-out forwards;
fill: none;
stroke: var(--color-green-infra-base);
stroke-width: 1.2;
stroke-linecap: round;
stroke-dasharray: v-bind(progress), 100;
}
.progress-circle-background {
fill: none;
stroke-width: 1.2;
stroke: var(--color-blue-scale-400);
}
.progress-circle-text {
font-size: 0.7rem;
font-weight: bold;
fill: var(--color-green-infra-base);
text-anchor: middle;
alignment-baseline: middle;
}
@keyframes progress {
0% {
stroke-dasharray: 0, 100;
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div ref="vmConsoleContainer" class="vm-console" />
</template>
<script lang="ts" setup>
import { onBeforeUnmount, ref, watchEffect } from "vue";
import VncClient from "@novnc/novnc/core/rfb";
import { useXenApiStore } from "@/stores/xen-api.store";
const props = defineProps<{
location: string;
}>();
const vmConsoleContainer = ref<HTMLDivElement>();
const xenApiStore = useXenApiStore();
let vncClient: VncClient | undefined;
watchEffect(() => {
if (!vmConsoleContainer.value || !xenApiStore.currentSessionId) {
return;
}
if (vncClient !== undefined) {
vncClient.disconnect();
vncClient = undefined;
}
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;
});
onBeforeUnmount(() => {
vncClient?.disconnect();
vncClient = undefined;
});
</script>
<style lang="postcss" scoped>
.vm-console {
height: 80rem;
& > :deep(div) {
background-color: transparent !important;
}
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<div class="tab-bar">
<slot />
</div>
</template>
<style lang="postcss" scoped>
.tab-bar {
display: flex;
align-items: stretch;
height: 6.5rem;
background-color: var(--background-color-primary);
border-bottom: 1px solid var(--color-blue-scale-400);
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<span v-if="disabled" class="tab-bar-item disabled">
<slot />
</span>
<RouterLink v-else class="tab-bar-item" v-bind="$props">
<slot />
</RouterLink>
</template>
<script lang="ts" setup>
import type { RouterLinkProps } from "vue-router";
// https://vuejs.org/api/sfc-script-setup.html#type-only-props-emit-declarations
interface Props extends RouterLinkProps {
disabled?: boolean;
}
defineProps<Props>();
</script>
<style lang="postcss" scoped>
.tab-bar-item {
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 1.2em;
text-decoration: none;
text-transform: uppercase;
color: var(--color-blue-scale-100);
border-bottom: 2px solid transparent;
&:hover:not(.disabled) {
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-secondary);
}
&:active:not(.disabled) {
color: var(--color-extra-blue-base);
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-secondary);
}
&.router-link-active {
color: var(--color-extra-blue-base);
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-primary);
}
&.disabled {
color: var(--color-blue-scale-400);
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="title-bar">
<UiIcon :icon="icon" class="icon" />
<div class="title">
<slot />
</div>
<div class="actions">
<slot name="actions" />
</div>
</div>
</template>
<script lang="ts" setup>
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import UiIcon from "@/components/ui/UiIcon.vue";
defineProps<{
icon: IconDefinition;
}>();
</script>
<style lang="postcss" scoped>
.title-bar {
display: flex;
align-items: center;
height: 8rem;
padding: 0 2rem;
border-bottom: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
gap: 1.5rem;
}
.icon {
font-size: 3.8rem;
color: var(--color-extra-blue-base);
}
.title {
font-size: 3rem;
color: var(--color-blue-scale-100);
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div v-if="data.length !== 0">
<div class="header">
<slot name="header" />
</div>
<ProgressBar
v-for="item in computedData.sortedArray"
:key="item.id"
:value="item.value"
:label="item.label"
:badge-label="item.badgeLabel"
/>
<div class="footer">
<slot name="footer" :total-percent="computedData.totalPercentUsage" />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import ProgressBar from "@/components/ProgressBar.vue";
interface Data {
id: string;
value: number;
label?: string;
badgeLabel?: string;
maxValue?: number;
}
interface Props {
data: Array<Data>;
nItems?: number;
}
const props = defineProps<Props>();
const computedData = computed(() => {
const _data = props.data;
let totalPercentUsage = 0;
return {
sortedArray: _data
.map((item) => {
const value = Math.round((item.value / (item.maxValue ?? 100)) * 100);
totalPercentUsage += value;
return {
...item,
value,
};
})
.sort((item, nextItem) => nextItem.value - item.value)
.slice(0, props.nItems ?? _data.length),
totalPercentUsage,
};
});
</script>
<style scoped>
.header {
color: var(--color-extra-blue-base);
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--color-extra-blue-base);
margin-bottom: 2rem;
font-size: 16px;
font-weight: 700;
}
.footer {
display: flex;
justify-content: space-between;
font-weight: 700;
font-size: 14px;
color: var(--color-blue-scale-300);
}
</style>
<style>
.progress-bar-component:nth-of-type(2) .progress-bar-fill,
.progress-bar-component:nth-of-type(2) .circle {
background-color: var(--color-extra-blue-d60);
}
.progress-bar-component:nth-of-type(3) .progress-bar-fill,
.progress-bar-component:nth-of-type(3) .circle {
background-color: var(--color-extra-blue-d40);
}
.progress-bar-component:nth-of-type(4) .progress-bar-fill,
.progress-bar-component:nth-of-type(4) .circle {
background-color: var(--color-extra-blue-d20);
}
.progress-bar-component .progress-bar-fill,
.progress-bar-component .circle {
background-color: var(--color-extra-blue-l20);
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="chart-summary">
<div>
<div class="label">{{ $t("total-used") }}</div>
<div>
{{ usedPercent }}%
<br />
{{ valueFormatter(used) }}
</div>
</div>
<div>
<div class="label">{{ $t("total-free") }}</div>
<div>
{{ freePercent }}%
<br />
{{ valueFormatter(total - used) }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, inject } from "vue";
import { percent } from "@/libs/utils";
const props = defineProps<{
total: number;
used: number;
}>();
const usedPercent = computed(() => percent(props.used, props.total));
const freePercent = computed(() =>
percent(props.total - props.used, props.total)
);
const valueFormatter = inject("valueFormatter") as (value: number) => string;
</script>
<style lang="postcss" scoped>
.chart-summary {
font-size: 1.4rem;
font-weight: 700;
display: flex;
margin-top: 2rem;
color: var(--color-blue-scale-200);
gap: 4rem;
& > div {
display: flex;
flex: 1;
justify-content: space-between;
}
}
.label {
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,38 @@
# LinearChart component
```vue
<template>
<LinearChart
title="Chart title"
subtitle="Chart subtitle"
:data="data"
:value-formatter="customValueFormatter"
/>
</template>
<script lang="ts" setup>
import type { LinearChartData } from "@/types/chart";
import LinearChart from "@/components/charts/LinearChart.vue";
const data: LinearChartData = [
{
label: "First series",
data: [
{ date: "...", value: 1234 },
{ date: "...", value: 1234 },
],
},
{
label: "Second series",
data: [
{ date: "...", value: 1234 },
{ date: "...", value: 1234 },
],
},
];
const customValueFormatter = (value: number) => {
return `${value} (Doubled: ${value * 2})`;
};
</script>
```

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