Compare commits

...

133 Commits

Author SHA1 Message Date
Olivier Floch
2ac7c0ffb8 test(web): seed the V2V review step on top of the list pool seed
seedV2vReviewStepCollections adds the migrated VM's pool and SRs to what seedV2vListPool seeds instead of seeding the list pool and its default SRs again
2026-11-09 18:48:04 +01:00
Olivier Floch
68ef82a9f3 fix(web-core): add the zero form of the French V2V review step counts 2026-11-09 18:48:04 +01:00
Olivier Floch
565dbfa835 refactor(web): read the V2V connection from the wizard context in the review step 2026-11-09 18:48:04 +01:00
Olivier Floch
a8952875ac docs(changelog): add the V2V migration wizard review step 2026-11-09 18:48:03 +01:00
Olivier Floch
7b44a15a51 test(web): share the running job expectation
The 7 job tests checking the "in progress" error of a running job use expectJobRunning; the V2V idle job tests check canRun alone, as the other job tests (canRun is the absence of error).
2026-11-09 18:48:03 +01:00
Olivier Floch
22ed4ac652 test(web): share the V2V check literals
Every V2V selected VM and source VM check a test expects comes from V2V_SELECTED_VM_CHECK, V2V_VM_CHECK or createV2vUnsupportedCheck, built from literals so a broken getV2vUnsupportedCheck fails its tests.
2026-11-09 18:48:03 +01:00
Olivier Floch
ee90cea718 test(web): share the V2V wizard component mount 2026-11-09 18:48:03 +01:00
Olivier Floch
1f2469b711 refactor(web-core): share the input wrapper message merge 2026-11-09 18:47:59 +01:00
Olivier Floch
533f69244b test(web): cover the remaining V2V review step branches 2026-11-09 18:47:58 +01:00
Olivier Floch
fbcf5ad7f7 refactor(web): share the V2V pool name, import VM, row lookup and list step mount
- useV2vPoolName names a pool through getV2vTargetName
- useV2vImportVm names the pool of an import VM and reads its active mapping, used by useV2vResumedVm and the review step
- useV2vSelectedVms exposes getRow and getName, used by the review step
- mountV2vListStep replaces mountV2vMappingStep and mountV2vReviewStep
2026-11-09 18:47:47 +01:00
Olivier Floch
104cdfbcb1 feat(web): add the review step of the V2V migration wizard 2026-11-09 18:47:47 +01:00
Olivier Floch
574e427b0a feat(web-core): add VtsSuccessStateHero component 2026-11-09 18:47:47 +01:00
Olivier Floch
48b445211b refactor(lite): use VtsNumberInput in the network forms
The network MTU and VLAN messages now wrap, as in the XO 6 forms.
2026-11-09 18:47:47 +01:00
Olivier Floch
a54e37c808 feat(web): add the V2V launch options cards 2026-11-09 18:47:47 +01:00
Olivier Floch
48ad080ada feat(web): add an info line to FormCheckbox
The VDI attach form moves from its own VdiFormCheckbox to FormCheckbox.
2026-11-09 18:47:46 +01:00
Olivier Floch
210e4f124e refactor(web): use VtsNumberInput in the network and traffic rule forms
The network MTU and VLAN messages now wrap, as in every other XO 6 form input.
2026-11-09 18:47:46 +01:00
Olivier Floch
e7da1be6ba feat(web-core): add VtsNumberInput component 2026-11-09 18:47:46 +01:00
Olivier Floch
d10aa60358 feat(web): add the V2V start job
- useXoV2vStartJob posts the launch to the pool import route and returns
  its task id, without monitoring it: an import lasts hours
- useV2vLaunchForm: stop the source VMs and stop on error by default, two
  VMs at a time, concurrency an integer from 1 to 8 as the back end accepts
- Dev mock: the import route answers a task id, or the problems the back
  end would refuse the launch for
- New test helper: createV2vStartPayloadInput, the input of buildV2vStartPayload
2026-11-09 18:47:46 +01:00
Olivier Floch
374d2c9d39 feat(web): add the V2V launch plan, options and start payload utils
- getV2vLaunchPlan groups the list VMs as the selected VMs panel checks them
  (excluded, already migrated, resumed, new) and keeps the launchable ones,
  an already migrated VM only once confirmed
- getV2vVmStopSource / setV2vVmStopSource: the stop source option of a VM,
  from the launch settings or its override; resolveV2vValue now gives the
  value and origin of any V2V override (resolveV2vTarget builds on it)
- buildV2vStartPayload sends only what the back end accepts (no start, no
  cleanup, no tags, MACs kept), no mapping for a resumed VM;
  getV2vStartProblems reads the problems of a refused launch
- getV2vCutoverAt: the date of the complete import snapshot
- getUnprocessableEntityData shared by the V2V connection and start errors
- New test helpers: createApiError, V2V_SELECTED_VM_CHECK, the V2V launch
  factories and createV2vCompleteImportSnapshot
2026-11-09 18:47:46 +01:00
Olivier Floch
b82d641783 test(web): provide the V2V mapping connection beside the other connection provides
The resumed VM and VM actions tests read it without mounting a mapping. The actions test only needs a
connection to open the drawer under: it gets a plain one.
2026-11-09 18:39:29 +01:00
Olivier Floch
3a41fba148 fix(web): open the V2V VM settings drawer outside the wizard context
- The drawer is an overlay rendered at the app root, out of the migration page: reading the V2V connection there
  threw, so Edit settings opened nothing
- useV2vVmTargets: the targets and options the drawer edits, read from an inventory handed in; useV2vVmMapping adds
  the pool name, host and checks of the side panel on top
- useV2vVmSettingsEdit hands the drawer the inventory of the connected source
- Tests: the drawer is mounted without the wizard connection; v2vMappingConnectionProvide shared by the mapping tests
2026-11-09 18:39:29 +01:00
Olivier Floch
1667ed2a16 test(web): seed every V2V list pool and list VMs through the shared helpers
- seedV2vListPool takes partial offers and the other pool, and sets the collections itself: seedChoices is gone
- the wizard, pool name and source-and-destination tests seed the list pool through it
- createV2vMigrationListVmsOf builds the list VMs of given ids, replacing the hand-written ones; V2vMappingStep reads the stored VM through readV2vStoredListVm
2026-11-09 12:43:34 +01:00
Olivier Floch
55cf10d8bf test(web): seed the pool of the V2V list through one helper
- `seedV2vListPool` seeds the pool of the list and its offers, in place of
  the same two lines in the VMs step, selected VMs and mapping step tests
- the resumed VM is imported to the pool of the list: `V2V_RESUMED_VM_POOL`
  is gone
- the network and template of the other pool get their own ids, apart from
  the list defaults
2026-11-09 12:22:53 +01:00
Olivier Floch
7ec1e941a5 test(web): use the shared list pool in the V2V pool name test 2026-11-09 11:57:47 +01:00
Olivier Floch
f8dc85855b test(web): use the shared list pool in the V2V VM mapping mount 2026-11-09 11:56:46 +01:00
Olivier Floch
8ae1f4e811 test(web): use the shared id of another pool in the V2V VM mapping mount 2026-11-09 11:02:57 +01:00
Olivier Floch
1c1960a09c fix(web-core): add the zero form of the French V2V snapshot check 2026-11-09 11:02:57 +01:00
Olivier Floch
892fff90ab refactor(web): read the V2V connection from the wizard context in the mapping step 2026-11-09 11:02:57 +01:00
Olivier Floch
d4c8b7aef4 test(web): share the useOverlay mock
- mock-overlay.ts records each useOverlay call, in order, as its options and the open spy it returned
- used by the backup repository, plugin drawer job and V2V settings tests, in place of 7 hand-written mocks
2026-11-09 11:02:57 +01:00
Olivier Floch
11b3302b62 test(web): share the V2V mapping test fixtures
- createV2vOfferedTarget: a resolved target among the pool offers
- V2V_LIST_VM_OVERRIDES, V2V_FULLY_OVERRIDDEN_LIST_VM, V2V_OTHER_LIST_VM: the list VMs the mapping tests start from
- V2V_NEW_STEP_VM, V2V_RESUMED_STEP_VM, V2V_NOT_FOUND_STEP_VM: one mapping step VM of each kind
- describeV2vMappingCardLayout, countDividers: the layout the side panel mapping cards share
- mountV2vVmKindActions, findV2vRouteQuery
- the drawer cancel cases, the table menu order and the panel kind switch no longer re-test what another test covers
2026-11-09 11:02:57 +01:00
Olivier Floch
587d273e67 fix(web): type the pool connection success server id as FrontXoServer
XoServer['id'] is branded: Vue resolves it to a runtime Object check and warns on the string id.
2026-11-09 11:02:56 +01:00
Olivier Floch
3dd6126425 fix(web): give the V2V VM heading card id a string prop type
XoV2vVm['id'] is branded: Vue resolves it to a runtime Object check and warns on every panel render.
2026-11-09 11:02:56 +01:00
Olivier Floch
92496e7a19 refactor(web-core): remove the unused selected state of UiPanelCard
The V2V mapping step no longer selects a panel card since it shows its VMs as a table.
2026-11-09 11:02:56 +01:00
Olivier Floch
d6e7324892 feat(web): show the V2V mapping step as a table 2026-11-09 11:02:56 +01:00
Olivier Floch
13986f2828 feat(web): add the V2V VM side panel 2026-11-09 11:02:41 +01:00
Olivier Floch
f7cad58fa3 feat(web): add the V2V VM settings drawer
Edit settings opens a drawer holding the per-VM settings of a new VM on a
draft: SR per disk, network per NIC, keep MAC, template and tags. Save
replaces the stored overrides of the VM once, Cancel writes nothing.
The options card becomes V2vVmOptionsFields, so the drawer reuses them.
2026-11-09 11:02:41 +01:00
Olivier Floch
d1e023bdd4 refactor(web): add an edit draft to the V2V VM mapping
The VM mapping reads its overrides from an injectable source, so that
useV2vVmMappingDraft edits a copy of them and hands back the changed
overrides, without writing the stored list.
2026-11-09 11:02:41 +01:00
Olivier Floch
710cc1915e docs(changelog): add the V2V migration wizard mapping step 2026-11-09 11:02:41 +01:00
Olivier Floch
873644d9e0 fix(web): let the V2V source step cards shrink on a small screen 2026-11-09 11:02:41 +01:00
Olivier Floch
35082b5d9e feat(web): add the mapping step of the V2V migration wizard 2026-11-09 11:02:41 +01:00
Olivier Floch
a8ab6a1f99 feat(web): add the V2V resumed VM mapping 2026-11-09 10:59:59 +01:00
Olivier Floch
fb1da9305e refactor(web): extract the VM delete flow into useVmDelete 2026-11-09 10:59:55 +01:00
Olivier Floch
04d86aca15 test(web): share the useXoRoutes mock 2026-11-09 10:59:49 +01:00
Olivier Floch
73207050a0 feat(web): add the V2V new VM mapping 2026-11-09 10:59:49 +01:00
Olivier Floch
a61182a466 feat(web): store the per-VM overrides of the V2V migration list 2026-11-09 10:59:36 +01:00
Olivier Floch
6866393ba8 feat(web): add V2V mapping, checks and active mapping utils
- per-VM overrides in the migration list (disks, NICs, template, keep MAC,
  tags), parsed with their shape
- one mapping resolver: each target from its override or the list default,
  offered or not, and the mapping complete or incomplete
- `mapping-incomplete` check of a new VM, after `partial-on-other-pool`
- mapping summary from the resolved mapping, with distinct target names
- source VM checks (unsupported disks, snapshots), next transfer (delta or
  full), datacenter of the VM
- active mapping of a resumed VM: disk to VDI by its `esxi_diskPath` in the
  disk chain, NIC to VIF by device, MAC kept or not
2026-11-09 10:59:16 +01:00
Olivier Floch
d1358444e7 feat(web-core): add a selected state to UiPanelCard 2026-11-09 10:57:55 +01:00
Olivier Floch
75e990343a feat(web-core): add VtsTagsInput component 2026-11-09 10:57:55 +01:00
Olivier Floch
8d25cb6b94 feat(types): add the vmdk chain paths of a V2V source disk 2026-11-09 10:57:55 +01:00
Olivier Floch
c099fabb2b test(web): read the first two V2V list VMs from the stored list fixture 2026-11-09 10:51:50 +01:00
Olivier Floch
b20630c512 test(web): read the V2V listed VM, empty and stored lists from the shared fixtures 2026-11-09 10:27:15 +01:00
Olivier Floch
e63e6a3810 test(web): read the V2V other pool and empty list from the shared fixtures 2026-11-09 10:15:03 +01:00
Olivier Floch
8d69b4095e test(web): read the V2V list pool and pair from the shared fixtures 2026-11-09 09:57:02 +01:00
Olivier Floch
43eefcf01f fix(web): drop the unstyled root class of the inline parts 2026-11-09 09:57:02 +01:00
Olivier Floch
67ea6b5a53 test(web): use the shared id of another pool in the V2V VMs step tests 2026-11-09 09:57:02 +01:00
Olivier Floch
c1c2673441 refactor(web): name the V2V selected VMs test pool id after the id it holds 2026-11-09 09:57:02 +01:00
Olivier Floch
23ffab2373 refactor(web): name the V2V list summary after its source and pool pair, not a lock 2026-11-09 09:57:02 +01:00
Olivier Floch
2a4c19d281 fix(web-core): drop the V2V empty list translation no screen uses any more 2026-11-09 09:57:02 +01:00
Olivier Floch
ba7059af89 docs(web): drop the removed mountComposable render option from the testing guide 2026-11-09 09:57:02 +01:00
Olivier Floch
a51c206614 fix(web): say that another V2V source or pool opens its own list
The selected VMs panel claimed the source and destination were locked
until the list was cleared, while each (source, pool) pair has its own
list and choosing another one just opens it.
2026-11-09 09:57:02 +01:00
Olivier Floch
3532de374d test(web): keep the fetch stub of the test setup under its comment 2026-11-09 09:57:02 +01:00
Olivier Floch
f27fdb5c96 style(web): drop the V2V comments that only say what the code does 2026-11-09 09:57:02 +01:00
Olivier Floch
79d303db55 fix(web): open and close the V2V selected VMs panel like the other side panels
Its VMs act as the selection of the side panel: it opens with them and
closes when the list is emptied, without forcing it closed on small screens.
2026-11-09 09:57:02 +01:00
Olivier Floch
e70963dc74 fix(web-core): show the persistent side panel close button wherever closing collapses it
On a large screen with the panel unpinned, a persistent panel could not be
closed: closing collapses it there as on small screens.
2026-11-09 09:57:02 +01:00
Olivier Floch
a654570dd1 test(web): share the V2V datastore filters and the VMs step query 2026-11-09 09:57:02 +01:00
Olivier Floch
38522688ca fix(web): name the root of the V2V selected VMs panel and item after the component 2026-11-09 09:57:02 +01:00
Olivier Floch
b5a6a0f2b2 fix(web-core): add the zero form of the French V2V VM step counts 2026-11-09 09:57:02 +01:00
Olivier Floch
9f10b3f942 refactor(web): read the V2V connection from the wizard context in the VMs step 2026-11-09 09:57:02 +01:00
Olivier Floch
8c14ebbb0c fix(web): leave a V2V VM not found in the source without a kind tag
A VM gone from the source is neither new nor resumed: its check alone tells it.
2026-11-09 09:57:02 +01:00
Olivier Floch
6838e0b536 test(web): read the plugin header tags with the shared findAccentedTags 2026-11-09 09:57:02 +01:00
Olivier Floch
4d671f71cd test(web): clear the local storage before every test
happy-dom keeps the local storage across the tests of a file: the global
test setup now clears it, instead of each V2V test file doing it again.
2026-11-09 09:57:02 +01:00
Olivier Floch
dc43545389 feat(web): add the VMs step of the V2V migration wizard 2026-11-09 09:57:02 +01:00
Olivier Floch
30a21d98a4 feat(web-core): add a persistent side panel, closable on small screens only
A panel whose content is not a selection has nothing to clear: its close
button only shows on small screens, where it leaves the full-screen panel.
2026-11-09 09:57:02 +01:00
Olivier Floch
bacd60115c feat(web): add the V2V selected VMs panel 2026-11-09 09:57:02 +01:00
Olivier Floch
a27f0cb5ed feat(web): add the V2V VM selection table 2026-11-09 09:57:02 +01:00
Olivier Floch
60272ed6cf feat(web): add the V2V VM query builder, presets and datastore filter 2026-11-09 09:57:02 +01:00
Olivier Floch
745b6a7012 feat(web): add, remove and clear VMs of the V2V migration list 2026-11-09 09:56:56 +01:00
Olivier Floch
8583fcdd32 feat(web): add V2V VM status, checks and rows utils 2026-11-09 09:56:56 +01:00
Olivier Floch
2a4e45f4dc feat(web-core): add a disabled state to the table checkbox column 2026-11-09 09:19:28 +01:00
Olivier Floch
0d7fba2b6a refactor(web): require the VMs of a built V2V migration list 2026-11-09 09:14:10 +01:00
Olivier Floch
9efc4df5e6 refactor(web): share the writable SR of a pool check between the V2V destination, VDI and VM migrate forms 2026-11-09 09:13:22 +01:00
Olivier Floch
cdda6f6e2a test(web): share the id of another pool between the tests 2026-11-09 09:12:36 +01:00
Olivier Floch
d85415f28e test(web): cover the V2V pool option slot of the destination pool card and name the id-only test params after the id 2026-11-09 08:56:27 +01:00
Olivier Floch
890a8aedae test(web): share the V2V pool option mount and the stored list with VMs of the destination step test 2026-11-09 08:44:27 +01:00
Olivier Floch
422512514a test(web): share the V2V destination pool seeding and cover the default SR of another pool 2026-11-06 17:27:01 +01:00
Olivier Floch
ee217dbf45 fix(web-core): describe the V2V destination pool card as one list per source and pool 2026-11-06 17:09:42 +01:00
Olivier Floch
f6f5f05696 refactor(web): name the V2V destination pool id variables after the id they hold 2026-11-06 17:08:53 +01:00
Olivier Floch
1acd6533fc refactor(web): read the V2V destination pool with useGetPoolById 2026-11-06 16:58:56 +01:00
Olivier Floch
12cf925a89 test(web): share the collections mock, the select selection reader and the V2V destination choice helpers 2026-11-06 16:58:03 +01:00
Olivier Floch
d550e4356b test(web): cover the host and unknown locations of getSrLocation 2026-11-06 16:41:53 +01:00
Olivier Floch
03fc6b5b1a refactor(web-core): share the • separator of inline parts as the interpunct-list class 2026-11-06 16:17:25 +01:00
Olivier Floch
5d79b4486e test(web): mount the FormSelect test with mountWithSelect 2026-11-06 16:07:49 +01:00
Olivier Floch
2ea5887ad1 test(web): share the V2V destination form select mount and test getSrSelectLabel where it lives 2026-11-06 16:06:58 +01:00
Olivier Floch
9fc2dc2b7c refactor(web): count the V2V destination pool resources with lodash countBy 2026-11-06 16:02:05 +01:00
Olivier Floch
c89eab12bf refactor(web): reuse getSrSelectLabel for the V2V destination SR options 2026-11-06 15:15:17 +01:00
Olivier Floch
d63f6a794c fix(web-core): sort the V2V destination translation keys and drop an unused one
Sort destination-pool alphabetically and drop the unused source-backup-repository key.
2026-11-06 15:15:17 +01:00
Olivier Floch
2739715642 refactor(web): read the V2V connection from the wizard context in the destination step 2026-11-06 15:15:17 +01:00
Olivier Floch
26eadbd6a4 fix(web): prefill the V2V destination defaults once their options are loaded
A select only shows a value it already offers: defaults set before the networks, PIFs or templates of the pool
were loaded (e.g. reopening a stored list after a reload) read empty although the form held them.
2026-11-06 15:15:17 +01:00
Olivier Floch
79fff5af37 feat(web): add the destination step of the V2V migration wizard 2026-11-06 15:15:17 +01:00
Olivier Floch
8be55f783b feat(web): wire the V2V destination step to the route and the stored migration list 2026-11-06 15:15:16 +01:00
Olivier Floch
2655842321 feat(web): add the V2V destination form 2026-11-06 15:15:16 +01:00
Olivier Floch
f593012837 feat(web): keep the V2V migration list of each source and pool in the browser 2026-11-06 15:15:16 +01:00
Olivier Floch
a2f590be31 feat(web): add V2V migration list and destination utils 2026-11-06 15:15:16 +01:00
Olivier Floch
bcecc5ded0 refactor(web): test when the V2V prerequisite lets a source connect 2026-11-06 15:02:05 +01:00
Olivier Floch
7fe262fbbe refactor(web): replace the V2V source kind with a vCenter check 2026-11-06 15:00:19 +01:00
Olivier Floch
dafc829cee test(web): share the V2V invalid credentials response stub 2026-11-06 14:58:22 +01:00
Olivier Floch
8ea46eb530 test(web): seed the remembered V2V connections when mounting the source step 2026-11-06 14:56:38 +01:00
Olivier Floch
9e59db67ee test(web): reset the V2V source step stubs between tests 2026-11-06 14:54:48 +01:00
Olivier Floch
73694a0da6 test(web): share the cold and warm V2V import fixture 2026-11-06 14:52:45 +01:00
Olivier Floch
6c201f2117 docs(web): list the V2V connection reader test helper 2026-11-06 12:33:34 +01:00
Olivier Floch
3be9f3e94a test(web): check the V2V remembered connections card slot without a test-only class 2026-11-06 12:26:34 +01:00
Olivier Floch
b1741f44f5 test(web): test that the V2V migration page provides the wizard connection 2026-11-06 12:14:47 +01:00
Olivier Floch
ffd581def2 test(web): test forgetting the V2V password with and without a stored one in one case 2026-11-06 12:13:58 +01:00
Olivier Floch
12cc94e98b docs(web): never add a class only for a test to find an element 2026-11-06 12:11:49 +01:00
Olivier Floch
d80a5f52f0 chore(web): remove the unused class of the V2V remember warning 2026-11-06 12:11:02 +01:00
Olivier Floch
da9044c98e fix(web): show the V2V wizard summary as the head bar status 2026-11-06 12:10:16 +01:00
Olivier Floch
ba01b16d1a fix(web): forget the remembered V2V password when remember is unticked
Connecting with an already remembered connection, edited or not, kept its stored password even with remember unticked.
2026-11-06 12:08:44 +01:00
Olivier Floch
3687817765 docs(changelog): release @xen-orchestra/web with the V2V migration wizard 2026-11-06 12:08:44 +01:00
Olivier Floch
8bd11632f0 test(web): test the new V2V connection per wizard on provideV2vConnection
The page test unmounted by hand and asserted the context lifecycle; it now only checks that the wizard renders.
2026-11-06 12:08:44 +01:00
Olivier Floch
b2fd9180b1 refactor(web): prefix the V2V migration wizard with V2v 2026-11-06 12:08:44 +01:00
Olivier Floch
8c00429922 refactor(web): focus the V2V password from the source step, not its composable 2026-11-06 11:03:01 +01:00
Olivier Floch
3a1191dfe1 chore(web): remove the vCenter or ESXi description of the V2V source form 2026-11-06 10:45:38 +01:00
Olivier Floch
9336c1c8ac chore(web): limit the V2V mock inventory to what the source step reads 2026-11-04 16:49:08 +01:00
Olivier Floch
a3bacc45a3 test(web): silence the expected console errors with the shared helper 2026-11-04 16:48:20 +01:00
Olivier Floch
de2cfc864d test(web): cover the port bounds of the V2V connection form 2026-11-04 16:47:33 +01:00
Olivier Floch
dfa0baf1fa fix(web-core): add the zero form of the French V2V migration counts 2026-11-04 16:47:28 +01:00
Olivier Floch
501bb71dc1 chore(web-core): remove the unused remaining-space translation 2026-11-04 16:47:28 +01:00
Olivier Floch
de1d6ed21d refactor(web): remove the unused toV2vConnection util 2026-11-04 16:46:38 +01:00
Olivier Floch
7d4f325330 refactor(web): share the V2V connection through provide/inject
XO 6 modules hold no Pinia store: the migration page now provides the
connection and its inventory to the wizard, which injects them. They live
as long as the page, so closing the wizard forgets the credentials without
a manual clear.
2026-11-04 16:14:13 +01:00
Olivier Floch
f1b316bd01 chore(web): add a dev-only mock of the V2V REST routes
Enabled with VITE_XO_V2V_MOCK=true until the back end ships the V2V routes.
2026-11-04 16:14:12 +01:00
Olivier Floch
166c4d1e46 feat(web): add the source step of the V2V migration wizard
Administration › V2V › Migrate VMs: connect to a VMware source, new or remembered in the browser, with the vectura prerequisite check and the inventory loading. The other steps are placeholders.
2026-11-04 16:14:12 +01:00
Olivier Floch
a2c83b402f refactor(web): share the connection form inputs and credentials error
Shared FormTextInput, FormPasswordInput, FormCheckbox and the credentials error composable, used by the pool connection and backup repository forms, with their tests and the shared test helpers.
2026-11-04 16:12:47 +01:00
Olivier Floch
d4047e3b9b feat(web-core): add UiNumberedStepper component 2026-11-04 16:12:47 +01:00
454 changed files with 25130 additions and 879 deletions

View File

@@ -32,6 +32,14 @@ export type V2V_VM_OTHER_CONFIG = (typeof V2V_VM_OTHER_CONFIG)[keyof typeof V2V_
export type XoV2vVmOtherConfig = Partial<Record<V2V_VM_OTHER_CONFIG, string>>
// The disk of the source chain an imported VDI last received, one of the `chainPaths` of its source disk: the CID
// cannot tell a fresh snapshot delta from its parent, the path can
export const V2V_VDI_OTHER_CONFIG = {
DISK_PATH: 'esxi_diskPath',
} as const
export type V2V_VDI_OTHER_CONFIG = (typeof V2V_VDI_OTHER_CONFIG)[keyof typeof V2V_VDI_OTHER_CONFIG]
export const V2V_START_BLOCKED_REASON = 'Esxi migration in progress...'
export const V2V_PARTIAL_IMPORT_TAG = 'xo:no-bak=V2V partial import'
@@ -120,6 +128,9 @@ export type XoV2vDisk = {
mode: V2V_DISK_MODE
isRdm: boolean
isEncrypted: boolean
// `dir/disk.vmdk` in its datastore (no `[datastore]` prefix) of the base disk, then of each snapshot delta down to
// the active disk: the chain the import walks for this vmx node
chainPaths: string[]
}
// No port group for opaque (NSX) or missing backings

View File

@@ -7,7 +7,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -18,7 +18,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -27,6 +26,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewBondedNetworkForm } from '@/modules/network/form/new-bonded/use-new-bonded-network-form.ts'
import type { NewBondedNetworkPayload } from '@/modules/network/jobs/bonded-network-create.job.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -4,7 +4,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -16,13 +16,13 @@
</template>
<script setup lang="ts">
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
import NewNetworkNbdRadio from '@/modules/network/components/form/new/inputs/NewNetworkNbdRadio.vue'
import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewNetworkButtonsSection.vue'
import { useNewInternalNetworkForm } from '@/modules/network/form/new-internal/use-new-internal-network-form.ts'
import type { NewInternalNetworkPayload } from '@/modules/network/jobs/internal-network-create.job.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -7,18 +7,17 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
<NetworkFormNumberInput v-bind="vlanInputBindings" class="vlan" />
<VtsNumberInput v-bind="vlanInputBindings" class="vlan" />
<NewNetworkNbdRadio class="nbd" v-bind="nbdRadioBindings" />
<NewNetworkButtonsSection :cancel-to :submit-label="t('action:create-network')" />
</VtsForm>
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -27,6 +26,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewNetworkForm } from '@/modules/network/form/new/use-new-network-form.ts'
import type { NewNetworkPayload } from '@/modules/network/jobs/network-create.job.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -1,27 +0,0 @@
<template>
<VtsInputWrapper :label :message="messages">
<UiInput v-model.number="model" accent="brand" type="number" :required @blur="emit('blur')" />
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { computed } from 'vue'
const { info, warning, error } = defineProps<{
label: string
error?: InputWrapperMessage
warning?: InputWrapperMessage
info?: string
required?: boolean
}>()
const emit = defineEmits<{ blur: [] }>()
const model = defineModel<number | undefined>()
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
</script>

View File

@@ -422,6 +422,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/number-input/vts-number-input': RouteRecordInfo<
'/story/web-core/number-input/vts-number-input',
'/story/web-core/number-input/vts-number-input',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/object-icon/vts-object-icon': RouteRecordInfo<
'/story/web-core/object-icon/vts-object-icon',
'/story/web-core/object-icon/vts-object-icon',
@@ -499,6 +506,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/success-state-hero/vts-success-state-hero': RouteRecordInfo<
'/story/web-core/success-state-hero/vts-success-state-hero',
'/story/web-core/success-state-hero/vts-success-state-hero',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/tab/router-tab': RouteRecordInfo<
'/story/web-core/tab/router-tab',
'/story/web-core/tab/router-tab',
@@ -527,6 +541,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/tags-input/vts-tags-input': RouteRecordInfo<
'/story/web-core/tags-input/vts-tags-input',
'/story/web-core/tags-input/vts-tags-input',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/tree/vts-tree-item': RouteRecordInfo<
'/story/web-core/tree/vts-tree-item',
'/story/web-core/tree/vts-tree-item',
@@ -828,6 +849,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/ui/numbered-stepper/ui-numbered-stepper': RouteRecordInfo<
'/story/web-core/ui/numbered-stepper/ui-numbered-stepper',
'/story/web-core/ui/numbered-stepper/ui-numbered-stepper',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/ui/object-link/ui-object-link': RouteRecordInfo<
'/story/web-core/ui/object-link/ui-object-link',
'/story/web-core/ui/object-link/ui-object-link',
@@ -1475,6 +1503,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/number-input/vts-number-input.story.vue': {
routes:
| '/story/web-core/number-input/vts-number-input'
views:
| never
}
'src/stories/web-core/object-icon/vts-object-icon.story.vue': {
routes:
| '/story/web-core/object-icon/vts-object-icon'
@@ -1541,6 +1575,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/success-state-hero/vts-success-state-hero.story.vue': {
routes:
| '/story/web-core/success-state-hero/vts-success-state-hero'
views:
| never
}
'src/stories/web-core/tab/router-tab.story.vue': {
routes:
| '/story/web-core/tab/router-tab'
@@ -1565,6 +1605,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/tags-input/vts-tags-input.story.vue': {
routes:
| '/story/web-core/tags-input/vts-tags-input'
views:
| never
}
'src/stories/web-core/tree/vts-tree-item.story.vue': {
routes:
| '/story/web-core/tree/vts-tree-item'
@@ -1823,6 +1869,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/ui/numbered-stepper/ui-numbered-stepper.story.vue': {
routes:
| '/story/web-core/ui/numbered-stepper/ui-numbered-stepper'
views:
| never
}
'src/stories/web-core/ui/object-link/ui-object-link.story.vue': {
routes:
| '/story/web-core/ui/object-link/ui-object-link'

View File

@@ -0,0 +1,8 @@
# Usage
A number field with its label and its info, warning and error messages, ready for the field bindings of a validated
form. A cleared input gives an empty value, not `0`.
```vue-template
<VtsNumberInput v-model="quantity" label="Quantity" info="Between 1 and 8" />
```

View File

@@ -0,0 +1,23 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
model().type('number | undefined').preset(2),
prop('label').str().required().preset('Quantity').widget(),
prop('info').str().widget(),
prop('warning').type('InputWrapperMessage').widget(object()),
prop('error').type('InputWrapperMessage').widget(object()),
prop('required').bool().widget(),
event('blur'),
]"
>
<VtsNumberInput v-bind="properties" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { event, model, prop } from '@/libs/story/story-param.ts'
import { object } from '@/libs/story/story-widget.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
</script>

View File

@@ -0,0 +1,13 @@
# Usage
The card telling an action succeeded: an optional title, the success alert, then the actions that follow, centred.
```vue-template
<VtsSuccessStateHero>
<template #title>Connected to 192.168.1.1</template>
The pool is connected.
<template #actions>
<UiLink :to="poolRoute" size="medium">Visit the pool dashboard</UiLink>
</template>
</VtsSuccessStateHero>
```

View File

@@ -0,0 +1,27 @@
<template>
<ComponentStory
v-slot="{ settings }"
:params="[
slot('title').help('Optional, shown above the alert'),
slot().help('The text of the success alert'),
slot('actions').help('The links and buttons that follow the success, centred'),
setting('titleSlot').preset('Connected to 192.168.1.1').widget(),
setting('defaultSlot').preset('The pool is connected.').widget(),
]"
>
<VtsSuccessStateHero>
<template v-if="settings.titleSlot" #title>{{ settings.titleSlot }}</template>
{{ settings.defaultSlot }}
<template #actions>
<UiButton accent="brand" variant="secondary" size="medium">Continue</UiButton>
</template>
</VtsSuccessStateHero>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { setting, slot } from '@/libs/story/story-param.ts'
import VtsSuccessStateHero from '@core/components/success-state-hero/VtsSuccessStateHero.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
</script>

View File

@@ -0,0 +1,14 @@
# Usage
Enter or comma adds the typed value: it is trimmed, and an empty value or a value already in the list is ignored. A
pasted comma-separated draft adds each of its values. Enter while an IME composition is in progress adds nothing.
Removing a chip updates the model with the new array.
The label comes from the surrounding `VtsInputWrapper`.
```vue-template
<VtsInputWrapper label="Values">
<VtsTagsInput v-model="values" accent="brand" placeholder="Add a value" />
</VtsInputWrapper>
```

View File

@@ -0,0 +1,21 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
model().type('string[]').preset(['alpha', 'beta']).help('Enter or comma adds the typed value'),
prop('accent').enum('brand', 'warning', 'danger').required().preset('brand').widget(),
prop('placeholder').str().preset('Add a value').widget(),
]"
>
<VtsInputWrapper label="Values">
<VtsTagsInput v-bind="properties" />
</VtsInputWrapper>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { model, prop } from '@/libs/story/story-param.ts'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsTagsInput from '@core/components/tags-input/VtsTagsInput.vue'
</script>

View File

@@ -0,0 +1,33 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
prop('steps').arr('NumberedStepDefinition').required().preset(steps),
prop('currentStep').num().required().preset(0).help('Index of the current step, earlier steps are done'),
]"
:presets
>
<UiNumberedStepper v-bind="properties" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { prop } from '@/libs/story/story-param.ts'
import UiNumberedStepper, {
type NumberedStepDefinition,
} from '@core/components/ui/numbered-stepper/UiNumberedStepper.vue'
const steps: NumberedStepDefinition[] = [
{ label: 'General information' },
{ label: 'Settings' },
{ label: 'Options' },
{ label: 'Review' },
]
const presets = {
'First step': { props: { steps, currentStep: 0 } },
'Intermediate step': { props: { steps, currentStep: 2 } },
'Last step': { props: { steps, currentStep: 3 } },
}
</script>

View File

@@ -211,3 +211,14 @@ So, a small utility can be used to add this style on the element that needs it:
```
Then, you can tweak the `inset` property to get the desired result.
## `•` separator utility
To separate inline parts with an interpunct (`•`), put the global `interpunct-list` class on their container: each rendered child after the first gets the separator, so a child hidden by `v-if` leaves no stray one.
```html
<span class="interpunct-list">
<span>{{ t('n-hosts', { n: hostCount }) }}</span>
<span v-if="srCount !== undefined">{{ t('n-srs', { n: srCount }) }}</span>
</span>
```

View File

@@ -95,3 +95,9 @@ pre {
content: '\2014'; /* em dash — */
margin-inline-end: 0.8rem;
}
/* Between rendered children only: a child hidden by `v-if` leaves no stray separator. Empty alt text: not read out */
.interpunct-list > * + *::before {
content: '•' / '';
margin-inline: 0.8rem;
}

View File

@@ -8,6 +8,7 @@
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { mergeInputWrapperMessages } from '@core/utils/input-wrapper-message.util.ts'
import { computed } from 'vue'
const { info, warning, error } = defineProps<{
@@ -19,9 +20,8 @@ const { info, warning, error } = defineProps<{
}>()
const emit = defineEmits<{ blur: [] }>()
const model = defineModel<number | undefined>()
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
const messages = computed(() => mergeInputWrapperMessages(info, warning, error))
</script>

View File

@@ -2,7 +2,7 @@
<template>
<div class="vts-side-panel-sticky" :class="{ mobile: uiStore.isSmall }">
<VtsPanel
:closable="hasSelection"
:closable="isClosable"
:close-icon="uiStore.isSmall ? 'fa:angle-left' : 'action:close-cancel-clear'"
class="vts-side-panel"
:class="{
@@ -47,11 +47,12 @@ import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { watch } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { hasSelection } = defineProps<{
const { hasSelection, persistent } = defineProps<{
hasSelection?: boolean
persistent?: boolean
}>()
const emit = defineEmits<{
@@ -70,6 +71,9 @@ const uiStore = useUiStore()
const { t } = useI18n()
// A persistent panel holds no selection to clear: it only closes where closing collapses it
const isClosable = computed(() => hasSelection && (!persistent || panelStore.syncsOpenStateWithSelection))
watch(
[() => hasSelection, () => panelStore.isLocked, () => uiStore.isSmall],
([hasSelection]) => {

View File

@@ -0,0 +1,69 @@
<template>
<VtsStateHero class="vts-success-state-hero" type="all-good" format="card" size="medium">
<div class="container">
<div v-if="slots.title" class="typo-h1 title"><slot name="title" /></div>
<div class="content" :class="{ mobile: uiStore.isSmall, desktop: !uiStore.isLarge }">
<UiAlert accent="success"><slot /></UiAlert>
<div class="action-buttons" :class="{ mobile: uiStore.isSmall }">
<slot name="actions" />
</div>
</div>
</div>
</VtsStateHero>
</template>
<script lang="ts" setup>
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
const slots = defineSlots<{
title?(): any
default(): any
actions(): any
}>()
const uiStore = useUiStore()
</script>
<style lang="postcss" scoped>
.vts-success-state-hero {
.container {
display: flex;
flex-direction: column;
gap: 4rem;
}
.title {
color: var(--color-neutral-txt-primary);
}
.content {
color: var(--color-neutral-txt-primary);
display: flex;
flex-direction: column;
gap: 4rem;
margin: 0 auto;
width: 65%;
&.desktop {
width: 80%;
}
&.mobile {
width: 100%;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 2.4rem;
&.mobile {
flex-direction: column;
align-items: center;
}
}
}
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div class="vts-tags-input">
<UiInput v-model="draft" :accent :placeholder @keydown="(event: KeyboardEvent) => handleKeydown(event)" />
<div v-if="tags.length > 0" class="chips">
<UiChip v-for="(tag, index) of tags" :key="index" accent="info" @remove="removeTag(index)">
{{ tag }}
</UiChip>
</div>
</div>
</template>
<script lang="ts" setup>
import UiChip from '@core/components/ui/chip/UiChip.vue'
import UiInput, { type InputAccent } from '@core/components/ui/input/UiInput.vue'
import { ref } from 'vue'
const { accent, placeholder } = defineProps<{
accent: InputAccent
placeholder?: string
}>()
const tags = defineModel<string[]>({ required: true })
const draft = ref('')
const TAG_SEPARATOR_KEYS = ['Enter', ',']
// A pasted draft may hold several comma-separated tags
function addDraftTags() {
const draftTags = new Set(draft.value.split(',').map(tag => tag.trim()))
const newTags = [...draftTags].filter(tag => tag !== '' && !tags.value.includes(tag))
draft.value = ''
if (newTags.length === 0) {
return
}
tags.value = [...tags.value, ...newTags]
}
function handleKeydown(event: KeyboardEvent) {
// Enter also validates an IME composition, which must not add a half-typed tag
const isSeparator = !event.isComposing && TAG_SEPARATOR_KEYS.includes(event.key)
if (!isSeparator) {
return
}
// Prevents a form submit on Enter and keeps the comma out of the draft
event.preventDefault()
addDraftTags()
}
function removeTag(index: number) {
tags.value = tags.value.filter((_tag, tagIndex) => tagIndex !== index)
}
</script>
<style lang="postcss" scoped>
.vts-tags-input {
display: flex;
flex-direction: column;
gap: 0.8rem;
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<!-- v1 -->
<template>
<ol class="ui-numbered-stepper">
<li
v-for="step in stepItems"
:key="step.number"
class="step"
:class="{ done: step.isDone, current: step.isCurrent }"
:aria-current="step.isCurrent ? 'step' : undefined"
>
<span class="marker typo-caption-small" aria-hidden="true">
<VtsIcon v-if="step.isDone" name="fa:check" size="small" />
<template v-else>{{ step.number }}</template>
</span>
<span class="label" :class="step.isCurrent ? 'typo-body-bold-small' : 'typo-body-regular-small'">
{{ step.label }}
<span v-if="step.isDone" class="visually-hidden">{{ t('done') }}</span>
</span>
</li>
</ol>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
export type NumberedStepDefinition = {
label: string
}
const { steps, currentStep } = defineProps<{
steps: NumberedStepDefinition[]
currentStep: number
}>()
const { t } = useI18n()
const stepItems = computed(() =>
steps.map((step, index) => ({
label: step.label,
number: index + 1,
isDone: index < currentStep,
isCurrent: index === currentStep,
}))
)
</script>
<style lang="postcss" scoped>
.ui-numbered-stepper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.8rem;
margin: 0;
padding: 0;
list-style: none;
.step {
display: flex;
align-items: center;
gap: 0.8rem;
&:not(:last-child)::after {
content: '';
width: 4rem;
height: 0.2rem;
margin-inline: 0.4rem;
background-color: var(--color-neutral-border);
}
&:is(.done, .current) .label {
color: var(--color-neutral-txt-primary);
}
}
.marker {
display: flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border: 0.1rem solid var(--color-neutral-border);
border-radius: 50%;
color: var(--color-neutral-txt-secondary);
background-color: var(--color-neutral-background-primary);
}
.label {
color: var(--color-neutral-txt-secondary);
}
.done .marker {
border-color: var(--color-success-item-base);
color: var(--color-success-txt-base);
background-color: var(--color-success-background-selected);
}
.current .marker {
border-color: var(--color-brand-item-base);
color: var(--color-brand-txt-item);
background-color: var(--color-brand-item-base);
}
}
</style>

View File

@@ -57,6 +57,19 @@ Now if the URL contains `?count=42`, then `count.value` will be the number `42`.
Setting `count.value = 0` will remove the query parameter from the URL.
A string subtype, such as a branded id, keeps its own type:
```typescript
type ItemId = string & { readonly __brand: 'item' }
const itemId = useRouteQuery<ItemId | undefined>('item', {
toData: query => (query === '' ? undefined : (query as ItemId)),
toQuery: data => data ?? '',
})
```
Here `itemId.value` is an `ItemId | undefined`, and setting it to `undefined` removes the query parameter from the URL.
### Working with objects, arrays, Set, Map, and boolean
The `useRouteQuery` composable can handle any data type,

View File

@@ -40,13 +40,16 @@ export type GuessActions<TData> = TData extends string | number
? MapActions<TKey, TValue>
: EmptyObject
// A string subtype (e.g. a branded id) is also an object type: it is matched first to keep it as is
export type RouteQuery<TData> = WritableComputedRef<
TData extends Set<infer V>
? ReadonlySet<V>
: TData extends Map<infer K, infer V>
? ReadonlyMap<K, V>
: TData extends object
? Readonly<TData>
: TData
TData extends string
? TData
: TData extends Set<infer V>
? ReadonlySet<V>
: TData extends Map<infer K, infer V>
? ReadonlyMap<K, V>
: TData extends object
? Readonly<TData>
: TData
> &
GuessActions<TData>

View File

@@ -30,6 +30,7 @@ The selection is stored by item ID, so it is kept when changing page or filter.
- "Select all" selects every item matching the filter
- "Clear selection" unselects every item, including the ones hidden by the filter
- IDs of items that are no longer in `items` are not part of `selectedIds`
- A row checkbox can be disabled with the optional `disabled` binding of the `checkbox` body cell; the header checkbox and "Select all" still include that row, so keep it out of `items` if it must never be selected
## Usage

View File

@@ -21,8 +21,11 @@
"action:attach": "Attach",
"action:attach-vdi": "@:attach-vdi",
"action:back": "Back",
"action:back-to-vms": "Back to VMs",
"action:boot-vm": "Boot VM",
"action:change-state": "Change state",
"action:clear": "Clear",
"action:clear-filter": "Clear filter",
"action:clear-selection": "clear selection",
"action:close": "Close",
"action:configure": "Configure",
@@ -36,6 +39,7 @@
"action:connect-n-vifs": "Connect 1 VIF | Connect {n} VIFs",
"action:connect-pool": "Connect pool",
"action:continue": "Continue",
"action:continue-with-existing-list": "Continue with existing list",
"action:copy": "Copy",
"action:copy-all": "Copy all",
"action:copy-host": "Copy host",
@@ -85,7 +89,9 @@
"action:edit-br": "@:action:edit BR",
"action:edit-config": "Edit config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-settings": "@:action:edit @.lower:settings",
"action:edit-traffic-rule": "@:action:edit @.lower:traffic-rule",
"action:edit-vm-settings": "@:action:edit {name} @.lower:settings",
"action:emergency-shutdown": "Emergency shutdown",
"action:emergency-shutdown-host": "@:action:emergency-shutdown host",
"action:enable": "Enable",
@@ -110,6 +116,7 @@
"action:forget-host": "Forget host",
"action:forget-n-brs": "@:action:forget 1 BR | @:action:forget {n} BRs",
"action:forget-n-pifs": "Forget 1 PIF | Forget {n} PIFs",
"action:forget-password": "Forget password",
"action:go-back": "Go back",
"action:group": "Group",
"action:hide": "Hide",
@@ -117,6 +124,7 @@
"action:import-export": "Import/Export",
"action:install-app": "Install {name}",
"action:install-guest-tools": "Install guest tools",
"action:launch-n-vms": "Launch 1 VM | Launch {n} VMs",
"action:load": "Load",
"action:load-now": "Load now",
"action:log-out": "Log out",
@@ -124,6 +132,8 @@
"action:migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"action:migrate-vdi-on-sr": "Migrate on SR",
"action:move-filters-to-parent-group": "Move filters to parent group",
"action:next-mapping": "Next: mapping",
"action:next-review": "Next: review",
"action:open": "Open",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Open console in new tab",
@@ -135,6 +145,9 @@
"action:reboot": "Reboot",
"action:reboot-host": "@:action:reboot host",
"action:reformat": "Reformat",
"action:remove": "Remove",
"action:remove-from-list": "Remove from list",
"action:remove-name-from-list": "Remove {name} from the list",
"action:reset-all-networks": "Reset all networks",
"action:reset-all-srs": "Reset all SRs",
"action:reset-to-default": "Reset to default",
@@ -148,12 +161,15 @@
"action:see-all": "See all",
"action:see-less": "See less",
"action:see-n-more": "See {n} more",
"action:see-task": "See the task",
"action:select-all": "Select all",
"action:select-all-n-objects": "select {n} object | select all {n} objects",
"action:select-n-objects-of": "{from} - {to} of {total} objects",
"action:select-network": "Select a network",
"action:select-none": "Select none",
"action:select-pool": "Select a pool",
"action:select-storage": "Select a storage",
"action:select-template": "Select a template",
"action:send": "Send",
"action:send-ctrl-alt-del": "Send Ctrl+Alt+Del",
"action:set-pif-management": "Set management PIF",
@@ -182,6 +198,8 @@
"action:update": "Update",
"action:use-query-builder": "Use query builder",
"action:visit-pool-dashboard": "Visit pool dashboard",
"active-mapping": "Active mapping",
"adapter": "Adapter",
"add-or": "+OR",
"admin-login": "Admin login",
"admin-password": "Admin password",
@@ -205,6 +223,7 @@
"all-done!": "All done!",
"all-good!": "All good!",
"all-quiet-launchpad": "All quiet on the launchpad",
"all-vms": "All VMs",
"all-srs-shared-storage-migration-optional": "All SRs are shared, storage migration is optional.",
"all-vifs-follow-vm": "All VIFs follow the VM to its new host: within the pool, their networks don't change.",
"allocated-space": "Allocated space",
@@ -213,6 +232,7 @@
"allow-unauthorized": "Allow unauthorized",
"allowed-ips": "Allowed IPs",
"allowed-ips-example": "For example: 192.168.0.1; 2001:db8::1",
"also-on": "Also on",
"and": "And",
"any-property": "Any property",
"api-error-details": "API Error details",
@@ -299,6 +319,7 @@
"character-limit": "{count}/{max} character | {count}/{max} characters",
"check-errors:": "Check out the errors:",
"check-summing": "TX checksumming",
"checks": "Checks",
"click-test-br-speed": "Click to test BR speed",
"click-to-display-alarms:": "Click to display alarms:",
"click-to-return-default-pool": "Click here to return to the default pool",
@@ -370,11 +391,18 @@
"dark-mode:disable": "Disable dark mode",
"dark-mode:enable": "Enable dark mode",
"dashboard": "Dashboard",
"datastore": "Datastore",
"datastores": "Datastores",
"date": "Date",
"default-behavior": "Default behavior",
"default-locking-mode": "Default locking mode",
"default-network": "Default network",
"default-options": "Default options",
"default-storage-repository": "Default storage repository",
"default-template": "Default template",
"default-value": "Default ({value})",
"deletion-blocked": "Deletion Blocked",
"delta-transfer": "Delta transfer",
"deploy-xoa-available-on-desktop": "XOA deployment is available on your desktop interface",
"deploy-xoa-custom-ntp-servers": "Custom NTP servers (separated by spaces)",
"deploy-xoa-status:configuring": "Configuring XOA…",
@@ -385,10 +413,14 @@
"deploy-xoa-status:waiting": "Waiting for XOA to respond…",
"descending": "descending",
"description": "Description",
"destination": "Destination",
"destination-host": "Destination host",
"destination-network": "Destination network",
"destination-network-required": "Destination network is required.",
"destination-pool": "Destination pool",
"destination-pool-named": "Destination pool {name}",
"destination-sr": "Destination SR",
"destination-vm": "Destination VM",
"destination-sr-required": "Destination SR is required.",
"details": "Details",
"device": "Device",
@@ -408,8 +440,10 @@
"disconnected-from-physical-device": "Disconnected from physical device",
"disconnected-pbd-number": "Disconnected PBD #{n}",
"disconnecting": "Disconnecting",
"disk": "Disk",
"disk-name": "Disk name",
"disk-space": "Disk space",
"disks": "Disks",
"dns": "DNS",
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"do-you-really-want-to-leave-this-page?": "Some changes are still being deployed. Do you really want to leave this page?",
@@ -483,10 +517,13 @@
"from-vm": "From VM",
"full": "Full",
"full-copy": "Full copy",
"full-transfer": "Full transfer",
"fullscreen": "Fullscreen",
"gateway": "Gateway",
"general": "General",
"general-information": "General information",
"global-default": "Global default",
"global-defaults-for-name": "Global defaults for {name}",
"gpu": "GPU",
"gpus": "GPUs",
"graphics-display": "Graphics & Display",
@@ -513,6 +550,7 @@
"host-not-running": "Host is not running",
"host-off": "Host is powered off",
"host-or-ip-address": "Host or IP address",
"host-required": "Host is required.",
"host:status:active": "@:status:active",
"host:status:disabled": "@:status:disabled",
"host:status:halted": "@:status:halted",
@@ -524,8 +562,10 @@
"https": "HTTPS",
"hyper-threading": "Hyper threading (SMT)",
"id": "ID",
"import-in-progress": "Import in progress",
"in": "in",
"in-last-three-runs": "In their last 3 runs",
"in-list": "In list",
"in-progress": "In progress",
"included": "Included",
"incremental": "Incremental",
@@ -687,6 +727,8 @@
"job:sr-delete:missing-sr": "No SRs to delete",
"job:sr-disconnect:missing-sr": "No SRs to disconnect",
"job:traffic-rule-delete:missing-rule": "No traffic rule selected",
"job:v2v-start:in-progress": "Migration launch in progress…",
"job:v2v-start:missing-vm": "No VMs to migrate",
"job:vbd-connect:missing-vbd": "No VBDs to connect",
"job:vbd-connect:vbd-already-connected": "VBD is already connected",
"job:vbd-delete:missing-vbd": "No VBDs to delete",
@@ -769,10 +811,12 @@
"json-schema:numbered-item": "{label} {n}",
"json-schema:show-examples-n": "Show example ({n}) | Show examples ({n})",
"json-schema:unsupported-field": "The \"{label}\" field cannot be edited from this interface",
"keep-mac-addresses": "Keep MAC addresses",
"keep-me-logged": "Keep me logged in",
"keep-page-open": "Do not refresh or quit tab before end of deployment.",
"key": "Key",
"keywords": "Keywords",
"label-with-count": "{label} {count}",
"language": "Language",
"language-preferences": "Language preferences",
"last": "Last",
@@ -783,6 +827,7 @@
"last-revert": "Last revert",
"last-run-number": "Last run #{n}",
"last-snapshot": "Last snapshot",
"last-sync": "Last sync",
"last-week": "Last week",
"ldap-auth-providers": "LDAP/Auth providers",
"learn-more": "Learn more",
@@ -797,6 +842,7 @@
"loaded": "Loaded",
"loading": "Loading",
"local": "Local",
"location": "Location",
"locked": "Locked",
"locking-mode": "Locking mode",
"locking-mode-default": "Default locking mode",
@@ -815,6 +861,7 @@
"management-ip": "Management IP",
"manual": "Manual",
"manufacturer-info": "Manufacturer info",
"mapping": "Mapping",
"master": "Primary host",
"maximum-dynamic-memory": "Maximum dynamic memory",
"maximum-static-memory": "Maximum static memory",
@@ -823,6 +870,8 @@
"memory-usage": "Memory usage",
"merge-backups-synchronously": "Merge backups synchronously",
"message": "Message",
"migrate-vms": "Migrate VMs",
"migration": "Migration",
"migration-blocked": "Migration Blocked",
"migration-compression": "Migration compression",
"migration-network": "Migration network",
@@ -883,19 +932,28 @@
"multi-pathing": "Multi-pathing",
"multiple-schedules": "Multiple schedules",
"multiple-schedules-advice": "We advise splitting a backup job with multiple schedules into multiple backup jobs with one schedule each.",
"n-blocking": "1 blocking | {n} blocking",
"n-bonded-networks": "1 bonded network | {n} bonded networks",
"n-brs": "1 BR | {n} BRs",
"n-disks": "1 disk | {n} disks",
"n-gb": "{n} GB",
"n-gb-left": "{n} GB left",
"n-gb-required": "{n} GB required",
"n-hexadecimal-characters": "{n} hexadecimal characters.",
"n-hosts": "1 host | {n} hosts",
"n-hosts-awaiting-patch": "{n} host is awaiting this patch | {n} hosts are awaiting this patch",
"n-in-progress": "1 in progress | {n} in progress",
"n-internal-networks": "1 internal network | {n} internal networks",
"n-migrated": "1 migrated | {n} migrated",
"n-missing": "{n} missing",
"n-need-confirmation": "1 needs confirmation | {n} need confirmation",
"n-networks": "1 network | {n} networks",
"n-nics": "1 NIC | {n} NICs",
"n-not-migrated": "1 not migrated | {n} not migrated",
"n-partially-migrated": "1 partially migrated | {n} partially migrated",
"n-percent": "{n}%",
"n-pifs": "1 PIF | {n} PIFs",
"n-ready": "1 ready | {n} ready",
"n-snapshots": "1 snapshot | {n} snapshots",
"n-srs": "1 SR | {n} SRs",
"n-subtasks": "1 subtask | {n} subtasks",
@@ -905,6 +963,8 @@
"n-vdis": "1 VDI | {n} VDIs",
"n-vifs": "1 VIF | {n} VIFs",
"n-vms": "1 VM | {n} VMs",
"n-vms-in-list": "1 VM in the list | {n} VMs in the list",
"n-warnings": "1 warning | {n} warnings",
"name": "Name",
"name-required": "Name is required.",
"native": "Native",
@@ -915,6 +975,7 @@
"nested-virtualization": "Nested virtualization",
"netmask": "Netmask",
"network": "Network",
"network-adapters": "Network adapters",
"network-block-device": "Network block device",
"network-config": "Network config",
"network-create:warning:mtu-out-of-range": "MTU should be between {min} and {max}",
@@ -992,6 +1053,7 @@
"no-pif-detected": "No PIFs detected",
"no-plugin-detected": "No plugins detected",
"no-pools-detected": "No pools detected",
"no-remembered-connection": "No remembered connection",
"no-replicated-vm": "This VM isn’t being replicated. Configure a replication job to enable it.",
"no-result": "No results",
"no-role-applied": "No role applied",
@@ -1015,8 +1077,11 @@
"normal": "Normal",
"not-found": "Not found",
"not-included": "Not included",
"not-migrated": "Not migrated",
"not-ready": "Not ready",
"not-resident-on": "Not resident on",
"not-running": "Not running",
"not-supported-yet": "Not supported yet",
"not-yet-available": "Information is not yet available",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
@@ -1048,6 +1113,8 @@
"operation:vm-clean-shutdown": "VMs clean shutting down",
"operation:vm-hard-shutdown": "VMs hard shutting down",
"operation:vm-suspend": "VMs suspending",
"option-off": "@:off",
"option-on": "@:on",
"optional": "Optional",
"options": "Options",
"or": "Or",
@@ -1056,6 +1123,7 @@
"other": "Other",
"other-properties": "Other properties",
"ova": "OVA",
"overridden": "Overridden",
"page-not-found": "This page is not to be found…",
"page-please-wait": "Hold tight, astronaut! The page is getting ready to take off.{newline}We're just aligning the thrusters and calibrating the systems. It won't be long now, stay tuned!",
"pagination:all": "@:all",
@@ -1075,6 +1143,7 @@
"pause-blocked": "Pause Blocked",
"pbd-details": "Physical Block Device (PBD) details",
"pending": "Pending",
"per-vm-options": "Per-VM options",
"physical-interface-status": "Physical interface status",
"pif": "PIF",
"pif-status": "PIF status",
@@ -1107,7 +1176,9 @@
"pool-forget-confirm-info": "This will permanently remove this host from XO. To reconnect, you will need to add it again.",
"pool-forget-confirm-title": "You are about to remove {name} host",
"pool-management": "Pool management",
"pool-named": "Pool {name}",
"pool-ram-usage": "Pool RAM Usage",
"pool-required": "Pool is required.",
"pool:status:connected": "@:status:connected",
"pool:status:disconnected": "@:status:disconnected",
"pool:status:unreachable": "@:status:unreachable",
@@ -1121,12 +1192,15 @@
"popup-first-connection-introduction": "We’re excited to introduce the first official release of Xen Orchestra 6.",
"popup-first-connection-upcoming-release": "In the upcoming releases, we’ll be adding actions on all objects, user management, backup features, and much more. We hope you’ll enjoy exploring this brand-new interface!",
"port": "Port",
"port-group": "Port group",
"port-invalid": "Port format is invalid.",
"port-required": "Port is required.",
"power-on-host-for-console": "Power on your host to access its console",
"power-on-mode": "Power on mode",
"power-on-vm-for-console": "Power on your VM to access its console",
"power-state": "Power state",
"power-state-on-host": "{power} on {host}",
"preview": "Preview",
"pro-support": "{name} pro support",
"professional-support": "Professional support",
"progress": "Progress",
@@ -1180,6 +1254,7 @@
"read": "Read",
"read-only": "Read-only",
"reading-speed": "Reading speed",
"ready": "Ready",
"reboot-blocked": "Reboot Blocked",
"reboot-vm-internal-shutdown": "Reboot VM on internal shutdown",
"receive": "Receive",
@@ -1193,6 +1268,9 @@
"relative-time:past": "{str} ago",
"relative-time:second": "1 second | {n} seconds",
"relative-time:year": "1 year | {n} years",
"remember-connection-in-browser": "Remember connection in this browser",
"remember-credentials-warning": "Storing these credentials in your browser increases security risks. Make sure this complies with your security guidelines.",
"remembered-connections": "Remembered connections",
"remote-syslog": "Remote syslog",
"report-recipients": "Report recipients",
"report-when": "Report when",
@@ -1205,8 +1283,11 @@
"resources": "Resources",
"resources-overview": "Resources overview",
"rest-api": "REST API",
"resume": "Resume",
"retry": "Retry",
"review": "Review",
"review-and-confirm": "Review & confirm",
"review-and-launch": "Review and launch",
"roles": "Roles",
"roles-applied": "Roles applied",
"root-by-default": "\"root\" by default.",
@@ -1230,7 +1311,9 @@
"select-pool": "Select a pool",
"select-preset": "Select a preset",
"select-to-see-details": "Click on {icon} to see details",
"selected-vms": "Selected VMs",
"selected-vms-in-execution": "Some selected VMs are running",
"selected-vms-n": "Selected VMs ({n})",
"self-signed-certificates": "Self-signed certificates",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
@@ -1241,6 +1324,9 @@
"size": "Size",
"size-and-speed": "@:size & @.lower:speed",
"size-on-disk": "Size on disk",
"size-on-this-datastore": "Size on this datastore",
"skip-ssl-check": "Skip SSL check (self-signed certificate)",
"skip-ssl-check-info": "The server certificate is not verified. Use it for self-signed certificates.",
"skipped": "Skipped",
"smart-mode": "Smart mode",
"smb": "SMB",
@@ -1259,6 +1345,7 @@
"software": "Software",
"software-tooling": "Software & tooling",
"source": "Source",
"source-to-destination": "{source} → {destination}",
"space": "Space",
"speed": "Speed",
"speed-limit": "Speed limit",
@@ -1283,6 +1370,7 @@
"ssh-login": "SSH login",
"ssh-password": "SSH password",
"ssh-password-confirm": "Confirm SSH password",
"ssl-check-skipped": "SSL check skipped",
"stacked-cpu-usage": "Stacked CPU usage",
"stacked-ram-usage": "Stacked RAM usage",
"start-console": "Start your {type} to access the console and resume operations!",
@@ -1309,6 +1397,7 @@
"status:unknown": "Unknown",
"status:unreachable": "Unreachable",
"step-n-of": "Step {current} / {total}",
"stop-on-error": "Stop on error",
"storage": "Storage",
"storage-configuration": "Storage configuration",
"storage-format": "Storage format",
@@ -1316,10 +1405,12 @@
"storage-repositories": "Storage repositories",
"storage-repository": "Storage repository",
"storage-usage": "Storage usage",
"stored-in-browser-only": "Stored in this browser only",
"subfolder": "Subfolder",
"success": "Success",
"summary": "Summary",
"support": "Support",
"supported": "Supported",
"supported-image-formats": "Supported image formats",
"suspend-blocked": "Suspend Blocked",
"suspend-storage-repository": "Suspend storage repository",
@@ -1335,6 +1426,8 @@
"table-selection:watch-out!": "Watch out!",
"tags": "Tags",
"target": "Target",
"target-network": "Target network",
"target-sr": "Target SR",
"task": "Task",
"task-type": "Task type",
"task:ended": "@:ended",
@@ -1347,6 +1440,7 @@
"tasks:overview": "Tasks overview",
"template": "Template",
"template-has-bios-strings": "The template already contains the BIOS strings",
"template-required": "Template is required.",
"test-data": "Test data",
"theme": "Theme",
"theme-auto": "Auto",
@@ -1422,6 +1516,7 @@
"unnamed": "Unnamed",
"unreachable-hosts": "Unreachable hosts",
"unreachable-hosts-reload-page": "Done, reload the page",
"unsupported": "Unsupported",
"untitled": "Untitled",
"untranslated-text-helper": "By default, untranslated texts will be presented in english.",
"up-to-date": "Up-to-date",
@@ -1429,6 +1524,7 @@
"use-https": "Use HTTPS",
"used": "Used",
"used-for-backup": "Used for backup",
"used-relative-time": "used {time}",
"used-space": "Used space",
"used-space-on-sr": "Used space on SR",
"user": "User",
@@ -1438,7 +1534,72 @@
"username-required": "Username is required.",
"users": "Users",
"uuid": "UUID",
"v2v": "V2V",
"v2v:action:add-to-migration-list": "Add to migration list",
"v2v:already-fully-migrated": "Already fully migrated",
"v2v:already-migrated-confirm-at-launch": "Already migrated, confirmation at launch",
"v2v:check:mapping-incomplete": "Mapping incomplete",
"v2v:check:n-snapshots": "1 snapshot on the source VM | {n} snapshots on the source VM",
"v2v:check:no-snapshot": "No snapshot on the source VM",
"v2v:check:no-unsupported-disk": "No unsupported disk (RDM, independent, encrypted)",
"v2v:choose-vmware-source": "Choose the VMware source",
"v2v:clean-up-preview": "Clean up (preview)",
"v2v:clean-up-vm": "Automatically clean up the VM",
"v2v:connect-new-vmware-source": "Connect a new VMware source",
"v2v:continues-on-pool-sr": "continues on pool {pool}, SR {srs}",
"v2v:create-second-copy": "Create a second copy of {name}",
"v2v:credentials-never-stored": "Credentials are never stored by XO: they stay in this browser and are sent with each request that needs VMware. Migration status is shared by everyone who connects to the same vCenter.",
"v2v:cutover-done-on": "Cutover done on {date}.",
"v2v:default-options-info": "Apply to every VM unless overridden below",
"v2v:delta-from-last-sync": "Delta from last sync",
"v2v:destination-pool-info": "A migration goes from one source to one pool. Each source and pool pair has its own list.",
"v2v:existing-list-info": "This browser already holds a migration list for {migration} ({vms}). Continuing reopens it, with the current migration state of every VM.",
"v2v:filter-vms-info": "filter by datastore, status or name with the query builder",
"v2v:global-defaults-info": "Applied to every VM, disk and network adapter. Each VM can override them in step 4 (Mapping). The SR and network are prefilled with the pool default SR and the management network of the pool master.",
"v2v:launch-group:excluded": "Excluded",
"v2v:launch-group:new": "New",
"v2v:launch-group:resumed": "Resumed",
"v2v:launch-problems": "The migration cannot be started",
"v2v:launch-success": "The migration is running: its task tells its progress.",
"v2v:list-pair-info": "One source and one destination per list: choosing another source or pool opens its own list.",
"v2v:locked-since-first-sync": "Locked since the first sync",
"v2v:mac-kept": "MAC kept",
"v2v:mapping-locked": "mapping locked",
"v2v:migrated-vm-details": "Existing VM: {vm} on pool {pool}, SR {srs}. A second VM will be created on pool {destinationPool}.",
"v2v:n-excluded": "1 excluded | {n} excluded",
"v2v:n-vms-launched": "1 VM launched | {n} VMs launched",
"v2v:name-from-migration-list": "{name} from the migration list",
"v2v:next-run-reminder": "The longer since the last sync, the larger the final delta. Run a delta sync shortly before the cutover to keep the downtime short.",
"v2v:no-bak-info": "Excluded from backups: {tag} (removed at cutover)",
"v2v:no-vm-in-list": "No VM in the migration list",
"v2v:not-found-in-source": "Not found in the source",
"v2v:partial-import-on-pool": "Partial import on pool {pool}",
"v2v:per-vm-options-info": "\"Default\" follows the default options above",
"v2v:prerequisite-checking": "Prerequisite check: checking vectura…",
"v2v:prerequisite-error": "Prerequisite check: vectura cannot be used, VMs cannot be migrated until it is fixed",
"v2v:prerequisite-error-detail": "Prerequisite check: vectura cannot be used ({error}), VMs cannot be migrated until it is fixed",
"v2v:prerequisite-ok": "Prerequisite check: vectura OK",
"v2v:remove-from-list-info": "Only the migration list changes: nothing is deleted on the source nor in XO.",
"v2v:saved-in-this-browser": "Saved in this browser",
"v2v:sent-to-xo-at-launch": "sent to XO at launch",
"v2v:source-snapshot-changed": "Source snapshot changed since the last sync",
"v2v:start-blocked-partial": "Start blocked while the import is partial",
"v2v:start-on-xcp-ng": "Start on XCP-ng",
"v2v:start-vm-on-xcp-ng": "Start the VM on XCP-ng",
"v2v:status:cold": "Cutover done",
"v2v:status:warm": "Warm synced",
"v2v:stop-on-error-info": "Stop on the first error when importing VMs. Applies to the whole launch.",
"v2v:stop-source": "Stop source",
"v2v:stop-source-vm": "Stop the source VM",
"v2v:stop-source-vm-info": "Source VM stopped before the last delta transfer (after final snapshot). Needed to fully transfer a running VM.",
"v2v:tags-backup-info": "Use tags to pick up the VM in smart backup jobs. Until the cutover, the VM also carries {tag}, so backup jobs skip it; the tag is removed at cutover.",
"v2v:tags-mac-not-applied": "Tags and the Keep MAC addresses option of the mapping step are not applied yet.",
"v2v:unsupported-encrypted-disk": "Unsupported: encrypted disk",
"v2v:unsupported-independent-disk": "Unsupported: independent disk",
"v2v:unsupported-rdm-disk": "Unsupported: RDM disk",
"v2v:vm-start-problem": "{vm}: {message}",
"value-by-default": "{value} by default",
"value-from": "Value from",
"vcpu": "vCPU",
"vcpu-at-startup": "vCPU at startup",
"vcpus": "vCPUs",
@@ -1487,6 +1648,7 @@
"virtualization-mode": "Virtualization mode",
"virtualization-type": "Virtualization type",
"vlan": "VLAN",
"vlan-id": "VLAN {id}",
"vlan-required": "VLAN is required.",
"vm": "VM",
"vm-backup-failure-number-of-retries": "Number of retries if VM backup fails",
@@ -1527,6 +1689,7 @@
"vms-status": "VMs status",
"vms-tags": "VMs tags",
"vms-treeview": "VMs treeview",
"vmware": "VMware",
"vtpm": "VTPM",
"warning": "Warning",
"warning:vdi-already-attached": "The selected VDI is already attached to this VM.",

View File

@@ -21,8 +21,11 @@
"action:attach": "Attacher",
"action:attach-vdi": "@:attach-vdi",
"action:back": "Retour",
"action:back-to-vms": "Retour aux VMs",
"action:boot-vm": "Démarrer la VM",
"action:change-state": "Changer l'état",
"action:clear": "Vider",
"action:clear-filter": "Effacer le filtre",
"action:clear-selection": "vider la sélection",
"action:close": "Fermer",
"action:configure": "Configurer",
@@ -36,6 +39,7 @@
"action:connect-n-vifs": "Connecter 1 VIF | Connecter {n} VIFs",
"action:connect-pool": "Connecter un pool",
"action:continue": "Continuer",
"action:continue-with-existing-list": "Continuer avec la liste existante",
"action:copy": "Copier",
"action:copy-all": "Copier tout",
"action:copy-host": "Copier l'hôte",
@@ -85,7 +89,9 @@
"action:edit-br": "@:action:edit le BR",
"action:edit-config": "Modifier config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-settings": "@:action:edit les @.lower:settings",
"action:edit-traffic-rule": "@:action:edit la @.lower:traffic-rule",
"action:edit-vm-settings": "@:action:edit les @.lower:settings de {name}",
"action:emergency-shutdown": "Arrêt d'urgence",
"action:emergency-shutdown-host": "@:action:emergency-shutdown de l'hôte",
"action:enable": "Activer",
@@ -110,6 +116,7 @@
"action:forget-host": "Oublier l'hôte",
"action:forget-n-brs": "@:action:forget 1 BR | @:action:forget {n} BRs",
"action:forget-n-pifs": "Oublier 1 PIF | Oublier {n} PIFs",
"action:forget-password": "Oublier le mot de passe",
"action:go-back": "Revenir en arrière",
"action:group": "Grouper",
"action:hide": "Masquer",
@@ -117,6 +124,7 @@
"action:import-export": "Importer/Exporter",
"action:install-app": "Installer {name}",
"action:install-guest-tools": "Installer les guest tools",
"action:launch-n-vms": "Lancer 0 VM | Lancer 1 VM | Lancer {n} VMs",
"action:load": "Charger",
"action:load-now": "Charger maintenant",
"action:log-out": "Se déconnecter",
@@ -124,6 +132,8 @@
"action:migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"action:migrate-vdi-on-sr": "Migrer sur le SR",
"action:move-filters-to-parent-group": "Déplacer les filtres vers le groupe parent",
"action:next-mapping": "Suivant : correspondance",
"action:next-review": "Suivant : récapitulatif",
"action:open": "Ouvrir",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
@@ -135,6 +145,9 @@
"action:reboot": "Redémarrer",
"action:reboot-host": "@:action:reboot l'hôte",
"action:reformat": "Reformater",
"action:remove": "Retirer",
"action:remove-from-list": "Retirer de la liste",
"action:remove-name-from-list": "Retirer {name} de la liste",
"action:reset-all-networks": "Réinitialiser tous les réseaux",
"action:reset-all-srs": "Réinitialiser tous les SRs",
"action:reset-to-default": "Rétablir la valeur par défaut",
@@ -148,12 +161,15 @@
"action:see-all": "Voir tout",
"action:see-less": "Voir moins",
"action:see-n-more": "Voir {n} de plus",
"action:see-task": "Voir la tâche",
"action:select-all": "Tout sélectionner",
"action:select-all-n-objects": "sélectionner {n} objet | sélectionner les {n} objets",
"action:select-n-objects-of": "{from} - {to} de {total} objets",
"action:select-network": "Sélectionner un réseau",
"action:select-none": "Tout désélectionner",
"action:select-pool": "Sélectionner un pool",
"action:select-storage": "Sélectionner un SR",
"action:select-template": "Sélectionner un modèle",
"action:send": "Envoyer",
"action:send-ctrl-alt-del": "Envoyer Ctrl+Alt+Suppr",
"action:set-pif-management": "Définir comme PIF de gestion",
@@ -182,6 +198,8 @@
"action:update": "Mettre à jour",
"action:use-query-builder": "Constructeur de requête",
"action:visit-pool-dashboard": "Visiter le tableau de bord du pool",
"active-mapping": "Correspondance active",
"adapter": "Carte",
"add-or": "+OU",
"admin-login": "Nom d'utilisateur administrateur",
"admin-password": "Mot de passe administrateur",
@@ -205,6 +223,7 @@
"all-done!": "Tout est terminé !",
"all-good!": "Tout va bien !",
"all-quiet-launchpad": "Tout est calme sur la rampe de lancement",
"all-vms": "Toutes les VMs",
"all-srs-shared-storage-migration-optional": "Tous les SRs sont partagés, la migration du stockage est facultative.",
"all-vifs-follow-vm": "Toutes les VIFs suivent la VM vers son nouvel hôte : au sein du pool, leurs réseaux ne changent pas.",
"allocated-space": "Espace alloué",
@@ -213,6 +232,7 @@
"allow-unauthorized": "Autoriser les connexions non vérifiées",
"allowed-ips": "IPs autorisées",
"allowed-ips-example": "Par exemple : 192.168.0.1; 2001:db8::1",
"also-on": "Aussi sur",
"and": "Et",
"any-property": "N'importe quelle propriété",
"api-error-details": "Détails de l'erreur API",
@@ -299,6 +319,7 @@
"character-limit": "{count}/{max} caractère | {count}/{max} caractères",
"check-errors:": "Consultez les erreurs :",
"check-summing": "TX checksumming",
"checks": "Vérifications",
"click-test-br-speed": "Cliquez pour tester la vitesse du BR",
"click-to-display-alarms:": "Cliquer pour afficher les alarmes :",
"click-to-return-default-pool": "Cliquer ici pour revenir au pool par défaut",
@@ -370,11 +391,18 @@
"dark-mode:disable": "Désactiver le mode sombre",
"dark-mode:enable": "Activer le mode sombre",
"dashboard": "Tableau de bord",
"datastore": "Banque de données",
"datastores": "Banques de données",
"date": "Date",
"default-behavior": "Comportement par défaut",
"default-locking-mode": "Mode de verrouillage par défaut",
"default-network": "Réseau par défaut",
"default-options": "Options par défaut",
"default-storage-repository": "Dépôt de stockage par défaut",
"default-template": "Modèle par défaut",
"default-value": "Par défaut ({value})",
"deletion-blocked": "Suppression Bloquée",
"delta-transfer": "Transfert delta",
"deploy-xoa-available-on-desktop": "Le déploiement de la XOA est disponible sur ordinateur",
"deploy-xoa-custom-ntp-servers": "Serveurs NTP (séparés par des espaces)",
"deploy-xoa-status:configuring": "Configuration de la XOA…",
@@ -385,10 +413,14 @@
"deploy-xoa-status:waiting": "En attente de réponse de la XOA…",
"descending": "descendant",
"description": "Description",
"destination": "Destination",
"destination-host": "Hôte de destination",
"destination-network": "Réseau de destination",
"destination-network-required": "Le réseau de destination est requis.",
"destination-pool": "Pool de destination",
"destination-pool-named": "Pool de destination {name}",
"destination-sr": "SR de destination",
"destination-vm": "VM de destination",
"destination-sr-required": "Le SR de destination est requis.",
"details": "Détails",
"device": "Appareil",
@@ -408,8 +440,10 @@
"disconnected-from-physical-device": "Déconnecté de l'appareil physique",
"disconnected-pbd-number": "PBD déconnecté #{n}",
"disconnecting": "Déconnexion",
"disk": "Disque",
"disk-name": "Nom du disque",
"disk-space": "Espace disque",
"disks": "Disques",
"dns": "DNS",
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites-le-nous savoir",
"do-you-really-want-to-leave-this-page?": "Des modifications sont encore en cours de déploiement. Souhaitez-vous vraiment quitter cette page ?",
@@ -483,10 +517,13 @@
"from-vm": "Depuis la VM",
"full": "Complet",
"full-copy": "Copie complète",
"full-transfer": "Transfert complet",
"fullscreen": "Plein écran",
"gateway": "Passerelle",
"general": "Général",
"general-information": "Informations générales",
"global-default": "Défaut global",
"global-defaults-for-name": "Valeurs par défaut globales pour {name}",
"gpu": "GPU",
"gpus": "GPUs",
"graphics-display": "Graphique & Affichage",
@@ -513,6 +550,7 @@
"host-not-running": "Hôte non démarré",
"host-off": "L’hôte est éteint",
"host-or-ip-address": "Hôte ou adresse IP",
"host-required": "L'hôte est requis.",
"host:status:active": "@:status:active",
"host:status:disabled": "@:status:disabled",
"host:status:halted": "@:status:halted",
@@ -524,8 +562,10 @@
"https": "HTTPS",
"hyper-threading": "Hyper-threading (SMT)",
"id": "ID",
"import-in-progress": "Import en cours",
"in": "entrant sur",
"in-last-three-runs": "Dans leurs trois derniers runs",
"in-list": "Dans la liste",
"in-progress": "En cours",
"included": "Inclus",
"incremental": "Incrémental",
@@ -687,6 +727,8 @@
"job:sr-delete:missing-sr": "Aucun SR à supprimer",
"job:sr-disconnect:missing-sr": "Aucun SR à déconnecter",
"job:traffic-rule-delete:missing-rule": "Aucune règle de trafic sélectionnée",
"job:v2v-start:in-progress": "Lancement de la migration en cours…",
"job:v2v-start:missing-vm": "Aucune VM à migrer",
"job:vbd-connect:missing-vbd": "Aucun VBD à connecter",
"job:vbd-connect:vbd-already-connected": "Le VBD est déjà connecté",
"job:vbd-delete:missing-vbd": "Aucun VBD à supprimer",
@@ -769,10 +811,12 @@
"json-schema:numbered-item": "{label} {n}",
"json-schema:show-examples-n": "Afficher l'exemple ({n}) | Afficher les exemples ({n})",
"json-schema:unsupported-field": "Le champ « {label} » ne peut pas être modifié depuis cette interface",
"keep-mac-addresses": "Conserver les adresses MAC",
"keep-me-logged": "Rester connecté",
"keep-page-open": "Ne pas rafraichir ou quitter cette page avant la fin du déploiement.",
"key": "Clé",
"keywords": "Mots-clés",
"label-with-count": "{label} {count}",
"language": "Langue",
"language-preferences": "Choix de la langue",
"last": "Dernier",
@@ -783,6 +827,7 @@
"last-revert": "Dernière restauration",
"last-run-number": "Dernier run #{n}",
"last-snapshot": "Dernier instantané",
"last-sync": "Dernière synchronisation",
"last-week": "Semaine dernière",
"ldap-auth-providers": "LDAP/Fournisseurs d'authentification",
"learn-more": "En savoir plus",
@@ -797,6 +842,7 @@
"loaded": "Chargé",
"loading": "Chargement",
"local": "Local",
"location": "Emplacement",
"locked": "Verrouillé",
"locking-mode": "Mode de verrouillage",
"locking-mode-default": "Mode de verrouillage par défaut",
@@ -815,6 +861,7 @@
"management-ip": "IP principale",
"manual": "Manuel",
"manufacturer-info": "Informations sur le fabricant",
"mapping": "Correspondance",
"master": "Hôte primaire",
"maximum-dynamic-memory": "Mémoire dynamique maximale",
"maximum-static-memory": "Mémoire statique maximale",
@@ -823,6 +870,8 @@
"memory-usage": "Utilisation de la mémoire",
"merge-backups-synchronously": "Fusionner les sauvegardes de manière synchrone",
"message": "Message",
"migrate-vms": "Migrer des VMs",
"migration": "Migration",
"migration-blocked": "Migration bloquée",
"migration-compression": "Compression des migrations",
"migration-network": "Réseau de migration",
@@ -883,19 +932,28 @@
"multi-pathing": "Multi-pathing",
"multiple-schedules": "Plusieurs planifications",
"multiple-schedules-advice": "Nous vous conseillons de diviser un job de sauvegarde à plusieurs planifications en plusieurs jobs ayant chacun une seule planification.",
"n-blocking": "0 bloquante | 1 bloquante | {n} bloquantes",
"n-bonded-networks": "1 réseau lié | {n} réseaux liés",
"n-brs": "1 BR | {n} BRs",
"n-disks": "0 disque | 1 disque | {n} disques",
"n-gb": "{n} Go",
"n-gb-left": "{n} Go libres",
"n-gb-required": "{n} Go requis",
"n-hexadecimal-characters": "{n} caractères hexadécimaux.",
"n-hosts": "0 hôte | 1 hôte | {n} hôtes",
"n-hosts-awaiting-patch": "{n} hôte attend ce patch | {n} hôtes attendent ce patch",
"n-in-progress": "0 en cours | 1 en cours | {n} en cours",
"n-internal-networks": "1 réseau interne | {n} réseaux internes",
"n-migrated": "0 migrée | 1 migrée | {n} migrées",
"n-missing": "{n} manquant | {n} manquants",
"n-need-confirmation": "0 à confirmer | 1 à confirmer | {n} à confirmer",
"n-networks": "0 réseau | 1 réseau | {n} réseaux",
"n-nics": "0 carte réseau | 1 carte réseau | {n} cartes réseau",
"n-not-migrated": "0 non migrée | 1 non migrée | {n} non migrées",
"n-partially-migrated": "0 partiellement migrée | 1 partiellement migrée | {n} partiellement migrées",
"n-percent": "{n} %",
"n-pifs": "1 PIF | {n} PIFs",
"n-ready": "0 prête | 1 prête | {n} prêtes",
"n-snapshots": "0 instantané | 1 instantané | {n} instantanés",
"n-srs": "0 SR | 1 SR | {n} SRs",
"n-subtasks": "0 sous-tâche | 1 sous-tâche | {n} sous-tâches",
@@ -905,6 +963,8 @@
"n-vdis": "0 VDI | 1 VDI | {n} VDIs",
"n-vifs": "0 VIF | 1 VIF | {n} VIFs",
"n-vms": "0 VM | 1 VM | {n} VMs",
"n-vms-in-list": "0 VM dans la liste | 1 VM dans la liste | {n} VMs dans la liste",
"n-warnings": "0 avertissement | 1 avertissement | {n} avertissements",
"name": "Nom",
"name-required": "Le nom est requis.",
"native": "Native",
@@ -915,6 +975,7 @@
"nested-virtualization": "Virtualisation imbriquée",
"netmask": "Masque réseau",
"network": "Réseau",
"network-adapters": "Cartes réseau",
"network-block-device": "Protocole NBD (Network Block Device)",
"network-config": "Configuration réseau",
"network-create:warning:mtu-out-of-range": "Le MTU doit être compris entre {min} et {max}",
@@ -992,6 +1053,7 @@
"no-pif-detected": "Aucun PIF détecté",
"no-plugin-detected": "Aucun plugin détecté",
"no-pools-detected": "Aucun pool détecté",
"no-remembered-connection": "Aucune connexion mémorisée",
"no-replicated-vm": "Cette VM n'est pas répliquée. Configurez une tâche de réplication pour l'activer.",
"no-result": "Aucun résultat",
"no-role-applied": "Pas de rôle appliqué",
@@ -1015,8 +1077,11 @@
"normal": "Normal",
"not-found": "Non trouvé",
"not-included": "Non inclus",
"not-migrated": "Non migrée",
"not-ready": "Non prête",
"not-resident-on": "Non résident sur",
"not-running": "Pas en cours d'exécution",
"not-supported-yet": "Pas encore pris en charge",
"not-yet-available": "Information pas encore disponible",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
@@ -1048,6 +1113,8 @@
"operation:vm-clean-shutdown": "Arrêt des VMs",
"operation:vm-hard-shutdown": "Arrêt forcé des VMs",
"operation:vm-suspend": "Suspension des VMs",
"option-off": "Désactivé",
"option-on": "Activé",
"optional": "Facultatif",
"options": "Options",
"or": "Ou",
@@ -1056,6 +1123,7 @@
"other": "Autre",
"other-properties": "Autres propriétés",
"ova": "OVA",
"overridden": "Personnalisé",
"page-not-found": "Cette page est introuvable…",
"page-please-wait": "Accrochez-vous, astronaute ! La page est sur le point de décoller.{newline}Nous alignons les propulseurs et calibrons les systèmes. Plus que quelques instants, restez à l'écoute !",
"pagination:all": "Tous",
@@ -1075,6 +1143,7 @@
"pause-blocked": "Mise en pause bloquée",
"pbd-details": "Détails du périphérique de bloc physique (PBD)",
"pending": "En cours",
"per-vm-options": "Options par VM",
"physical-interface-status": "Statut de l'interface physique",
"pif": "PIF",
"pif-status": "Statut du PIF",
@@ -1107,7 +1176,9 @@
"pool-forget-confirm-info": "Cela supprimera définitivement cet hôte de XO. Pour vous reconnecter, vous devrez l'ajouter à nouveau.",
"pool-forget-confirm-title": "Vous êtes sur le point de supprimer l'hôte {name}",
"pool-management": "Gestion du Pool",
"pool-named": "Pool {name}",
"pool-ram-usage": "Utilisation RAM du Pool",
"pool-required": "Le pool est requis.",
"pool:status:connected": "@:status:connected",
"pool:status:disconnected": "@:status:disconnected",
"pool:status:unreachable": "@:status:unreachable",
@@ -1121,12 +1192,15 @@
"popup-first-connection-introduction": "Nous sommes ravis de vous présenter la première version officielle de Xen Orchestra 6.",
"popup-first-connection-upcoming-release": "Dans les prochaines versions, nous ajouterons des actions sur tous les objets, la gestion des utilisateurs, des fonctionnalités de sauvegarde et bien plus encore. Nous espérons que vous apprécierez découvrir cette toute nouvelle interface !",
"port": "Port",
"port-group": "Groupe de ports",
"port-invalid": "Le format du port est invalide.",
"port-required": "Le port est requis.",
"power-on-host-for-console": "Allumez votre hôte pour accéder à sa console",
"power-on-mode": "Mode d'alimentation",
"power-on-vm-for-console": "Allumez votre VM pour accéder à sa console",
"power-state": "État d'alimentation",
"power-state-on-host": "{power} sur {host}",
"preview": "Aperçu",
"pro-support": "Support pro {name}",
"professional-support": "Support professionnel",
"progress": "Progression",
@@ -1180,6 +1254,7 @@
"read": "Lecture",
"read-only": "Lecture seule",
"reading-speed": "Vitesse de lecture",
"ready": "Prête",
"reboot-blocked": "Redémarrage bloqué",
"reboot-vm-internal-shutdown": "Redémarrer la VM lors d'un arrêt interne",
"receive": "Recevoir",
@@ -1193,6 +1268,9 @@
"relative-time:past": "Il y a {str}",
"relative-time:second": "1 seconde | {n} secondes",
"relative-time:year": "1 an | {n} ans",
"remember-connection-in-browser": "Mémoriser la connexion dans ce navigateur",
"remember-credentials-warning": "Stocker ces identifiants dans votre navigateur augmente les risques de sécurité. Assurez-vous que cela respecte vos règles de sécurité.",
"remembered-connections": "Connexions mémorisées",
"remote-syslog": "Syslog distant",
"report-recipients": "Destinataires du rapport",
"report-when": "Rapporter quand",
@@ -1205,8 +1283,11 @@
"resources": "Ressources",
"resources-overview": "Vue d'ensemble des ressources",
"rest-api": "API REST",
"resume": "Reprise",
"retry": "Réessayer",
"review": "Revue",
"review-and-confirm": "Vérifier et confirmer",
"review-and-launch": "Revue et lancement",
"roles": "Rôles",
"roles-applied": "Rôles appliqués",
"root-by-default": "\"root\" par défaut.",
@@ -1230,7 +1311,9 @@
"select-pool": "Sélectionnez un pool",
"select-preset": "Sélectionner une configuration prédéfinie",
"select-to-see-details": "Cliquez sur {icon} pour voir les détails",
"selected-vms": "VMs sélectionnées",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"selected-vms-n": "VMs sélectionnées ({n})",
"self-signed-certificates": "Certificats auto-signés",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
@@ -1241,6 +1324,9 @@
"size": "Taille",
"size-and-speed": "@:size et @.lower:speed",
"size-on-disk": "Taille sur le disque",
"size-on-this-datastore": "Taille sur cette banque de données",
"skip-ssl-check": "Ignorer la vérification SSL (certificat auto-signé)",
"skip-ssl-check-info": "Le certificat du serveur n'est pas vérifié. À utiliser pour les certificats auto-signés.",
"skipped": "Ignoré",
"smart-mode": "Mode intelligent",
"smb": "SMB",
@@ -1259,6 +1345,7 @@
"software": "Logiciel",
"software-tooling": "Logiciels et outils",
"source": "Source",
"source-to-destination": "{source} → {destination}",
"space": "Espace",
"speed": "Vitesse",
"speed-limit": "Limite de vitesse",
@@ -1283,6 +1370,7 @@
"ssh-login": "Nom d'utilisateur SSH",
"ssh-password": "Mot de passe SSH",
"ssh-password-confirm": "Confirmer le mot de passe SSH",
"ssl-check-skipped": "Vérification SSL ignorée",
"stacked-cpu-usage": "Utilisation CPU empilée",
"stacked-ram-usage": "Utilisation RAM empilée",
"start-console": "Démarrez votre {type} pour accéder à la console et reprendre les opérations !",
@@ -1309,6 +1397,7 @@
"status:unknown": "Inconnu",
"status:unreachable": "Injoignable | Injoignable | Injoignables",
"step-n-of": "Étape {current} / {total}",
"stop-on-error": "Arrêter en cas d’erreur",
"storage": "Stockage",
"storage-configuration": "Configuration du stockage",
"storage-format": "Format de stockage",
@@ -1316,10 +1405,12 @@
"storage-repositories": "Dépôts de stockage",
"storage-repository": "Dépôt de stockage",
"storage-usage": "Utilisation du stockage",
"stored-in-browser-only": "Uniquement dans ce navigateur",
"subfolder": "Sous-dossier",
"success": "Succès",
"summary": "Résumé",
"support": "Support",
"supported": "Prise en charge",
"supported-image-formats": "Formats d'image supportés",
"suspend-blocked": "Mise en veille bloquée",
"suspend-storage-repository": "Espace de stockage en attente",
@@ -1335,6 +1426,8 @@
"table-selection:watch-out!": "Attention !",
"tags": "Tags",
"target": "Cible",
"target-network": "Réseau cible",
"target-sr": "SR cible",
"task": "Tâche",
"task-type": "Type de tâche",
"task:ended": "Terminée",
@@ -1347,6 +1440,7 @@
"tasks:overview": "Vue d'ensemble des tâches",
"template": "Modèle",
"template-has-bios-strings": "Le modèle contient déjà les BIOS strings",
"template-required": "Le modèle est requis.",
"test-data": "Données de test",
"theme": "Thème",
"theme-auto": "Auto",
@@ -1422,6 +1516,7 @@
"unnamed": "Sans nom",
"unreachable-hosts": "Hôtes inaccessibles",
"unreachable-hosts-reload-page": "C'est fait. Rafraîchir la page",
"unsupported": "Non prise en charge",
"untitled": "Sans titre",
"untranslated-text-helper": "Par défaut, les textes non traduits seront affichés en anglais.",
"up-to-date": "À jour",
@@ -1429,6 +1524,7 @@
"use-https": "Utiliser HTTPS",
"used": "Utilisé",
"used-for-backup": "Utilisé pour la sauvegarde",
"used-relative-time": "dernière utilisation {time}",
"used-space": "Espace utilisé",
"used-space-on-sr": "Espace utilisé sur le SR",
"user": "Utilisateur",
@@ -1438,7 +1534,72 @@
"username-required": "Le nom d'utilisateur est requis.",
"users": "Utilisateurs",
"uuid": "UUID",
"v2v": "V2V",
"v2v:action:add-to-migration-list": "Ajouter à la liste de migration",
"v2v:already-fully-migrated": "Déjà entièrement migrées",
"v2v:already-migrated-confirm-at-launch": "Déjà migrée, confirmation au lancement",
"v2v:check:mapping-incomplete": "Correspondance incomplète",
"v2v:check:n-snapshots": "0 instantané sur la VM source | 1 instantané sur la VM source | {n} instantanés sur la VM source",
"v2v:check:no-snapshot": "Aucun instantané sur la VM source",
"v2v:check:no-unsupported-disk": "Aucun disque non pris en charge (RDM, indépendant, chiffré)",
"v2v:choose-vmware-source": "Choisir la source VMware",
"v2v:clean-up-preview": "Nettoyage (aperçu)",
"v2v:clean-up-vm": "Nettoyer automatiquement la VM",
"v2v:connect-new-vmware-source": "Connecter une nouvelle source VMware",
"v2v:continues-on-pool-sr": "continue sur le pool {pool}, SR {srs}",
"v2v:create-second-copy": "Créer une seconde copie de {name}",
"v2v:credentials-never-stored": "Les identifiants ne sont jamais stockés par XO : ils restent dans ce navigateur et sont envoyés avec chaque requête qui nécessite VMware. L'état des migrations est partagé par tous ceux qui se connectent au même vCenter.",
"v2v:cutover-done-on": "Bascule effectuée le {date}.",
"v2v:default-options-info": "S’appliquent à chaque VM, sauf si elles sont remplacées ci-dessous",
"v2v:delta-from-last-sync": "Delta depuis la dernière synchronisation",
"v2v:destination-pool-info": "Une migration va d’une source vers un pool. Chaque couple source et pool a sa propre liste.",
"v2v:existing-list-info": "Ce navigateur contient déjà une liste de migration pour {migration} ({vms}). Continuer la rouvre, avec l’état de migration actuel de chaque VM.",
"v2v:filter-vms-info": "filtrer par banque de données, statut ou nom avec le constructeur de requête",
"v2v:global-defaults-info": "Appliquées à chaque VM, disque et carte réseau. Chaque VM peut les remplacer à l’étape 4 (Correspondance). Le SR et le réseau sont préremplis avec le SR par défaut du pool et le réseau de management du maître du pool.",
"v2v:launch-group:excluded": "Exclues",
"v2v:launch-group:new": "Nouvelles",
"v2v:launch-group:resumed": "Reprises",
"v2v:launch-problems": "La migration ne peut pas être lancée",
"v2v:launch-success": "La migration est en cours : sa tâche indique sa progression.",
"v2v:list-pair-info": "Une source et une destination par liste : choisir une autre source ou un autre pool ouvre sa propre liste.",
"v2v:locked-since-first-sync": "Verrouillée depuis la première synchronisation",
"v2v:mac-kept": "MAC conservée",
"v2v:mapping-locked": "correspondance verrouillée",
"v2v:migrated-vm-details": "VM existante : {vm} sur le pool {pool}, SR {srs}. Une seconde VM sera créée sur le pool {destinationPool}.",
"v2v:n-excluded": "0 exclue | 1 exclue | {n} exclues",
"v2v:n-vms-launched": "0 VM lancée | 1 VM lancée | {n} VMs lancées",
"v2v:name-from-migration-list": "{name} de la liste de migration",
"v2v:next-run-reminder": "Plus la dernière synchronisation est ancienne, plus le delta final est important. Lancez une synchronisation delta peu avant la bascule pour réduire l’interruption.",
"v2v:no-bak-info": "Exclue des sauvegardes : {tag} (retiré à la bascule)",
"v2v:no-vm-in-list": "Aucune VM dans la liste de migration",
"v2v:not-found-in-source": "Introuvable dans la source",
"v2v:partial-import-on-pool": "Import partiel sur le pool {pool}",
"v2v:per-vm-options-info": "« Par défaut » suit les options par défaut ci-dessus",
"v2v:prerequisite-checking": "Vérification des prérequis : vérification de vectura…",
"v2v:prerequisite-error": "Vérification des prérequis : vectura ne peut pas être utilisé, aucune VM ne peut être migrée tant que ce n'est pas corrigé",
"v2v:prerequisite-error-detail": "Vérification des prérequis : vectura ne peut pas être utilisé ({error}), aucune VM ne peut être migrée tant que ce n'est pas corrigé",
"v2v:prerequisite-ok": "Vérification des prérequis : vectura OK",
"v2v:remove-from-list-info": "Seule la liste de migration change : rien n’est supprimé sur la source ni dans XO.",
"v2v:saved-in-this-browser": "Enregistrée dans ce navigateur",
"v2v:sent-to-xo-at-launch": "envoyée à XO au lancement",
"v2v:source-snapshot-changed": "Le snapshot source a changé depuis la dernière synchronisation",
"v2v:start-blocked-partial": "Démarrage bloqué tant que l’import est partiel",
"v2v:start-on-xcp-ng": "Démarrer sur XCP-ng",
"v2v:start-vm-on-xcp-ng": "Démarrer la VM sur XCP-ng",
"v2v:status:cold": "Bascule effectuée",
"v2v:status:warm": "Synchronisée à chaud",
"v2v:stop-on-error-info": "S’arrêter à la première erreur lors de l’import des VMs. S’applique à tout le lancement.",
"v2v:stop-source": "Arrêt de la source",
"v2v:stop-source-vm": "Arrêter la VM source",
"v2v:stop-source-vm-info": "VM source arrêtée avant le dernier transfert delta (après le snapshot final). Nécessaire pour transférer entièrement une VM en cours d’exécution.",
"v2v:tags-backup-info": "Utilisez les tags pour retrouver la VM dans les jobs de sauvegarde en mode intelligent. Jusqu’à la bascule, la VM porte aussi {tag}, pour que les jobs de sauvegarde l’ignorent ; ce tag est retiré à la bascule.",
"v2v:tags-mac-not-applied": "Les tags et l’option Conserver les adresses MAC de l’étape Correspondance ne sont pas encore appliqués.",
"v2v:unsupported-encrypted-disk": "Non prise en charge : disque chiffré",
"v2v:unsupported-independent-disk": "Non prise en charge : disque indépendant",
"v2v:unsupported-rdm-disk": "Non prise en charge : disque RDM",
"v2v:vm-start-problem": "{vm} : {message}",
"value-by-default": "{value} par défaut",
"value-from": "Valeur issue de",
"vcpu": "vCPU",
"vcpu-at-startup": "vCPU au démarrage",
"vcpus": "vCPUs",
@@ -1487,6 +1648,7 @@
"virtualization-mode": "Mode de virtualisation",
"virtualization-type": "Type de virtualisation",
"vlan": "VLAN",
"vlan-id": "VLAN {id}",
"vlan-required": "Le VLAN est requis.",
"vm": "VM",
"vm-backup-failure-number-of-retries": "Nombre de tentatives en cas d'échec de la sauvegarde de la VM",
@@ -1527,6 +1689,7 @@
"vms-status": "Statut des VMs",
"vms-tags": "Tags des VMs",
"vms-treeview": "Arborescence des VMs",
"vmware": "VMware",
"vtpm": "VTPM",
"warning": "Avertissement",
"warning:vdi-already-attached": "Le VDI sélectionné est déjà attaché à cette VM.",

View File

@@ -8,6 +8,7 @@ const COLUMN_WIDTH = '4rem'
export type CheckboxCellBindings = {
selected: boolean
disabled?: boolean
onToggle: () => void
}
@@ -21,9 +22,10 @@ function renderHeadCheckbox(model: Ref<boolean | undefined>) {
})
}
function renderBodyCheckbox({ selected, onToggle }: CheckboxCellBindings) {
function renderBodyCheckbox({ selected, disabled, onToggle }: CheckboxCellBindings) {
return h(UiCheckbox, {
accent: 'brand',
disabled,
modelValue: selected,
'onUpdate:modelValue': onToggle,
})

View File

@@ -0,0 +1,6 @@
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import { toArray } from '@core/utils/to-array.utils.ts'
export function mergeInputWrapperMessages(...messages: (InputWrapperMessage | undefined)[]): InputWrapperMessage {
return messages.flatMap(message => toArray(message))
}

View File

@@ -1 +1,5 @@
VITE_XO_REST_HOST=
# Serve the V2V REST routes from dev/v2v-mock until the back end provides them
VITE_XO_V2V_MOCK=
# Any text makes the mocked vectura prerequisite check fail with it
VITE_XO_V2V_MOCK_VECTURA_ERROR=

View File

@@ -0,0 +1,156 @@
import {
V2V_DISK_MODE,
V2V_FIRMWARE,
V2V_VM_POWER_STATE,
type XoV2vDatacenter,
type XoV2vDatastore,
type XoV2vDisk,
type XoV2vHost,
type XoV2vInventory,
type XoV2vNic,
type XoV2vPortGroup,
type XoV2vVm,
} from '@vates/types'
import {
createV2vDatastore,
createV2vDisk,
createV2vInventory,
createV2vNic,
createV2vVm,
} from '../../src/test/create-v2v-inventory.ts'
const VCENTER_VM_COUNT = 148
const ESXI_VM_COUNT = 9
const SNAPSHOTS_VM_INDEX = 4
// Warm/cold come from real XO VMs, the inventory cannot mock them: to see `app-008` resumed, turn an XO VM of the
// destination pool with 2 VDIs and 2 VIFs into its partial import (`sourceId` is `<host>:<port>` for an ESXi
// source), one VDI paired on a snapshot delta, the other on the base disk:
// xe vm-param-set uuid=<vm> other-config:xo:v2v:sourceId=mock-vcenter-instance-uuid other-config:sourceVmId=vm-107 \
// other-config:sourceSnapshotId=2 blocked-operations:start='Esxi migration in progress...'
// xe vdi-param-set uuid=<1st vdi> other-config:esxi_diskPath=app-008/app-008-000001.vmdk
// xe vdi-param-set uuid=<2nd vdi> other-config:esxi_diskPath=app-008/app-008_1.vmdk
const WARM_VM_INDEX = 7
// One VM per disk case the VM selection step tells apart: each unsupported disk, and disks on two datastores. The
// last three are past the ESXi VMs, so only the vCenter shows them
const RDM_VM_INDEX = 5
const TWO_DATASTORES_VM_INDEX = 10
const INDEPENDENT_DISK_VM_INDEX = 11
const ENCRYPTED_DISK_VM_INDEX = 12
const DATACENTER_ID = 'datacenter-1' as XoV2vDatacenter['id']
const HOST_ID = 'host-1' as XoV2vHost['id']
const PORT_GROUP_ID = 'network-1' as XoV2vPortGroup['id']
const DATASTORES = [
{ id: 'datastore-11', name: 'ds-prod-01', capacity: 4 * 1024 ** 4, free: 1.1 * 1024 ** 4 },
{ id: 'datastore-12', name: 'ds-prod-02', capacity: 4 * 1024 ** 4, free: 1.1 * 1024 ** 4 },
{ id: 'datastore-13', name: 'ds-local-esx03', capacity: 1024 ** 4, free: 512 * 1024 ** 3 },
].map(({ id, ...datastore }) =>
createV2vDatastore({ ...datastore, id: id as XoV2vDatastore['id'], hostIds: [HOST_ID] })
)
const SNAPSHOT_COUNTS: Record<number, number> = { [SNAPSHOTS_VM_INDEX]: 1, [WARM_VM_INDEX]: 2 }
// VMware names the deltas of `dir/disk.vmdk` `dir/disk-000001.vmdk`, `dir/disk-000002.vmdk`… one per snapshot
function createChainPaths(directory: string, fileName: string, snapshotCount: number): string[] {
const deltaPaths = Array.from(
{ length: snapshotCount },
(_, deltaIndex) => `${directory}/${fileName}-${String(deltaIndex + 1).padStart(6, '0')}.vmdk`
)
return [`${directory}/${fileName}.vmdk`, ...deltaPaths]
}
function createDisks(index: number, name: string, snapshotCount: number): XoV2vDisk[] {
const datastoreId = DATASTORES[index % DATASTORES.length].id
const disk = createV2vDisk({
datastoreId,
capacity: (40 + (index % 5) * 20) * 1024 ** 3,
mode: index === INDEPENDENT_DISK_VM_INDEX ? V2V_DISK_MODE.INDEPENDENT_PERSISTENT : V2V_DISK_MODE.PERSISTENT,
isRdm: index === RDM_VM_INDEX,
isEncrypted: index === ENCRYPTED_DISK_VM_INDEX,
chainPaths: createChainPaths(name, name, snapshotCount),
})
const hasSecondDisk = index === TWO_DATASTORES_VM_INDEX || index === WARM_VM_INDEX
if (!hasSecondDisk) {
return [disk]
}
const secondDatastoreId =
index === TWO_DATASTORES_VM_INDEX ? DATASTORES[(index + 1) % DATASTORES.length].id : datastoreId
return [
disk,
createV2vDisk({
key: 'scsi0:1' as XoV2vDisk['key'],
label: 'Hard disk 2',
datastoreId: secondDatastoreId,
capacity: 200 * 1024 ** 3,
chainPaths: createChainPaths(name, `${name}_1`, snapshotCount),
}),
]
}
function createNics(index: number): XoV2vNic[] {
const nicCount = index === WARM_VM_INDEX ? 2 : 1
return Array.from({ length: nicCount }, (_, nicIndex) =>
createV2vNic({
key: `ethernet${nicIndex}` as XoV2vNic['key'],
label: `Network adapter ${nicIndex + 1}`,
portGroupId: PORT_GROUP_ID,
macAddress: `00:50:56:9a:0${nicIndex}:${index.toString(16).padStart(2, '0')}`,
})
)
}
// Varied on purpose: the list must show every power state, firmware and size combination
function createVm(index: number): XoV2vVm {
const name = `app-${String(index + 1).padStart(3, '0')}`
const snapshotCount = SNAPSHOT_COUNTS[index] ?? 0
const disks = createDisks(index, name, snapshotCount)
return createV2vVm({
id: `vm-${index + 100}` as XoV2vVm['id'],
instanceUuid: `vm-instance-${index + 100}`,
name,
powerState: index % 3 === 0 ? V2V_VM_POWER_STATE.POWERED_OFF : V2V_VM_POWER_STATE.POWERED_ON,
hostId: HOST_ID,
nCpus: 2 + (index % 4),
memory: (4 + (index % 4) * 4) * 1024 ** 3,
firmware: index % 2 === 0 ? V2V_FIRMWARE.UEFI : V2V_FIRMWARE.BIOS,
datastoreIds: [...new Set(disks.map(disk => disk.datastoreId))],
snapshotCount,
// The vmsd numbers snapshots from 1, the last taken is the current one
currentSnapshotId: snapshotCount > 0 ? String(snapshotCount) : undefined,
disks,
nics: createNics(index),
})
}
export function createMockInventory(host: string): XoV2vInventory {
const isVcenter = host.includes('vcenter')
return createV2vInventory({
product: isVcenter ? 'VMware vCenter Server' : 'VMware ESXi',
version: isVcenter ? '8.0.2' : '7.0.3',
instanceUuid: isVcenter ? 'mock-vcenter-instance-uuid' : undefined,
datacenters: [{ id: DATACENTER_ID, name: 'DC-Paris' }],
hosts: [{ id: HOST_ID, name: 'esx01.lab.local', datacenterId: DATACENTER_ID }],
datastores: DATASTORES,
portGroups: [{ id: PORT_GROUP_ID, name: 'PG-Prod', vlan: 10 }],
vms: Array.from({ length: isVcenter ? VCENTER_VM_COUNT : ESXI_VM_COUNT }, (_, index) => createVm(index)),
})
}

View File

@@ -0,0 +1,168 @@
import type { XoV2vInventoryPayload, XoV2vStartPayload, XoV2vStatus, XoV2vVm } from '@vates/types'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Plugin } from 'vite'
import { createMockInventory } from './fixtures.ts'
type V2vMockOptions = {
enabled: boolean
vecturaError?: string
}
function sendJson(response: ServerResponse, status: number, body: unknown) {
response.statusCode = status
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(body))
}
async function readJson<T>(request: IncomingMessage): Promise<T> {
const chunks: Buffer[] = []
for await (const chunk of request) {
chunks.push(chunk as Buffer)
}
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T
}
async function handleInventory(request: IncomingMessage, response: ServerResponse) {
const { connection } = await readJson<XoV2vInventoryPayload>(request)
if (connection.password === 'bad') {
sendJson(response, 422, { error: 'invalid credentials', data: { code: 'V2V_INVALID_CREDENTIALS' } })
return
}
if (connection.host === 'unreachable.lab') {
sendJson(response, 500, { error: `${connection.host}:${connection.port} is unreachable` })
return
}
// A real inventory takes a while: let the "connecting" state show
await new Promise(resolve => setTimeout(resolve, 800))
sendJson(response, 200, createMockInventory(connection.host))
}
type V2vStartProblem = { vm?: XoV2vVm['id']; field?: string; message: string }
const START_VM_UNSUPPORTED = 'starting the VM after its import is not supported yet'
const CLEANUP_UNSUPPORTED = 'cleaning up the VM is not supported yet'
// Mirrors `planMigration` of the REST API (`@xen-orchestra/rest-api/src/v2v/v2v.plan.mts`): every problem at once
function planMockMigration({ connection, defaults, vms }: XoV2vStartPayload): V2vStartProblem[] {
const problems: V2vStartProblem[] = []
const sourceVms = new Map(createMockInventory(connection.host).vms.map(vm => [vm.id, vm]))
const seenVmIds = new Set<XoV2vVm['id']>()
if (defaults.startVm) {
problems.push({ field: 'defaults.startVm', message: START_VM_UNSUPPORTED })
}
if (defaults.cleanup) {
problems.push({ field: 'defaults.cleanup', message: CLEANUP_UNSUPPORTED })
}
for (const vm of vms) {
const report = (message: string, field?: string) => problems.push({ vm: vm.id, field, message })
if (seenVmIds.has(vm.id)) {
report('the VM is listed more than once')
continue
}
seenVmIds.add(vm.id)
const sourceVm = sourceVms.get(vm.id)
if (sourceVm === undefined) {
report('no such VM on the source')
continue
}
if (vm.startVm) {
report(START_VM_UNSUPPORTED, 'startVm')
}
if (vm.cleanup) {
report(CLEANUP_UNSUPPORTED, 'cleanup')
}
if (vm.tags.length > 0) {
report('setting tags is not supported yet', 'tags')
}
if (!vm.keepMac) {
report('the MAC addresses are always kept for now', 'keepMac')
}
for (const disk of sourceVm.disks) {
if (disk.isRdm) {
report(`the disk ${disk.key} is a raw device mapping, which cannot be imported`, 'disks')
}
if (disk.isEncrypted) {
report(`the disk ${disk.key} is encrypted, which cannot be imported`, 'disks')
}
}
}
return problems
}
async function handleStart(request: IncomingMessage, response: ServerResponse) {
const problems = planMockMigration(await readJson<XoV2vStartPayload>(request))
if (problems.length > 0) {
// What the REST API error handler answers for `invalidParameters`
sendJson(response, 422, { error: 'the migration cannot be started', data: { errors: problems } })
return
}
// The task does not exist in XO: its link leads to an empty task panel
sendJson(response, 202, { taskId: `v2v-mock-task-${Date.now()}` })
}
const START_PATHNAME = /^\/rest\/v0\/pools\/[^/]+\/actions\/import_vms_from_vmware$/
// Stands in for the V2V REST routes until the back end ships them (dev server only, never built)
export function v2vMockPlugin({ enabled, vecturaError }: V2vMockOptions): Plugin {
return {
name: 'xo-v2v-mock',
apply: (_config, { command }) => enabled && command === 'serve',
configureServer(server) {
server.middlewares.use((request, response, next) => {
const { pathname } = new URL(request.url ?? '/', 'http://localhost')
if (pathname === '/rest/v0/v2v/status' && request.method === 'GET') {
const status: XoV2vStatus = {
vectura: vecturaError ? { status: 'error', error: vecturaError } : { status: 'success' },
}
sendJson(response, 200, status)
return
}
if (pathname === '/rest/v0/v2v/inventory' && request.method === 'POST') {
handleInventory(request, response).catch(next)
return
}
if (START_PATHNAME.test(pathname) && request.method === 'POST') {
handleStart(request, response).catch(next)
return
}
next()
})
},
}
}

View File

@@ -14,6 +14,10 @@ A fixed priority, not a preference:
Testing a page is a different job from testing a component, and the component is the more valuable one. A component should not carry much complex logic — when it does, extract that into a composable or utility and test it there.
### Only the app's own behaviour
An XO 6 test covers what the app decides, never what `@xen-orchestra/web-core` does on its own: a side panel opening on a selection, a store re-expanding on a resize, a select opening its options all belong to the web-core tests. A web-core component may be rendered beside the code under test when the app's rule depends on it, but the assertion must be about that rule: a test that still passes once the app's code is removed is testing web-core and is deleted, not written. A state the app sets on a web-core component (`busy`, `disabled`, `selected`, a table `state`…) is asserted through that component's props (`getUiButton(wrapper, label).props('busy')`), never through the DOM it renders for it; a user action clicks the real `<button>` (`getButton(wrapper, label)`). A composable member that only forwards to a web-core store gets no test of its own. Selects: see [Testing components](./tests/testing-components.md#selects).
## Getting started
- [Assert behaviour, not implementation](./tests/assert-behaviour.md) — the one rule that outranks the others

File diff suppressed because one or more lines are too long

View File

@@ -38,7 +38,7 @@ Those types are declared in `tsconfig.vitest.json`, not in `tsconfig.app.json`,
The two projects split on the file name:
| Project | Covers | Test globals |
| ---------------------- | ----------------------------------------------------- | ------------ |
| `tsconfig.app.json` | everything **except** `*.unit.test.ts` and `setup.ts` | no |
| `tsconfig.vitest.json` | everything, tests included | yes |
| Project | Covers | Test globals |
| ---------------------- | ------------------------------------------------------ | ------------ |
| `tsconfig.app.json` | everything **except** `*.unit.test.ts` and `src/test/` | no |
| `tsconfig.vitest.json` | everything, tests included | yes |

View File

@@ -22,6 +22,35 @@ beforeEach(() => {
Note the cast: the typed form checks the factory return against the **whole** module, so a partial mock of a large export needs `as unknown as typeof useX` (with an `import type { useX }`).
When several test files mock the same remote resource (a collection or a single resource) with the same shape, the mock lives once in `src/test/` and the test file only points `vi.mock` to it; its exported state is read and changed by the tests, and reset in `beforeEach`:
```typescript
vi.mock(
import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'),
() => import('@/test/mock-xo-vm-collection.ts')
)
beforeEach(() => {
vmCollectionMock.reset()
})
```
`vmCollectionMock.areVmsReady` is true after a reset; set it to false to hold the collection as still loading.
Collections always read together share one mock module exporting each of their composables: `mock-xo-collections.ts` stands for the pool, host, SR, network, PBD, PIF and VM template collections. Each `vi.mock` of these modules points to it (`vi.mock` is hoisted, it cannot be wrapped in a helper), and `xoCollectionsMock.reset()` resets them all. Its pool collection also answers `getPoolById`, `hasPoolById` and `useGetPoolById` from `pools`, its host collection `getHostById` from `hosts`, its SR and network collections `getSrById` and `getNetworkById`, its PBD collection `pbdsBySr` from `pbds`, and `arePoolsReady`, `areSrsReady`, `areNetworksReady` and `areTemplatesReady` (true after a reset) let a test hold that collection as still loading. `seedV2vListPool` (`seed-v2v-list-pool.ts`) seeds the pool of a V2V list and what it offers.
The same goes for `mock-xo-v2v-vm-rows-collections.ts`, the VM, task and VM snapshot collections the V2V VM rows are built from: `v2vVmRowsCollectionsMock` holds `vms`, `tasks`, `snapshots` and their `are…Ready` flags (true after a reset; set one to false to hold that collection as still loading).
`mock-delete-modal.ts` stands for `useDeleteModal`, whose overlay a unit test cannot answer: `deleteModalMock.answer('onConfirm' | 'onCancel')` sets how the next confirmation is answered, `deleteModalMock.confirm()` runs the `onConfirm` event of the last confirmation for a caller that passes its events instead of awaiting the answer, `deleteModalMock.open` is the spy to read the props it was opened with, and `deleteModalMock.reset()` goes in `beforeEach`.
`mock-overlay.ts` stands for `useOverlay`, whose overlays a unit test cannot open: each call is recorded in order, as the options it was given (its `component`, its `events`) and the `open` spy it got back; `overlayMock.getOverlay<TOptions>(index = 0)` / `overlayMock.getOverlays<TOptions>()` read them back, `TOptions` typing the events for the payload they take, and `overlayMock.reset()` goes in `beforeEach`.
`mock-v2v-vm-actions.ts` stands for `use-v2v-vm-actions.composable.ts`, whose composables open the settings drawer or delete an import VM: each `v2vVmActionsMock.useV2v*VmActions` spy returns fresh fake actions of its kind (read them back from `mock.results`), and `v2vVmActionsMock.reset()` goes in `beforeEach`.
`mock-xo-vm-device-collections.ts` stands for the VBD, VDI and VIF collections: `vmDeviceCollectionsMock` holds `vbds`, `vdis`, `vifs` and their `are…Ready` flags (true after a reset), and its collections answer `getVbdsByIds` and `getVifsByIds`.
`mock-xo-routes.ts` stands for `useXoRoutes`, the XO 5 routes the app fetches: an XO 5 link reads `https://xo5.example.com/#<path>`.
## Network
`src/test/setup.ts` stubs the global `fetch` with a never-settling promise, so composables that trigger remote requests on use never hit the network in unit tests. Override it locally with `vi.stubGlobal('fetch', …)` when a test needs a real response.
`src/test/setup.ts` stubs the global `fetch` with a never-settling promise before each test, so composables that trigger remote requests on use never hit the network in unit tests. When a test needs a real response, `stubFetchResponse(status, body)` from `src/test/stub-fetch-response.ts` overrides it for that test only and returns the mock, to assert the request when the request is the contract (a job). `stubFetchServerError()` answers a 500 the server explains with `SERVER_ERROR_MESSAGE`; `stubV2vStartAccepted()` (the V2V start route answering `V2V_START_TASK_ID`) and `stubV2vStartProblems(errors)` (refusing the launch for those problems) live in `create-v2v-launch.ts`.

View File

@@ -16,17 +16,21 @@ it('is true while a state-changing operation is pending', () => {
})
```
Two cases legitimately stay inline: a **reactivity** test, which needs a `ref` it can reassign, and a test that must call `useI18n()` _inside_ the `setup` callback to build its expected value (see [Testing composables](./testing-composables.md)).
A helper returning `wrapper.vm` serves a **reactivity** test too, as long as it accepts the source as a `ref` (`MaybeRefOrGetter`): `wrapper.vm` keeps following the updates. Only a test that must call a function returned by the composable, or build its expected value, _inside_ the `setup` callback stays inline (see [Testing composables](./testing-composables.md)).
Because mocks are read lazily, calling `getX.mockReturnValue(...)` in the test body _before_ invoking the helper still takes effect.
This applies to **navigation into the result** too, not only to mounting. An expression such as `result.filterableServers[0]`, repeated across a `describe` block, is repeated setup — give it a helper and assert on a named local, rather than re-deriving it in every test.
The exception is a **reactivity** test: it must keep the live `wrapper` so it can re-read through `wrapper.vm` after reassigning the source ref. A helper returning a snapshot of the first item would defeat the test.
The exception is a **reactivity** test: it must keep reading through the mounted `vm` after reassigning the source ref. A helper returning a snapshot of the first item would defeat the test.
## Unmounting
`src/test/setup.ts` calls `enableAutoUnmount(afterEach)`, so every wrapper — `mount` or `mountComposable` — is unmounted after the test that created it. Never unmount by hand, and never mount in `beforeAll`: that wrapper would be torn down after the first test.
`src/test/setup.ts` calls `enableAutoUnmount(afterEach)`, so every wrapper — `mount` or `mountComposable` — is unmounted after the test that created it. Never unmount by hand for cleanup, and never mount in `beforeAll`: that wrapper would be torn down after the first test. The one `wrapper.unmount()` allowed is the act of a test asserting what `onUnmounted` does.
## Local storage
`src/test/setup.ts` clears `localStorage` before each test, so every test starts with an empty storage. Never clear it again in a test file: store what a test needs in its own setup (e.g. `seedV2vMigrationList`).
## Shared helpers: `use<X>EnhancedData`

View File

@@ -1,6 +1,6 @@
# Testing components
Components run in the same `unit` project as everything else: `@vue/test-utils` `mount` on `happy-dom`, **no browser needed**. Mount with `createGlobalTestConfig()` so the real `vue-i18n` instance and a fresh Pinia are installed, and give the repeated mount a helper (see [Setup](./setup-helpers.md)):
Components run in the same `unit` project as everything else: `@vue/test-utils` `mount` on `happy-dom`, **no browser needed**. Mount with `createGlobalTestConfig()` so the real `vue-i18n` instance, the app form validation messages (`RegleVuePlugin` with `formValidationConfig`, as in `main.ts`) and a fresh Pinia are installed, and give the repeated mount a helper (see [Setup](./setup-helpers.md)):
```typescript
import VmSystemGraphics from '@/modules/vm/components/system/VmSystemGraphics.vue'
@@ -61,6 +61,8 @@ expect(labelledValues).toEqual({ [t('vga')]: t('disabled'), [t('video-ram')]: '8
Prefer this shape: one assertion covering every row a user sees, and it fails loudly when a value lands under the wrong label. Assert against **class names and semantic elements**, never a positional chain of child indexes.
The class must exist for the component's own CSS: **never add a class only for a test**, and remove one whose CSS is gone. Without a styled class, query the component (`getComponent`), a styled class of a child component, or the text the user reads.
`src/test/find-rendered-values.ts` does exactly that reduction, so a card test does not re-roll it:
| Helper | Reads |
@@ -131,6 +133,25 @@ Where the line falls:
| data reaching the component (a name, an id, an IP, a tag) | the literal — it is the test's own input |
| a number the component formats (`'8 B'`, `'4 GiB'`, `'2'`) | the literal — deriving it would copy the template |
## Tables
A table is read **by header label**, never by column position: `src/test/find-table-cells.ts` looks the label up in `thead th`, so a column added, moved or hidden does not shift the assertions.
| Helper | Reads |
| -------------------------------------------------------- | --------------------------------------------------------------------- |
| `findTableHeaders(wrapper)` | the header labels, in order (`''` for a checkbox column) |
| `findTableColumn(wrapper, label)` | the text of each cell of that column, one per body row |
| `findTableColumnCells(wrapper, label)` | the cells of that column, to query a link, a tag or a checkbox inside |
| `findTableRows(wrapper)`, from `find-rendered-values.ts` | the cell texts of each body row, for a table read row by row |
```typescript
expect(findTableHeaders(wrapper)).toEqual(['', t('vm'), t('size')])
expect(findTableColumn(wrapper, t('size'))).toEqual(['80GiB', '120GiB'])
expect(findTableColumnCells(wrapper, t('vm'))[0].getComponent(UiLink).attributes('href')).toBe('/vm/vm-42/dashboard')
```
A label missing from the header throws, naming the label, rather than reading an empty column.
## Chart cards
A dashboard chart card loads its `VtsLinearChart` asynchronously, and the real chart cannot mount here: ECharts needs a canvas and a `ResizeObserver`. `src/test/linear-chart-stub.ts` stands in for it, with the props declared so they stay readable. `mountChartCard` from `src/test/mount-chart-card.ts` wires that stub in and defaults `loading` to `false`, so a card test only names its component and its stats type:
@@ -225,6 +246,13 @@ expect(
Pushing before mounting is what makes the active tab assertable; without a push the router sits at its start location and nothing is active.
## Selects
How `FormSelect` and `VtsSelect` render and open their options belongs to the web-core tests, never to an XO 6 one:
- A component that only passes select bindings on is mounted with `createGlobalTestConfig({ stubs: { FormSelect: true } })`, given `createSelectBindings`, and read with `findSelectStubLabels`.
- The options a form builds (their labels, their custom properties) are read by mounting the form with `mountWithSelect`, rendering a `VtsSelect` for its select, then `openSelect` and `findSelectOptionLabels`; `findShownSelections` reads the selection each select shows.
## What `happy-dom` cannot do
There is no layout engine, and Vitest's `css` option is off, so component styles are never loaded. Out of reach:

View File

@@ -1,23 +1,23 @@
# Testing composables
A composable that relies on a Vue context (`useI18n`, `inject`, an effect scope, plugins) must run **inside a mounted component**. Use the `mountComposable` helper from `src/test/mount-composable.ts`, which mounts a throwaway component with the real `vue-i18n` instance and a fresh Pinia:
A composable that relies on a Vue context (`useI18n`, `inject`, an effect scope, plugins) must run **inside a mounted component**. Use the helpers from `src/test/mount-composable.ts`, which mount a throwaway component with `createGlobalTestConfig()`: the real `vue-i18n` instance, the app form validation messages, a fresh Pinia and a router.
Because the real i18n instance is installed, translations resolve to actual strings, and the assertion builds its expected label with `t` instead of hard-coding the wording — see [Assert behaviour](./assert-behaviour.md). `mountComposableWithI18n` mounts the composable, spreads its result, adds the `t` of the mounted component and returns `wrapper.vm`:
```typescript
import { mountComposable } from '@/test/mount-composable.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
const result = mountComposableWithI18n(() => useXoVmUtils(createVm({ power_state: VM_POWER_STATE.RUNNING })))
return { ...useXoVmUtils(createVm({ power_state: VM_POWER_STATE.RUNNING })), t }
})
expect(wrapper.vm.powerState.icon).toBe('status:running-circle')
expect(wrapper.vm.powerState.text).toBe(wrapper.vm.t('vm:status:running'))
expect(result.powerState.icon).toBe('status:running-circle')
expect(result.powerState.text).toBe(result.t('vm:status:running'))
```
The composable result becomes the component state, so `wrapper.vm` exposes it with refs **already unwrapped** — no `.value`. Read through `wrapper.vm` rather than destructuring it: a destructured value is a snapshot and stops following updates.
`mountComposable` does the same without `t` and returns `{ wrapper }`, for a test that needs neither the translation nor a returned object spread.
Because the real i18n instance is installed, translations resolve to actual strings. Return `t` from the `setup` callback so the assertion can build its expected label with it instead of hard-coding the wording — see [Assert behaviour](./assert-behaviour.md).
Both take the options of `createGlobalTestConfig()` as a second argument, e.g. `{ router }` for a composable that navigates (build it with `createTestRouter()` and assert on `router.currentRoute.value`), or `{ provide }` for a composable injecting what a parent component provides.
The composable result becomes the component state, so `wrapper.vm` exposes it with refs **already unwrapped** — no `.value`. Read through it rather than destructuring it: a destructured value is a snapshot and stops following updates.
The wrapper is unmounted after the test, so `onUnmounted` and `onScopeDispose` cleanup runs and no watcher of a previous test can re-fire in the next one.

View File

@@ -57,6 +57,7 @@
"vite": "^8.0.0",
"vitest": "^4.1.9",
"vue": "~3.5.13",
"vue-component-type-helpers": "~3.3.7",
"vue-i18n": "^11.1.2",
"vue-router": "5.0.6",
"vue-tsc": "^3.0.0",

View File

@@ -0,0 +1,37 @@
import AdministrationMenu from '@/modules/admin/components/AdministrationMenu.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { type DOMWrapper, mount } from '@vue/test-utils'
function mountMenu() {
return mount(AdministrationMenu, { global: createGlobalTestConfig() })
}
function findSections(wrapper: ReturnType<typeof mountMenu>) {
return wrapper.findAll('.menu-section')
}
function findSectionTitle(section: DOMWrapper<Element>) {
return section.get('.section-title').text()
}
function findSection(wrapper: ReturnType<typeof mountMenu>, title: string) {
return findSections(wrapper).find(section => findSectionTitle(section) === title)
}
it('lists the V2V migration between backups and user management', () => {
expect(findSections(mountMenu()).map(findSectionTitle)).toEqual([
t('backup-and-replication'),
t('v2v'),
t('user-management'),
t('settings'),
])
})
it('links the V2V section to the migration wizard', () => {
const section = findSection(mountMenu(), t('v2v'))
expect(section?.findAll('a').map(link => [link.text(), link.attributes('href')])).toEqual([
[t('migrate-vms'), '/admin/v2v/migrate'],
])
})

View File

@@ -58,6 +58,10 @@ const sections = computed<AdminMenuSection[]>(() => [
{ label: t('logs'), href: undefined, to: { name: '/admin/backup-and-replication/logs' } },
],
},
{
title: t('v2v'),
items: [{ label: t('migrate-vms'), href: undefined, to: { name: '/admin/v2v/migrate' } }],
},
{
title: t('user-management'),
items: [

View File

@@ -1,7 +1,6 @@
import BackupJobsTable from '@/modules/backup-job/components/list/BackupJobsTable.vue'
import type { FrontAnyXoBackupJob } from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
import type { useXoBackupLogCollection } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createMetadataBackupJob, createMirrorBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { createBackupLog } from '@/test/create-backup-log.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
@@ -21,11 +20,7 @@ const { areBackupLogsReady, getLastNBackupLogsByJobId } = vi.hoisted(() => ({
getLastNBackupLogsByJobId: vi.fn(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'), () => ({
useXoBackupLogCollection: (() => ({

View File

@@ -21,7 +21,6 @@ import type {
} from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import type { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createMetadataBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { createBackupLog } from '@/test/create-backup-log.ts'
import { createSchedule } from '@/test/create-schedule.ts'
@@ -36,11 +35,7 @@ const { schedulesByJobId, backupLogsByJobId } = vi.hoisted(() => ({
backupLogsByJobId: new Map<string, FrontXoBackupLog[]>(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'), async () => {
const { computed: vueComputed, ref: vueRef } = await import('vue')

View File

@@ -4,7 +4,6 @@ import type {
FrontXoSchedule,
useXoScheduleCollection,
} from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createBackupLog, createBackupLogTask } from '@/test/create-backup-log.ts'
import { createSchedule } from '@/test/create-schedule.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
@@ -20,11 +19,7 @@ const { schedules } = vi.hoisted(() => ({
schedules: [] as FrontXoSchedule[],
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'), () => ({
useXoScheduleCollection: (() => ({

View File

@@ -6,7 +6,6 @@ import type {
FrontXoSr,
useXoSrCollection,
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createMetadataBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { createSr } from '@/test/create-sr.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
@@ -24,11 +23,7 @@ const { srs } = vi.hoisted(() => ({
srs: [] as FrontXoSr[],
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'), () => ({
useXoSrCollection: (() => ({

View File

@@ -2,7 +2,6 @@ import BackupJobRunItem from '@/modules/backup-job/components/list/panel/card-it
import BackupJobLogsCard from '@/modules/backup-job/components/list/panel/cards/BackupJobLogsCard.vue'
import type { FrontXoBackupLog } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
import type { useXoScheduleCollection } from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createBackupLog } from '@/test/create-backup-log.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
@@ -10,11 +9,7 @@ import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTi
import { mount } from '@vue/test-utils'
import { computed } from 'vue'
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'), () => ({
useXoScheduleCollection: (() => ({

View File

@@ -1,7 +1,6 @@
import BackupJobSchedulesCard from '@/modules/backup-job/components/list/panel/cards/BackupJobSchedulesCard.vue'
import type { FrontXoSchedule } from '@/modules/schedule/remote-resources/use-xo-schedule-collection.ts'
import { getScheduleNextRun } from '@/modules/schedule/utils/xo-schedule.util.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createSchedule } from '@/test/create-schedule.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
@@ -12,11 +11,7 @@ import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTi
import { formatDateTime } from '@core/utils/time.util.ts'
import { mount } from '@vue/test-utils'
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -5,9 +5,9 @@ import type { useXoHostCollection } from '@/modules/host/remote-resources/use-xo
import type { useXoPbdCollection } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
import type { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createBackupLog, createBackupLogVmTask } from '@/test/create-backup-log.ts'
import { createVm } from '@/test/create-vm.ts'
import { findTableColumnCells } from '@/test/find-table-cells.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import UiLink from '@core/components/ui/link/UiLink.vue'
@@ -20,11 +20,7 @@ const { getBackupJobById, getVmById } = vi.hoisted(() => ({
getVmById: vi.fn(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({
buildXo5Route: (path: string) => `https://xo5.example.com/#${path}`,
})) as unknown as typeof useXoRoutes,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => import('@/test/mock-xo-routes.ts'))
vi.mock(import('@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'), () => ({
useXoBackupJobCollection: (() => ({ getBackupJobById })) as unknown as typeof useXoBackupJobCollection,
@@ -73,9 +69,7 @@ function mountTable(backupLog: FrontXoBackupLog) {
}
function findVmCell(wrapper: ReturnType<typeof mountTable>) {
const headers = wrapper.findAll('thead th').map(header => header.text())
return wrapper.findAll('tbody tr td')[headers.indexOf(t('vm'))]
return findTableColumnCells(wrapper, t('vm'))[0]
}
describe('VM', () => {

View File

@@ -1,10 +1,10 @@
import BulkEditBackupRepositoriesDrawer from '@/modules/backup-repository/components/drawer/BulkEditBackupRepositoriesDrawer.vue'
import BackupRepositoryFormCheckbox from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormCheckbox.vue'
import BackupRepositoryDetailsStep from '@/modules/backup-repository/components/form/steps/BackupRepositoryDetailsStep.vue'
import BackupRepositoryGeneralStep from '@/modules/backup-repository/components/form/steps/BackupRepositoryGeneralStep.vue'
import type { BulkEditBackupRepositoryPayloads } from '@/modules/backup-repository/jobs/xo-backup-repository-bulk-edit.job.ts'
import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote-resources/use-xo-backup-repository-collection.ts'
import type { useXoProxyCollection } from '@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import { createBr } from '@/test/create-br.ts'
import { submitDrawer } from '@/test/drawer.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
@@ -72,7 +72,7 @@ it('shows the general information without the encryption', async () => {
const wrapper = await mountDrawer()
expect(wrapper.findComponent(BackupRepositoryGeneralStep).exists()).toBe(true)
expect(wrapper.findComponent(BackupRepositoryFormCheckbox).exists()).toBe(false)
expect(wrapper.findComponent(FormCheckbox).exists()).toBe(false)
})
it('shows the details of the repositories when they are of the same type', async () => {

View File

@@ -49,7 +49,8 @@ const emit = defineEmits<{
const { t } = useI18n()
const { general, details, detailsTitle, hasMixedTypes, validateAndBuildPayloads } = useBulkEditBackupRepositoriesForm(brs)
const { general, details, detailsTitle, hasMixedTypes, validateAndBuildPayloads } =
useBulkEditBackupRepositoriesForm(brs)
async function handleConfirm() {
const payloads = await validateAndBuildPayloads()

View File

@@ -1,30 +1,30 @@
<template>
<div class="backup-repository-azure-fields">
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.hostName" />
<BackupRepositoryFormCheckbox v-if="isAzurite" v-bind="bindings.useHttps" />
<FormTextInput v-bind="bindings.hostName" />
<FormCheckbox v-if="isAzurite" v-bind="bindings.useHttps" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.accountName" />
<BackupRepositoryFormTextInput v-bind="bindings.key" />
<FormTextInput v-bind="bindings.accountName" />
<FormTextInput v-bind="bindings.key" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.containerName" />
<FormTextInput v-bind="bindings.containerName" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow wide>
<BackupRepositoryFormTextInput v-bind="bindings.pathInContainer" />
<FormTextInput v-bind="bindings.pathInContainer" />
</BackupRepositoryFormRow>
</div>
</template>
<script lang="ts" setup>
import BackupRepositoryFormRow from '@/modules/backup-repository/components/form/BackupRepositoryFormRow.vue'
import BackupRepositoryFormCheckbox from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormCheckbox.vue'
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import type { AzureBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-azure-backup-repository-details-form.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
defineProps<{
bindings: AzureBackupRepositoryDetailsForm['bindings']

View File

@@ -1,13 +1,13 @@
<template>
<BackupRepositoryFormRow wide>
<BackupRepositoryFormTextInput v-bind="bindings.path" />
<FormTextInput v-bind="bindings.path" />
</BackupRepositoryFormRow>
</template>
<script lang="ts" setup>
import BackupRepositoryFormRow from '@/modules/backup-repository/components/form/BackupRepositoryFormRow.vue'
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import type { LocalBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-local-backup-repository-details-form.ts'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
defineProps<{
bindings: LocalBackupRepositoryDetailsForm['bindings']

View File

@@ -1,12 +1,12 @@
<template>
<div class="backup-repository-nfs-fields">
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.host" />
<BackupRepositoryFormTextInput v-bind="bindings.port" />
<FormTextInput v-bind="bindings.host" />
<FormTextInput v-bind="bindings.port" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow wide>
<BackupRepositoryFormTextInput v-bind="bindings.path" />
<FormTextInput v-bind="bindings.path" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow wide>
@@ -18,8 +18,8 @@
<script lang="ts" setup>
import BackupRepositoryFormRow from '@/modules/backup-repository/components/form/BackupRepositoryFormRow.vue'
import BackupRepositoryFormTextArea from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextArea.vue'
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import type { NfsBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-nfs-backup-repository-details-form.ts'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
defineProps<{ bindings: NfsBackupRepositoryDetailsForm['bindings'] }>()
</script>

View File

@@ -1,37 +1,37 @@
<template>
<div class="backup-repository-s3-fields">
<div class="endpoint-row">
<BackupRepositoryFormTextInput v-bind="bindings.endpoint" class="endpoint" />
<FormTextInput v-bind="bindings.endpoint" class="endpoint" />
<div class="checkboxes">
<BackupRepositoryFormCheckbox v-bind="bindings.useHttps" />
<BackupRepositoryFormCheckbox v-if="bindings.useHttps.modelValue" v-bind="bindings.allowUnauthorized" />
<FormCheckbox v-bind="bindings.useHttps" />
<FormCheckbox v-if="bindings.useHttps.modelValue" v-bind="bindings.allowUnauthorized" />
</div>
</div>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.region" />
<FormTextInput v-bind="bindings.region" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.accessKeyId" />
<BackupRepositoryFormTextInput v-bind="bindings.secret" />
<FormTextInput v-bind="bindings.accessKeyId" />
<FormTextInput v-bind="bindings.secret" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.bucket" />
<FormTextInput v-bind="bindings.bucket" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow wide>
<BackupRepositoryFormTextInput v-bind="bindings.pathInBucket" />
<FormTextInput v-bind="bindings.pathInBucket" />
</BackupRepositoryFormRow>
</div>
</template>
<script lang="ts" setup>
import BackupRepositoryFormRow from '@/modules/backup-repository/components/form/BackupRepositoryFormRow.vue'
import BackupRepositoryFormCheckbox from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormCheckbox.vue'
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import type { S3BackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-s3-backup-repository-details-form.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
defineProps<{ bindings: S3BackupRepositoryDetailsForm['bindings'] }>()
</script>

View File

@@ -5,17 +5,17 @@
</UiAlert>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.pathOnShare" />
<BackupRepositoryFormTextInput v-bind="bindings.subfolder" />
<FormTextInput v-bind="bindings.pathOnShare" />
<FormTextInput v-bind="bindings.subfolder" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.username" />
<BackupRepositoryFormTextInput v-bind="bindings.password" />
<FormTextInput v-bind="bindings.username" />
<FormTextInput v-bind="bindings.password" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.domain" />
<FormTextInput v-bind="bindings.domain" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow wide>
@@ -27,8 +27,8 @@
<script lang="ts" setup>
import BackupRepositoryFormRow from '@/modules/backup-repository/components/form/BackupRepositoryFormRow.vue'
import BackupRepositoryFormTextArea from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextArea.vue'
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import type { SmbBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-smb-backup-repository-details-form.ts'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import { useI18n } from 'vue-i18n'

View File

@@ -1,62 +0,0 @@
import BackupRepositoryFormCheckbox from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormCheckbox.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { mount, type VueWrapper } from '@vue/test-utils'
type CheckboxProps = InstanceType<typeof BackupRepositoryFormCheckbox>['$props']
function mountCheckbox(props: Partial<CheckboxProps> = {}) {
return mount(BackupRepositoryFormCheckbox, {
props: { label: 'Encrypted', modelValue: false, ...props },
global: createGlobalTestConfig(),
})
}
function findWarnings(wrapper: VueWrapper) {
return wrapper.findAll('.ui-info').map(warning => warning.text())
}
it('shows its label', () => {
expect(mountCheckbox().get('.checkbox-label').text()).toBe('Encrypted')
})
it('shows no warning when none is given', () => {
expect(findWarnings(mountCheckbox())).toEqual([])
})
it.each<[string, CheckboxProps['warning']]>([
['a text', 'The data cannot be recovered'],
['a message', { content: 'The data cannot be recovered', accent: 'warning' }],
])('shows the warning given as %s', (_, warning) => {
expect(findWarnings(mountCheckbox({ warning }))).toEqual(['The data cannot be recovered'])
})
it('shows every warning when several are given', () => {
const warning = ['The data cannot be recovered', 'Another warning']
expect(findWarnings(mountCheckbox({ warning }))).toEqual(warning)
})
it('is checked according to its model', () => {
const isChecked = (modelValue: boolean) =>
mountCheckbox({ modelValue }).get<HTMLInputElement>('input[type="checkbox"]').element.checked
expect({ whenFalse: isChecked(false), whenTrue: isChecked(true) }).toEqual({ whenFalse: false, whenTrue: true })
})
it('is indeterminate when its model is undefined, as for a value which differs between edited repositories', () => {
expect(mountCheckbox({ modelValue: undefined }).find('input.indeterminate').exists()).toBe(true)
})
it('updates its model when it is checked', async () => {
const wrapper = mountCheckbox()
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(wrapper.emitted('update:modelValue')).toEqual([[true]])
})
it('cannot be checked when it is disabled', () => {
const wrapper = mountCheckbox({ disabled: true })
expect(wrapper.get('input[type="checkbox"]').attributes()).toHaveProperty('disabled')
})

View File

@@ -1,67 +0,0 @@
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { mount, type VueWrapper } from '@vue/test-utils'
type TextInputProps = InstanceType<typeof BackupRepositoryFormTextInput>['$props']
function mountTextInput(props: Partial<TextInputProps> = {}) {
return mount(BackupRepositoryFormTextInput, {
props: { label: 'Host', modelValue: '', ...props },
global: createGlobalTestConfig(),
})
}
function findMessages(wrapper: VueWrapper) {
return wrapper.findAll('.ui-info').map(message => message.text())
}
it('shows its label', () => {
expect(mountTextInput().get('.ui-label').text()).toContain('Host')
})
it('shows no message without info nor error', () => {
expect(findMessages(mountTextInput())).toEqual([])
})
it('shows the info as its message', () => {
const wrapper = mountTextInput({ info: 'Example: 192.168.1.10' })
expect(findMessages(wrapper)).toEqual(['Example: 192.168.1.10'])
})
it('shows the error instead of the info', () => {
const wrapper = mountTextInput({
info: 'Example: 192.168.1.10',
error: { content: 'This field is required', accent: 'danger' },
})
expect(findMessages(wrapper)).toEqual(['This field is required'])
})
it('shows the prefix ahead of the value', () => {
expect(mountTextInput({ prefix: '\\\\' }).get('.prefix').text()).toBe('\\\\')
})
it('disables the input when it is disabled', () => {
expect(mountTextInput({ disabled: true }).get('input').attributes('disabled')).toBeDefined()
})
it('enables the input by default', () => {
expect(mountTextInput().get('input').attributes('disabled')).toBeUndefined()
})
it('updates its model with the trimmed value', async () => {
const wrapper = mountTextInput()
await wrapper.get('input').setValue(' 192.168.1.10 ')
expect(wrapper.emitted('update:modelValue')).toEqual([['192.168.1.10']])
})
it('reports that the input lost the focus', async () => {
const wrapper = mountTextInput()
await wrapper.get('input').trigger('blur')
expect(wrapper.emitted('blur')).toHaveLength(1)
})

View File

@@ -1,13 +1,13 @@
import BackupRepositoryFormRow from '@/modules/backup-repository/components/form/BackupRepositoryFormRow.vue'
import BackupRepositoryFormCheckbox from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormCheckbox.vue'
import BackupRepositoryFormSelect from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormSelect.vue'
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import BackupRepositoryGeneralStep from '@/modules/backup-repository/components/form/steps/BackupRepositoryGeneralStep.vue'
import {
type BackupRepositoryGeneralFormData,
useBackupRepositoryGeneralForm,
} from '@/modules/backup-repository/form/use-backup-repository-general-form.ts'
import type { useXoProxyCollection } from '@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
@@ -40,10 +40,7 @@ function mountGeneralStep(formData: Partial<BackupRepositoryGeneralFormData> = {
return { wrapper, data }
}
function findLabels(
wrapper: VueWrapper,
component: typeof BackupRepositoryFormSelect | typeof BackupRepositoryFormTextInput
) {
function findLabels(wrapper: VueWrapper, component: typeof BackupRepositoryFormSelect | typeof FormTextInput) {
return wrapper.findAllComponents(component).map(input => input.props('label'))
}
@@ -61,13 +58,13 @@ it('hides the options section, and its encryption, when asked to', () => {
const { wrapper } = mountGeneralStep({}, { hideEncryption: true })
expect(findSectionTitles(wrapper)).toEqual([t('general-information')])
expect(wrapper.findComponent(BackupRepositoryFormCheckbox).exists()).toBe(false)
expect(wrapper.findComponent(FormCheckbox).exists()).toBe(false)
})
it('offers the name, type, backup format and proxy fields', () => {
const { wrapper } = mountGeneralStep()
expect(findLabels(wrapper, BackupRepositoryFormTextInput)).toEqual([t('name')])
expect(findLabels(wrapper, FormTextInput)).toEqual([t('name')])
expect(findLabels(wrapper, BackupRepositoryFormSelect)).toEqual([t('type'), t('backup-format'), t('proxy')])
})
@@ -80,8 +77,8 @@ it('warns that the encryption is available for the block based format only', ()
it('hides the encryption key until the encryption is enabled', () => {
const { wrapper } = mountGeneralStep({ type: 's3', backupFormat: 'block' })
expect(wrapper.findComponent(BackupRepositoryFormCheckbox).exists()).toBe(true)
expect(findLabels(wrapper, BackupRepositoryFormTextInput)).not.toContain(t('key'))
expect(wrapper.findComponent(FormCheckbox).exists()).toBe(true)
expect(findLabels(wrapper, FormTextInput)).not.toContain(t('key'))
})
it('shows the encryption key on the same row as the encryption, once enabled', async () => {
@@ -92,9 +89,9 @@ it('shows the encryption key on the same row as the encryption, once enabled', a
const encryptionRow = wrapper
.findAllComponents(BackupRepositoryFormRow)
.find(row => row.findComponent(BackupRepositoryFormCheckbox).exists())
.find(row => row.findComponent(FormCheckbox).exists())
expect(findLabels(encryptionRow!, BackupRepositoryFormTextInput)).toEqual([t('key')])
expect(findLabels(encryptionRow!, FormTextInput)).toEqual([t('key')])
})
it('describes each backup format with its hint', async () => {

View File

@@ -4,7 +4,7 @@
<UiTitle>{{ t('general-information') }}</UiTitle>
<BackupRepositoryFormRow>
<BackupRepositoryFormTextInput v-bind="bindings.name" />
<FormTextInput v-bind="bindings.name" />
</BackupRepositoryFormRow>
<BackupRepositoryFormRow>
@@ -36,8 +36,8 @@
</UiAlert>
<BackupRepositoryFormRow>
<BackupRepositoryFormCheckbox v-bind="bindings.encrypted" />
<BackupRepositoryFormTextInput v-if="bindings.encrypted.modelValue" v-bind="bindings.encryptionKey" />
<FormCheckbox v-bind="bindings.encrypted" />
<FormTextInput v-if="bindings.encrypted.modelValue" v-bind="bindings.encryptionKey" />
</BackupRepositoryFormRow>
</div>
</div>
@@ -45,10 +45,10 @@
<script lang="ts" setup>
import BackupRepositoryFormRow from '@/modules/backup-repository/components/form/BackupRepositoryFormRow.vue'
import BackupRepositoryFormCheckbox from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormCheckbox.vue'
import BackupRepositoryFormSelect from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormSelect.vue'
import BackupRepositoryFormTextInput from '@/modules/backup-repository/components/form/inputs/BackupRepositoryFormTextInput.vue'
import type { BackupRepositoryGeneralForm } from '@/modules/backup-repository/form/use-backup-repository-general-form.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
import VtsOption from '@core/components/select/VtsOption.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'

View File

@@ -51,7 +51,10 @@
</template>
<script lang="ts" setup>
import { MASKED_SECRET, splitBackupRepositoryPath } from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import {
MASKED_SECRET,
splitBackupRepositoryPath,
} from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'

View File

@@ -65,7 +65,10 @@
</template>
<script lang="ts" setup>
import { MASKED_SECRET, splitBackupRepositoryPath } from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import {
MASKED_SECRET,
splitBackupRepositoryPath,
} from '@/modules/backup-repository/utils/xo-backup-repository.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'

View File

@@ -4,9 +4,9 @@ import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote
import { createBr } from '@/test/create-br.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
import { t } from '@/test/i18n.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import type { useRouteQuery } from '@core/composables/route-query.composable.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { type MaybeRefOrGetter, ref } from 'vue'
import type { Router } from 'vue-router'
@@ -16,8 +16,7 @@ type ForgetResults = PromiseSettledResult<void>[]
type MockedOverlay = { open: ReturnType<typeof vi.fn>; events: OverlayEvents }
const { overlays, run, selectedBrId } = vi.hoisted(() => ({
overlays: [] as MockedOverlay[],
const { run, selectedBrId } = vi.hoisted(() => ({
run: vi.fn(),
selectedBrId: { value: '' },
}))
@@ -36,21 +35,13 @@ vi.mock(import('@core/composables/route-query.composable.ts'), () => ({
useRouteQuery: (() => selectedBrId) as unknown as typeof useRouteQuery,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: (({ events }: Pick<MockedOverlay, 'events'>) => {
const overlay = { open: vi.fn(), events }
overlays.push(overlay)
return { open: overlay.open }
}) as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
let successHandled: Promise<void> | undefined
beforeEach(() => {
vi.restoreAllMocks()
overlays.length = 0
overlayMock.reset()
run.mockReset()
selectedBrId.value = ''
successHandled = undefined
@@ -71,7 +62,7 @@ function mountForget(brs: MaybeRefOrGetter<FrontXoBackupRepository[]>, { router
const result = mountComposable(() => useBackupRepositoryForget(brs), { router }).wrapper.vm
// The composable declares the single repository modal first, then the type to confirm one
const [forgetModal, typeToConfirmModal] = overlays
const [forgetModal, typeToConfirmModal] = overlayMock.getOverlays<MockedOverlay>()
return { result, forgetModal, typeToConfirmModal }
}
@@ -82,7 +73,7 @@ async function forgetAndConfirm(brs: MaybeRefOrGetter<FrontXoBackupRepository[]>
result.forgetBackupRepositories()
const openedModal = overlays.find(overlay => overlay.open.mock.calls.length > 0)
const openedModal = overlayMock.getOverlays<MockedOverlay>().find(overlay => overlay.open.mock.calls.length > 0)
// The events can be declared with the modal, or given when opening it
const openEvents: OverlayEvents | undefined = openedModal?.open.mock.lastCall?.[0]?.events

View File

@@ -6,8 +6,9 @@ import type {
import type { EditBackupRepositoryPayload } from '@/modules/backup-repository/jobs/xo-backup-repository-edit.job.ts'
import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote-resources/use-xo-backup-repository-collection.ts'
import { createBr } from '@/test/create-br.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import { ref, toValue } from 'vue'
@@ -16,8 +17,7 @@ type MockedOverlay = {
events: { onConfirm: (payload: never) => Promise<unknown> }
}
const { overlays, useBulkEditJob, run } = vi.hoisted(() => ({
overlays: [] as MockedOverlay[],
const { useBulkEditJob, run } = vi.hoisted(() => ({
useBulkEditJob: vi.fn(),
run: vi.fn(),
}))
@@ -27,19 +27,11 @@ vi.mock(import('@/modules/backup-repository/jobs/xo-backup-repository-bulk-edit.
useXoBackupRepositoryBulkEditJob: useBulkEditJob as unknown as typeof useXoBackupRepositoryBulkEditJob,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: (({ events }: Pick<MockedOverlay, 'events'>) => {
const overlay = { open: vi.fn(), events }
overlays.push(overlay)
return { open: overlay.open }
}) as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
beforeEach(() => {
vi.restoreAllMocks()
overlays.length = 0
overlayMock.reset()
useBulkEditJob.mockReset()
run.mockReset()
@@ -70,7 +62,7 @@ function mountBulkEdit(brs: FrontXoBackupRepository[]) {
const result = mountComposable(() => useBulkEditBackupRepositories(() => brs)).wrapper.vm
// The composable declares the edit drawer of a single repository first, then the bulk edit one
const [editDrawer, bulkEditDrawer] = overlays
const [editDrawer, bulkEditDrawer] = overlayMock.getOverlays<MockedOverlay>()
return { result, editDrawer, bulkEditDrawer }
}
@@ -140,7 +132,7 @@ describe('on confirm', () => {
})
it('keeps the drawer open when the job fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
run.mockRejectedValue(new Error('Missing changes for a backup repository'))
const { editDrawer } = mountBulkEdit([firstBr])

View File

@@ -5,8 +5,9 @@ import type {
} from '@/modules/backup-repository/jobs/xo-backup-repository-edit.job.ts'
import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote-resources/use-xo-backup-repository-collection.ts'
import { createBr } from '@/test/create-br.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import { type Ref, ref } from 'vue'
@@ -17,20 +18,16 @@ type OverlayOptions = {
}
}
const { useEditJob, run, useOverlayMock, open } = vi.hoisted(() => ({
const { useEditJob, run } = vi.hoisted(() => ({
useEditJob: vi.fn(),
run: vi.fn(),
useOverlayMock: vi.fn(),
open: vi.fn(),
}))
vi.mock(import('@/modules/backup-repository/jobs/xo-backup-repository-edit.job.ts'), () => ({
useXoBackupRepositoryEditJob: useEditJob as unknown as typeof useXoBackupRepositoryEditJob,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: useOverlayMock as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
const PAYLOAD: EditBackupRepositoryPayload = {
name: 'Weekly backups',
@@ -42,10 +39,9 @@ const PAYLOAD: EditBackupRepositoryPayload = {
beforeEach(() => {
useEditJob.mockReset()
run.mockReset()
useOverlayMock.mockReset()
open.mockReset()
overlayMock.reset()
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
useEditJob.mockReturnValue({
run,
@@ -53,8 +49,6 @@ beforeEach(() => {
isRunning: ref(false),
errorMessage: ref(undefined),
})
useOverlayMock.mockReturnValue({ open, abort: vi.fn() })
})
function mountEditBackupRepository(br: FrontXoBackupRepository = createBr()) {
@@ -62,7 +56,7 @@ function mountEditBackupRepository(br: FrontXoBackupRepository = createBr()) {
}
function getOverlayOptions(): OverlayOptions {
return useOverlayMock.mock.calls[0][0]
return overlayMock.getOverlay<OverlayOptions>()
}
function getJobArgs() {
@@ -107,7 +101,7 @@ describe('editBackupRepository', () => {
mountEditBackupRepository(br).editBackupRepository()
expect(open).toHaveBeenCalledWith({ props: expect.objectContaining({ br }) })
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({ props: expect.objectContaining({ br }) })
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -3,8 +3,9 @@ import type {
NewBackupRepositoryPayload,
useXoBackupRepositoryCreateJob,
} from '@/modules/backup-repository/jobs/xo-backup-repository-create.job.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import type { Ref } from 'vue'
@@ -15,20 +16,16 @@ type OverlayOptions = {
}
}
const { useCreateJob, run, useOverlayMock, open } = vi.hoisted(() => ({
const { useCreateJob, run } = vi.hoisted(() => ({
useCreateJob: vi.fn(),
run: vi.fn(),
useOverlayMock: vi.fn(),
open: vi.fn(),
}))
vi.mock(import('@/modules/backup-repository/jobs/xo-backup-repository-create.job.ts'), () => ({
useXoBackupRepositoryCreateJob: useCreateJob as unknown as typeof useXoBackupRepositoryCreateJob,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: useOverlayMock as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
const PAYLOAD: NewBackupRepositoryPayload = {
name: 'Nightly backups',
@@ -38,13 +35,11 @@ const PAYLOAD: NewBackupRepositoryPayload = {
beforeEach(() => {
useCreateJob.mockReset()
run.mockReset()
useOverlayMock.mockReset()
open.mockReset()
overlayMock.reset()
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
useCreateJob.mockReturnValue({ run })
useOverlayMock.mockReturnValue({ open, abort: vi.fn() })
})
function mountNewBackupRepository() {
@@ -52,7 +47,7 @@ function mountNewBackupRepository() {
}
function getOverlayOptions(): OverlayOptions {
return useOverlayMock.mock.calls[0][0]
return overlayMock.getOverlay<OverlayOptions>()
}
function getJobPayload() {
@@ -68,7 +63,7 @@ it('has no payload before the drawer is confirmed', () => {
it('opens the drawer', () => {
mountNewBackupRepository().openNewBackupRepositoryDrawer()
expect(open).toHaveBeenCalledOnce()
expect(overlayMock.getOverlay().open).toHaveBeenCalledOnce()
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -1,15 +1,10 @@
import { useXoBackupRepositoryStorageModeLabel } from '@/modules/backup-repository/composables/use-xo-backup-repository-storage-mode-label.composable.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { type MaybeRefOrGetter, ref } from 'vue'
import { type ParsedBackupRepositoryUrl, parse as parseBackupRepositoryUrl } from 'xo-remote-parser'
function mountStorageModeLabel(parsedUrl: ParsedBackupRepositoryUrl | undefined) {
return mountComposable(() => {
const { t } = useI18n()
return { storageModeLabel: useXoBackupRepositoryStorageModeLabel(parsedUrl), t }
}).wrapper.vm
function mountStorageModeLabel(parsedUrl: MaybeRefOrGetter<ParsedBackupRepositoryUrl | undefined>) {
return mountComposableWithI18n(() => ({ storageModeLabel: useXoBackupRepositoryStorageModeLabel(parsedUrl) }))
}
describe('useXoBackupRepositoryStorageModeLabel', () => {
@@ -43,14 +38,10 @@ describe('useXoBackupRepositoryStorageModeLabel', () => {
const parsedUrl = ref<ParsedBackupRepositoryUrl | undefined>(
parseBackupRepositoryUrl('nfs://192.168.1.10:/exports/backups')
)
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
return { storageModeLabel: useXoBackupRepositoryStorageModeLabel(parsedUrl), t }
})
const result = mountStorageModeLabel(parsedUrl)
parsedUrl.value = parseBackupRepositoryUrl('nfs://192.168.1.10:/exports/backups?useVhdDirectory=true')
expect(wrapper.vm.storageModeLabel).toBe(wrapper.vm.t('block-based'))
expect(result.storageModeLabel).toBe(result.t('block-based'))
})
})

View File

@@ -1,15 +1,10 @@
import { useXoBackupRepositoryTypeLabel } from '@/modules/backup-repository/composables/use-xo-backup-repository-type-label.composable.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { type MaybeRefOrGetter, ref } from 'vue'
import { BACKUP_REPOSITORY_TYPE, type BackupRepositoryType } from 'xo-remote-parser'
function mountTypeLabel(type: BackupRepositoryType | undefined) {
return mountComposable(() => {
const { t } = useI18n()
return { typeLabel: useXoBackupRepositoryTypeLabel(type), t }
}).wrapper.vm
function mountTypeLabel(type: MaybeRefOrGetter<BackupRepositoryType | undefined>) {
return mountComposableWithI18n(() => ({ typeLabel: useXoBackupRepositoryTypeLabel(type) }))
}
describe('useXoBackupRepositoryTypeLabel', () => {
@@ -41,14 +36,10 @@ describe('useXoBackupRepositoryTypeLabel', () => {
it('reacts to changes of the source type', () => {
const type = ref<BackupRepositoryType | undefined>(BACKUP_REPOSITORY_TYPE.NFS)
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
return { typeLabel: useXoBackupRepositoryTypeLabel(type), t }
})
const result = mountTypeLabel(type)
type.value = BACKUP_REPOSITORY_TYPE.S3
expect(wrapper.vm.typeLabel).toBe(wrapper.vm.t('s3'))
expect(result.typeLabel).toBe(result.t('s3'))
})
})

View File

@@ -2,10 +2,9 @@ import { useXoBackupRepositoryUtils } from '@/modules/backup-repository/composab
import type { FrontXoBackupRepository } from '@/modules/backup-repository/remote-resources/use-xo-backup-repository-collection.ts'
import type { useXoProxyCollection } from '@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'
import { createBr } from '@/test/create-br.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import type { XoProxy } from '@vates/types'
import { computed, ref, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import { parse as parseBackupRepositoryUrl } from 'xo-remote-parser'
const { useGetProxyById } = vi.hoisted(() => ({
@@ -23,11 +22,7 @@ beforeEach(() => {
})
function mountUtils(br: FrontXoBackupRepository = createBr()) {
return mountComposable(() => {
const { t } = useI18n()
return { ...useXoBackupRepositoryUtils(br, parseBackupRepositoryUrl(br.url)), t }
}).wrapper.vm
return mountComposableWithI18n(() => useXoBackupRepositoryUtils(br, parseBackupRepositoryUrl(br.url)))
}
describe('useXoBackupRepositoryUtils', () => {
@@ -79,18 +74,13 @@ describe('useXoBackupRepositoryUtils', () => {
it('reacts to changes of the source repository', () => {
const br = ref(createBr({ url: 'nfs://192.168.100.225:/media/nfs' }))
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
return {
...useXoBackupRepositoryUtils(br, () => parseBackupRepositoryUrl(br.value.url)),
t,
}
})
const result = mountComposableWithI18n(() =>
useXoBackupRepositoryUtils(br, () => parseBackupRepositoryUrl(br.value.url))
)
br.value = createBr({ enabled: false, url: 's3://key:secret@s3.us-west-1.amazonaws.com/my-bucket/backups' })
expect(wrapper.vm.brStatus).toBe('disabled')
expect(wrapper.vm.brType).toBe(wrapper.vm.t('s3'))
expect(result.brStatus).toBe('disabled')
expect(result.brType).toBe(result.t('s3'))
})
})

View File

@@ -119,7 +119,8 @@ export function useBulkEditBackupRepositoriesForm(brs: FrontXoBackupRepository[]
return undefined
}
const detailsChanges = detailsForm === undefined ? undefined : omitMixedValues(detailsForm.formData, t('mixed-value'))
const detailsChanges =
detailsForm === undefined ? undefined : omitMixedValues(detailsForm.formData, t('mixed-value'))
return new Map(initialBrs.map(initialBr => [initialBr.br.id, buildPayload(initialBr, detailsChanges)]))
}

View File

@@ -1,4 +1,5 @@
import { useAzureBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-azure-backup-repository-details-form.ts'
import { requiredMessage } from '@/test/form-messages.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { nextTick, ref } from 'vue'
import type { BackupRepositoryType } from 'xo-remote-parser'
@@ -25,10 +26,10 @@ describe('validate', () => {
const result = mountAzureForm()
expect(await result.validate()).toBe(false)
expect(result.bindings.hostName.error).toBeDefined()
expect(result.bindings.accountName.error).toBeDefined()
expect(result.bindings.key.error).toBeDefined()
expect(result.bindings.containerName.error).toBeDefined()
expect(result.bindings.hostName.error).toEqual(requiredMessage())
expect(result.bindings.accountName.error).toEqual(requiredMessage())
expect(result.bindings.key.error).toEqual(requiredMessage())
expect(result.bindings.containerName.error).toEqual(requiredMessage())
})
it('accepts the required fields without a path in the container', async () => {

View File

@@ -1,17 +1,13 @@
import { useLocalBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-local-backup-repository-details-form.ts'
import type { FrontXoProxy } from '@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { requiredMessage } from '@/test/form-messages.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { type MaybeRefOrGetter, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const PROXY_ID = 'proxy-1' as FrontXoProxy['id']
function mountLocalForm(proxy?: MaybeRefOrGetter<FrontXoProxy['id'] | undefined>) {
return mountComposable(() => {
const { t } = useI18n()
return { ...useLocalBackupRepositoryDetailsForm(proxy), t }
}).wrapper.vm
return mountComposableWithI18n(() => useLocalBackupRepositoryDetailsForm(proxy))
}
describe('bindings', () => {
@@ -41,7 +37,7 @@ describe('validate', () => {
const result = mountLocalForm()
expect(await result.validate()).toBe(false)
expect(result.bindings.path.error).toBeDefined()
expect(result.bindings.path.error).toEqual(requiredMessage())
})
it('accepts a filled path', async () => {

View File

@@ -1,8 +1,8 @@
import { useNfsBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-nfs-backup-repository-details-form.ts'
import { NFS_DEFAULT_PORT } from '@/modules/backup-repository/utils/xo-backup-repository-details-payload.util.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { dangerMessage, requiredMessage } from '@/test/form-messages.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
type NfsFormData = ReturnType<typeof useNfsBackupRepositoryDetailsForm>['formData']
@@ -12,11 +12,7 @@ const VALID_FORM_DATA = {
} satisfies Partial<NfsFormData>
function mountNfsForm(formData: Partial<NfsFormData> = {}) {
const result = mountComposable(() => {
const { t } = useI18n()
return { ...useNfsBackupRepositoryDetailsForm(), t }
}).wrapper.vm
const result = mountComposableWithI18n(() => useNfsBackupRepositoryDetailsForm())
Object.assign(result.formData, formData)
@@ -28,8 +24,8 @@ describe('validate', () => {
const result = mountNfsForm()
expect(await result.validate()).toBe(false)
expect(result.bindings.host.error).toBeDefined()
expect(result.bindings.path.error).toBeDefined()
expect(result.bindings.host.error).toEqual(requiredMessage())
expect(result.bindings.path.error).toEqual(requiredMessage())
})
it('accepts a host and a path without a port', async () => {
@@ -42,7 +38,7 @@ describe('validate', () => {
const result = mountNfsForm({ ...VALID_FORM_DATA, port })
expect(await result.validate()).toBe(false)
expect(result.bindings.port.error).toMatchObject({ content: result.t('invalid-port') })
expect(result.bindings.port.error).toEqual(dangerMessage(result.t('invalid-port')))
})
it.each(['1', '2049', '65535'])('accepts the valid port %s', async port => {

View File

@@ -1,4 +1,5 @@
import { useS3BackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-s3-backup-repository-details-form.ts'
import { requiredMessage } from '@/test/form-messages.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { nextTick } from 'vue'
@@ -37,11 +38,11 @@ describe('validate', () => {
const result = mountS3Form()
expect(await result.validate()).toBe(false)
expect(result.bindings.endpoint.error).toBeDefined()
expect(result.bindings.region.error).toBeDefined()
expect(result.bindings.accessKeyId.error).toBeDefined()
expect(result.bindings.secret.error).toBeDefined()
expect(result.bindings.bucket.error).toBeDefined()
expect(result.bindings.endpoint.error).toEqual(requiredMessage())
expect(result.bindings.region.error).toEqual(requiredMessage())
expect(result.bindings.accessKeyId.error).toEqual(requiredMessage())
expect(result.bindings.secret.error).toEqual(requiredMessage())
expect(result.bindings.bucket.error).toEqual(requiredMessage())
})
it('accepts the required fields without a path in the bucket', async () => {

View File

@@ -1,5 +1,6 @@
import { useSmbBackupRepositoryDetailsForm } from '@/modules/backup-repository/form/details/use-smb-backup-repository-details-form.ts'
import { SMB_DEFAULT_DOMAIN } from '@/modules/backup-repository/utils/xo-backup-repository-details-payload.util.ts'
import { requiredMessage } from '@/test/form-messages.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { nextTick } from 'vue'
@@ -24,9 +25,9 @@ describe('validate', () => {
const result = mountSmbForm()
expect(await result.validate()).toBe(false)
expect(result.bindings.pathOnShare.error).toBeDefined()
expect(result.bindings.username.error).toBeDefined()
expect(result.bindings.password.error).toBeDefined()
expect(result.bindings.pathOnShare.error).toEqual(requiredMessage())
expect(result.bindings.username.error).toEqual(requiredMessage())
expect(result.bindings.password.error).toEqual(requiredMessage())
})
it('accepts a share with credentials, without subfolder, domain or custom options', async () => {

View File

@@ -4,10 +4,10 @@ import {
SMB_DEFAULT_DOMAIN,
} from '@/modules/backup-repository/utils/xo-backup-repository-details-payload.util.ts'
import type { FrontXoProxy, useXoProxyCollection } from '@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { dangerMessage } from '@/test/form-messages.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
import { flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { type BackupRepositoryType, format as formatBackupRepositoryUrl } from 'xo-remote-parser'
const PROXY_ID = 'proxy-1' as FrontXoProxy['id']
@@ -21,11 +21,7 @@ vi.mock(import('@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'), (
}))
function mountNewForm() {
return mountComposable(() => {
const { t } = useI18n()
return { ...useNewBackupRepositoryForm(), t }
}).wrapper.vm
return mountComposableWithI18n(() => useNewBackupRepositoryForm())
}
type NewForm = ReturnType<typeof mountNewForm>
@@ -113,9 +109,9 @@ describe('general', () => {
await fillEncryptedGeneralStep(form, encryptionKey)
expect(await form.general.validate()).toBe(false)
expect(form.general.bindings.encryptionKey.error).toMatchObject({
content: form.t('encryption-key-invalid', { n: 32 }),
})
expect(form.general.bindings.encryptionKey.error).toEqual(
dangerMessage(form.t('encryption-key-invalid', { n: 32 }))
)
}
)

View File

@@ -6,13 +6,13 @@ import {
} from '@/modules/backup-repository/form/use-backup-repository-general-form.ts'
import type { useXoProxyCollection } from '@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'
import { OBFUSCATED_VALUE } from '@/shared/constants.ts'
import { requiredMessage } from '@/test/form-messages.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { BackupRepositoryType } from 'xo-remote-parser'
vi.mock(import('@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'), () => ({
@@ -26,11 +26,7 @@ function createGeneralFormData(): BackupRepositoryGeneralFormData {
}
async function mountGeneralForm(formData: Partial<BackupRepositoryGeneralFormData> = {}) {
const result = mountComposable(() => {
const { t } = useI18n()
return { ...useBackupRepositoryGeneralForm(reactive(createGeneralFormData())), t }
}).wrapper.vm
const result = mountComposable(() => useBackupRepositoryGeneralForm(reactive(createGeneralFormData()))).wrapper.vm
const { type, ...otherFormData } = formData
@@ -156,9 +152,9 @@ describe('validate', () => {
const result = await mountGeneralForm()
expect(await result.validate()).toBe(false)
expect(result.bindings.name.error).toBeDefined()
expect(result.bindings.type.error).toBeDefined()
expect(result.bindings.backupFormat.error).toBeDefined()
expect(result.bindings.name.error).toEqual(requiredMessage())
expect(result.bindings.type.error).toEqual(requiredMessage())
expect(result.bindings.backupFormat.error).toEqual(requiredMessage())
})
it('accepts a name, a type and a format, without proxy nor encryption', async () => {
@@ -171,7 +167,7 @@ describe('validate', () => {
const result = await mountGeneralForm({ name: 'My repository', type: 's3', encrypted: true })
expect(await result.validate()).toBe(false)
expect(result.bindings.encryptionKey.error).toBeDefined()
expect(result.bindings.encryptionKey.error).toEqual(requiredMessage())
})
it('accepts any encryption key, its format being checked by the form using it', async () => {
@@ -263,7 +259,7 @@ describe('multi-edit mode', () => {
}
// The selects display the label of their selected option
function findSelectedLabels(
function findShownSelections(
formData: Partial<BackupRepositoryGeneralFormData>,
mixedFields?: (keyof BackupRepositoryGeneralFormData)[]
) {
@@ -284,7 +280,7 @@ describe('multi-edit mode', () => {
}
it('shows the mixed value on the type, the format and the proxy which differ between the repositories', () => {
expect(findSelectedLabels({ name: 'My repository' }, ['type', 'backupFormat', 'proxy'])).toEqual({
expect(findShownSelections({ name: 'My repository' }, ['type', 'backupFormat', 'proxy'])).toEqual({
[t('type')]: t('mixed-value'),
[t('backup-format')]: t('mixed-value'),
[t('proxy')]: t('mixed-value'),
@@ -292,7 +288,7 @@ describe('multi-edit mode', () => {
})
it('shows the values shared by all the repositories', () => {
const selectedLabels = findSelectedLabels({
const selectedLabels = findShownSelections({
name: 'My repository',
type: 'nfs',
backupFormat: 'block',
@@ -304,7 +300,7 @@ describe('multi-edit mode', () => {
})
it('removes the proxy of all the repositories with "None"', () => {
expect(findSelectedLabels({ name: 'My repository', proxy: null }, ['proxy'])[t('proxy')]).toBe(t('none'))
expect(findShownSelections({ name: 'My repository', proxy: null }, ['proxy'])[t('proxy')]).toBe(t('none'))
})
it('accepts a type and a format left mixed', async () => {

View File

@@ -5,6 +5,7 @@ import type { fetchPost } from '@/shared/utils/fetch.util.ts'
import { createBr } from '@/test/create-br.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
const { post, monitorTask } = vi.hoisted(() => ({
post: vi.fn(),
@@ -51,7 +52,7 @@ describe('run', () => {
})
it('reports each repository result, without stopping at the first failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
const error = new Error('Backup repository unreachable')
monitorTask.mockRejectedValueOnce(error)

View File

@@ -9,6 +9,7 @@ import type { fetchPatch } from '@/shared/utils/fetch.util.ts'
import { createBr } from '@/test/create-br.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
const { patch, checkHealth } = vi.hoisted(() => ({
patch: vi.fn(),
@@ -65,7 +66,7 @@ describe('run', () => {
})
it('reports each repository result, without stopping at the first failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
const error = new Error('Backup repository unreachable')
patch.mockRejectedValueOnce(error)

View File

@@ -5,6 +5,7 @@ import type { fetchPatch } from '@/shared/utils/fetch.util.ts'
import { createBr } from '@/test/create-br.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
const { patch, checkHealth } = vi.hoisted(() => ({
patch: vi.fn(),
@@ -62,7 +63,7 @@ describe('run', () => {
})
it('reports each repository result, without stopping at the first failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
const error = new Error('Backup repository unreachable')
patch.mockRejectedValueOnce(error)

View File

@@ -9,6 +9,7 @@ import type { fetchPost } from '@/shared/utils/fetch.util.ts'
import { createBr } from '@/test/create-br.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { ref } from 'vue'
const { post, monitorTask, backupJobs } = vi.hoisted(() => ({
@@ -68,7 +69,7 @@ describe('run', () => {
})
it('reports each repository result, without stopping at the first failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
const error = new Error('Backup repository unreachable')
monitorTask.mockRejectedValueOnce(error)

View File

@@ -4,10 +4,10 @@ import type { XoHostState } from '@/modules/host/utils/xo-host.util.ts'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { createVm } from '@/test/create-vm.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { mountComposable, mountComposableWithI18n } from '@/test/mount-composable.ts'
import { getRelativeTime } from '@core/composables/relative-time.composable.ts'
import { HOST_ALLOWED_OPERATIONS, HOST_POWER_STATE, VM_OPERATIONS } from '@vates/types'
import { ref } from 'vue'
import { type MaybeRefOrGetter, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { vmsByHost } = vi.hoisted(() => ({
@@ -26,12 +26,12 @@ function setResidentVms(hostId: FrontXoHost['id'], vms: FrontXoVm[]) {
vmsByHost.value = new Map([[hostId, vms]])
}
function mountHostUtils(overrides: Partial<FrontXoHost> = {}) {
return mountComposable(() => {
const { t } = useI18n()
function mountHostUtilsFromSource(host: MaybeRefOrGetter<FrontXoHost>) {
return mountComposableWithI18n(() => useXoHostUtils(host))
}
return { ...useXoHostUtils(createHost(overrides)), t }
}).wrapper.vm
function mountHostUtils(overrides: Partial<FrontXoHost> = {}) {
return mountHostUtilsFromSource(createHost(overrides))
}
describe('getHostStateDisplay', () => {
@@ -139,9 +139,9 @@ describe('isChangingState', () => {
setResidentVms(host.id, [createVm({ current_operations: { 'task-1': VM_OPERATIONS.SUSPEND } })])
const { wrapper } = mountComposable(() => useXoHostUtils(host))
const result = mountHostUtilsFromSource(host)
expect(wrapper.vm.isChangingState).toBe(true)
expect(result.isChangingState).toBe(true)
})
it('is false when a resident VM is suspending but the host is still enabled', () => {
@@ -149,20 +149,20 @@ describe('isChangingState', () => {
setResidentVms(host.id, [createVm({ current_operations: { 'task-1': VM_OPERATIONS.SUSPEND } })])
const { wrapper } = mountComposable(() => useXoHostUtils(host))
const result = mountHostUtilsFromSource(host)
expect(wrapper.vm.isChangingState).toBe(false)
expect(result.isChangingState).toBe(false)
})
it('reacts to changes of the source host', () => {
const host = ref(createHost({ current_operations: {} }))
const { wrapper } = mountComposable(() => useXoHostUtils(host))
const result = mountHostUtilsFromSource(host)
expect(wrapper.vm.isChangingState).toBe(false)
expect(result.isChangingState).toBe(false)
host.value = createHost({ current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT } })
expect(wrapper.vm.isChangingState).toBe(true)
expect(result.isChangingState).toBe(true)
})
})
@@ -189,13 +189,9 @@ describe('currentOperation', () => {
setResidentVms(host.id, [createVm({ current_operations: { 'task-1': VM_OPERATIONS.HARD_SHUTDOWN } })])
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
const result = mountHostUtilsFromSource(host)
return { ...useXoHostUtils(host), t }
})
expect(wrapper.vm.currentOperation).toBe(wrapper.vm.t('operation:vm-hard-shutdown'))
expect(result.currentOperation).toBe(result.t('operation:vm-hard-shutdown'))
})
it('falls back to an empty label when there is no pending operation', () => {

View File

@@ -7,7 +7,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -20,7 +20,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -29,6 +28,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewBondedNetworkForm } from '@/modules/network/form/new-bonded/use-new-bonded-network-form.ts'
import type { NewBondedNetworkPayload } from '@/modules/network/jobs/xo-bonded-network-create.job.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -4,7 +4,7 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
@@ -16,7 +16,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -25,6 +24,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewInternalNetworkForm } from '@/modules/network/form/new-internal/use-new-internal-network-form.ts'
import type { NewInternalNetworkPayload } from '@/modules/network/jobs/xo-internal-network-create.job.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -7,11 +7,11 @@
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
<VtsNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
<NetworkFormNumberInput v-bind="vlanInputBindings" class="vlan" />
<VtsNumberInput v-bind="vlanInputBindings" class="vlan" />
<div class="nbd">
<NewNetworkNbdRadio v-bind="nbdRadioBindings" />
</div>
@@ -20,7 +20,6 @@
</template>
<script lang="ts" setup>
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormSelect from '@/modules/network/components/form/new/inputs/NetworkFormSelect.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
@@ -29,6 +28,7 @@ import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewN
import { useNewNetworkForm } from '@/modules/network/form/new/use-new-network-form.ts'
import type { NewNetworkPayload } from '@/modules/network/jobs/xo-network-create.job.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import VtsNumberInput from '@core/components/number-input/VtsNumberInput.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'

View File

@@ -1,27 +0,0 @@
<template>
<VtsInputWrapper :label :message="messages">
<UiInput v-model.number="model" accent="brand" type="number" :required @blur="emit('blur')" />
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { computed } from 'vue'
const { info, warning, error } = defineProps<{
label: string
error?: InputWrapperMessage
warning?: InputWrapperMessage
info?: string
required?: boolean
}>()
const emit = defineEmits<{ blur: [] }>()
const model = defineModel<number | undefined>()
const messages = computed<InputWrapperMessage>(
() => [info, warning, error].filter(message => message !== undefined) as InputWrapperMessage
)
</script>

View File

@@ -2,6 +2,7 @@ import PluginActions from '@/modules/plugin/components/actions/PluginActions.vue
import PluginHeader from '@/modules/plugin/components/PluginHeader.vue'
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
import { createPlugin, PLUGIN_CONFIGURATION_SCHEMA } from '@/test/create-plugin.ts'
import { findAccentedTags } from '@/test/find-accented-labels.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
@@ -13,16 +14,6 @@ function mountHeader(plugin: FrontXoPlugin = createPlugin()) {
})
}
function findTags(wrapper: ReturnType<typeof mountHeader>) {
return wrapper.findAll('.ui-head-bar .ui-tag').map(tag => ({
text: tag.text(),
accent: tag
.classes()
.find(className => className.startsWith('accent--'))
?.replace('accent--', ''),
}))
}
it('shows the name of the plugin in the head bar', () => {
const wrapper = mountHeader(createPlugin())
@@ -46,28 +37,28 @@ it('shows the version and the configuration status of a configured plugin', () =
createPlugin({ version: '2.1.0', configurationSchema: PLUGIN_CONFIGURATION_SCHEMA, configured: true })
)
expect(findTags(wrapper)).toEqual([
{ text: t('version-number', { version: '2.1.0' }), accent: 'neutral' },
{ text: t('status:configured'), accent: 'success' },
expect(findAccentedTags(wrapper)).toEqual([
{ label: t('version-number', { version: '2.1.0' }), accent: 'neutral' },
{ label: t('status:configured'), accent: 'success' },
])
})
it('warns about a plugin which is not configured', () => {
const wrapper = mountHeader(createPlugin({ configurationSchema: PLUGIN_CONFIGURATION_SCHEMA, configured: false }))
expect(findTags(wrapper).at(-1)).toEqual({ text: t('status:not-configured'), accent: 'warning' })
expect(findAccentedTags(wrapper).at(-1)).toEqual({ label: t('status:not-configured'), accent: 'warning' })
})
it('shows no configuration status for a plugin which is not configurable', () => {
const wrapper = mountHeader(createPlugin({ version: '2.1.0', configurationSchema: undefined }))
expect(findTags(wrapper)).toEqual([{ text: t('version-number', { version: '2.1.0' }), accent: 'neutral' }])
expect(findAccentedTags(wrapper)).toEqual([{ label: t('version-number', { version: '2.1.0' }), accent: 'neutral' }])
})
it('shows no version for a plugin without version', () => {
const wrapper = mountHeader(createPlugin({ version: undefined, configurationSchema: undefined }))
expect(findTags(wrapper)).toEqual([])
expect(findAccentedTags(wrapper)).toEqual([])
})
it('displays the actions of the plugin', () => {

View File

@@ -3,8 +3,9 @@ import type { useXoPluginConfigureJob } from '@/modules/plugin/jobs/xo-plugin-co
import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plugin-collection.ts'
import { createJobResult } from '@/test/create-job-result.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { overlayMock } from '@/test/mock-overlay.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import type { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import type { Ref } from 'vue'
@@ -16,30 +17,24 @@ type OverlayOptions = {
}
}
const { useJob, run, useOverlayMock, open, drawer } = vi.hoisted(() => ({
const { useJob, run, drawer } = vi.hoisted(() => ({
useJob: vi.fn(),
run: vi.fn(),
useOverlayMock: vi.fn(),
open: vi.fn(),
drawer: vi.fn(),
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => ({
useOverlay: useOverlayMock as unknown as typeof useOverlay,
}))
vi.mock(import('@core/packages/overlay/use-overlay.ts'), () => import('@/test/mock-overlay.ts'))
const PAYLOAD = { to: 'admin@example.org' }
beforeEach(() => {
useJob.mockReset()
run.mockReset()
useOverlayMock.mockReset()
open.mockReset()
overlayMock.reset()
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
useJob.mockReturnValue(createJobResult(run))
useOverlayMock.mockReturnValue({ open, abort: vi.fn() })
})
function mountDrawerJob(plugin: FrontXoPlugin = createPlugin()) {
@@ -49,7 +44,7 @@ function mountDrawerJob(plugin: FrontXoPlugin = createPlugin()) {
}
function getOverlayOptions(): OverlayOptions {
return useOverlayMock.mock.calls[0][0]
return overlayMock.getOverlay<OverlayOptions>()
}
function getJobArgs() {
@@ -86,7 +81,7 @@ describe('openDrawer', () => {
mountDrawerJob(plugin).openDrawer()
expect(getOverlayOptions().component).toBe(drawer)
expect(open).toHaveBeenCalledWith({ props: expect.objectContaining({ plugin }) })
expect(overlayMock.getOverlay().open).toHaveBeenCalledWith({ props: expect.objectContaining({ plugin }) })
})
it('closes the drawer when it is cancelled', () => {

View File

@@ -3,6 +3,7 @@ import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plu
import type { fetchPatch } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { patch } = vi.hoisted(() => ({
@@ -63,9 +64,6 @@ describe('validate', () => {
patch.mockReturnValue(new Promise(() => {}))
const job = mountAutoloadJob(createPlugin())
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:edit:in-progress'))
expectJobRunning(job, t('job:edit:in-progress'))
})
})

View File

@@ -4,7 +4,9 @@ import type { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composab
import type { fetchPatch, fetchPost } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { silenceConsoleError } from '@/test/silence-console-error.ts'
const { post, patch, monitorTask } = vi.hoisted(() => ({
post: vi.fn(),
@@ -54,7 +56,7 @@ describe('run', () => {
})
it('succeeds when only the autoload update fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
silenceConsoleError()
patch.mockRejectedValue(new Error('autoload update failed'))
await expect(mountChangeStateJob(unloadedPlugin, true).run()).resolves.toBeUndefined()
@@ -127,9 +129,6 @@ describe('validate', () => {
post.mockReturnValue(new Promise(() => {}))
const job = mountChangeStateJob(plugin, loaded)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t(key))
expectJobRunning(job, t(key))
})
})

View File

@@ -3,6 +3,7 @@ import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plu
import type { fetchPut } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { put } = vi.hoisted(() => ({
@@ -72,9 +73,6 @@ describe('validate', () => {
put.mockReturnValue(new Promise(() => {}))
const job = mountConfigureJob(configurablePlugin)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:edit:in-progress'))
expectJobRunning(job, t('job:edit:in-progress'))
})
})

View File

@@ -3,6 +3,7 @@ import type { FrontXoPlugin } from '@/modules/plugin/remote-resources/use-xo-plu
import type { fetchDelete } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { remove } = vi.hoisted(() => ({
@@ -79,9 +80,6 @@ describe('validate', () => {
remove.mockReturnValue(new Promise(() => {}))
const job = mountDeleteConfigurationJob(configuredPlugin)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:delete:in-progress'))
expectJobRunning(job, t('job:delete:in-progress'))
})
})

View File

@@ -4,6 +4,7 @@ import type { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composab
import type { fetchPost } from '@/shared/utils/fetch.util.ts'
import { createPlugin } from '@/test/create-plugin.ts'
import { t } from '@/test/i18n.ts'
import { expectJobRunning } from '@/test/job-running.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const { post, monitorTask } = vi.hoisted(() => ({
@@ -85,9 +86,6 @@ describe('validate', () => {
post.mockReturnValue(new Promise(() => {}))
const job = mountTestJob(testablePlugin)
void job.run()
expect(job.isRunning).toBe(true)
expect(job.errorMessage).toBe(t('job:plugin-test:in-progress'))
expectJobRunning(job, t('job:plugin-test:in-progress'))
})
})

View File

@@ -3,16 +3,16 @@
<div class="primary-host-section">
<UiTitle>{{ t('master') }}</UiTitle>
<div class="inputs-container">
<PoolConnectionFormTextInput v-bind="hostInputBindings" />
<PoolConnectionFormTextInput v-bind="httpProxyInputBindings" />
<PoolConnectionFormTextInput v-bind="usernameInputBindings" />
<PoolConnectionFormPasswordInput v-bind="passwordInputBindings" />
<FormTextInput v-bind="hostInputBindings" />
<FormTextInput v-bind="httpProxyInputBindings" />
<FormTextInput v-bind="usernameInputBindings" />
<FormPasswordInput v-bind="passwordInputBindings" />
</div>
</div>
<UiTitle>{{ t('options') }}</UiTitle>
<div class="options-section">
<PoolConnectionFormCheckbox v-bind="readOnlyCheckboxBindings" />
<PoolConnectionFormCheckbox v-bind="allowUnauthorizedCheckboxBindings" />
<FormCheckbox v-bind="readOnlyCheckboxBindings" />
<FormCheckbox v-bind="allowUnauthorizedCheckboxBindings" />
</div>
<div class="buttons-container">
<UiLink :to="{ name: '/(site)/dashboard' }" size="medium">
@@ -26,14 +26,14 @@
</template>
<script setup lang="ts">
import PoolConnectionFormCheckbox from '@/modules/pool/components/connection/inputs/PoolConnectionFormCheckbox.vue'
import PoolConnectionFormPasswordInput from '@/modules/pool/components/connection/inputs/PoolConnectionFormPasswordInput.vue'
import PoolConnectionFormTextInput from '@/modules/pool/components/connection/inputs/PoolConnectionFormTextInput.vue'
import { usePoolConnectionForm } from '@/modules/pool/form/use-pool-connection-form.ts'
import { isAuthenticationFailedError } from '@/modules/pool/utils/xo-pool.util.ts'
import { useXoServerConnectJob } from '@/modules/server/jobs/xo-server-connect.job.ts'
import { useXoServerCreateJob } from '@/modules/server/jobs/xo-server-create.job.ts'
import { useXoServerForgetJob } from '@/modules/server/jobs/xo-server-forget.job.ts'
import FormCheckbox from '@/shared/components/form/FormCheckbox.vue'
import FormPasswordInput from '@/shared/components/form/FormPasswordInput.vue'
import FormTextInput from '@/shared/components/form/FormTextInput.vue'
import VtsForm from '@core/components/form/VtsForm.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'

View File

@@ -0,0 +1,74 @@
import PoolConnectionSuccess from '@/modules/pool/components/connection/PoolConnectionSuccess.vue'
import type {
FrontXoServer,
useXoServerCollection,
} from '@/modules/server/remote-resources/use-xo-server-collection.ts'
import { createServer } from '@/test/create-server.ts'
import { findTitle } from '@/test/find-titles.ts'
import { getButton } from '@/test/get-button.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { mount } from '@vue/test-utils'
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue'
const servers = ref<FrontXoServer[]>([])
vi.mock(import('@/modules/server/remote-resources/use-xo-server-collection.ts'), () => ({
useXoServerCollection: (() => ({
useGetServerById: (id: MaybeRefOrGetter<FrontXoServer['id']>) =>
computed(() => servers.value.find(server => server.id === toValue(id))),
})) as unknown as typeof useXoServerCollection,
}))
const SERVER = createServer({ host: '10.0.0.1' })
function mountSuccess() {
return mount(PoolConnectionSuccess, { props: { serverId: SERVER.id }, global: createGlobalTestConfig() })
}
beforeEach(() => {
servers.value = [SERVER]
})
it('tells the IP address the pool is connected to', () => {
expect(findTitle(mountSuccess())).toBe(t('connected-to-ip', { ip: '10.0.0.1' }))
})
it('tells the connection succeeded', () => {
const alert = mountSuccess().getComponent(UiAlert)
expect(alert.props('accent')).toBe('success')
expect(alert.text()).toBe(t('pool-connection-success'))
})
it('links to the dashboard of the connected pool', () => {
const link = mountSuccess().getComponent(UiLink)
expect(link.text()).toBe(t('action:visit-pool-dashboard'))
expect(link.props('to')).toEqual({ name: '/pool/[id]/dashboard', params: { id: SERVER.poolId } })
})
it('offers to connect another pool', async () => {
const wrapper = mountSuccess()
await getButton(wrapper, t('action:connect-another-pool')).trigger('click')
expect(wrapper.emitted('connectAnotherPool')).toHaveLength(1)
})
it('has neither title nor link while the server is unknown', () => {
servers.value = []
const wrapper = mountSuccess()
expect(findTitle(wrapper)).toBeUndefined()
expect(wrapper.findComponent(UiLink).exists()).toBe(false)
})
it('has no link while the pool of the server is unknown', () => {
servers.value = [{ ...SERVER, poolId: undefined }]
expect(mountSuccess().findComponent(UiLink).exists()).toBe(false)
})

View File

@@ -1,46 +1,40 @@
<template>
<VtsStateHero class="connection-success" type="all-good" format="card" size="medium">
<div class="container">
<div v-if="server" class="typo-h1 title">{{ t('connected-to-ip', { ip: server.host }) }}</div>
<div class="content" :class="{ mobile: uiStore.isSmall, desktop: !uiStore.isLarge }">
<UiAlert accent="success">
{{ t('pool-connection-success') }}
</UiAlert>
<div class="action-buttons" :class="{ mobile: uiStore.isSmall }">
<UiLink
v-if="server && server.poolId"
:to="{ name: '/pool/[id]/dashboard', params: { id: server.poolId } }"
size="medium"
>
{{ t('action:visit-pool-dashboard') }}
</UiLink>
<UiButton
variant="secondary"
accent="brand"
size="medium"
left-icon="fa:plus"
@click="emit('connectAnotherPool')"
>
{{ t('action:connect-another-pool') }}
</UiButton>
</div>
</div>
</div>
</VtsStateHero>
<VtsSuccessStateHero>
<template v-if="server" #title>{{ t('connected-to-ip', { ip: server.host }) }}</template>
{{ t('pool-connection-success') }}
<template #actions>
<UiLink
v-if="server && server.poolId"
:to="{ name: '/pool/[id]/dashboard', params: { id: server.poolId } }"
size="medium"
>
{{ t('action:visit-pool-dashboard') }}
</UiLink>
<UiButton
variant="secondary"
accent="brand"
size="medium"
left-icon="fa:plus"
@click="emit('connectAnotherPool')"
>
{{ t('action:connect-another-pool') }}
</UiButton>
</template>
</VtsSuccessStateHero>
</template>
<script setup lang="ts">
import { useXoServerCollection } from '@/modules/server/remote-resources/use-xo-server-collection.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import {
type FrontXoServer,
useXoServerCollection,
} from '@/modules/server/remote-resources/use-xo-server-collection.ts'
import VtsSuccessStateHero from '@core/components/success-state-hero/VtsSuccessStateHero.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
import type { XoServer } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { serverId } = defineProps<{
serverId: XoServer['id']
serverId: FrontXoServer['id']
}>()
const emit = defineEmits<{
@@ -49,49 +43,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const { useGetServerById } = useXoServerCollection()
const uiStore = useUiStore()
const server = useGetServerById(() => serverId)
</script>
<style lang="postcss" scoped>
.connection-success {
.container {
display: flex;
flex-direction: column;
gap: 4rem;
}
.title {
color: var(--color-neutral-txt-primary);
}
.content {
color: var(--color-neutral-txt-primary);
display: flex;
flex-direction: column;
gap: 4rem;
margin: 0 auto;
width: 65%;
&.desktop {
width: 80%;
}
&.mobile {
width: 100%;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 2.4rem;
&.mobile {
flex-direction: column;
align-items: center;
}
}
}
}
</style>

View File

@@ -1,17 +0,0 @@
<template>
<UiCheckbox v-model="model" accent="brand">
{{ label }}
</UiCheckbox>
</template>
<script lang="ts" setup>
import UiCheckbox from '@core/components/ui/checkbox/UiCheckbox.vue'
defineOptions({ inheritAttrs: false })
defineProps<{
label: string
}>()
const model = defineModel<boolean>({ required: true })
</script>

View File

@@ -1,28 +0,0 @@
<template>
<VtsInputWrapper :label :message="messages" wrap-message>
<UiInput v-model.trim="model" accent="brand" :required :placeholder @blur="emit('blur')" />
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { computed } from 'vue'
const { info, error } = defineProps<{
label: string
info?: string
error?: InputWrapperMessage
required?: boolean
placeholder?: string
}>()
const emit = defineEmits<{ blur: [] }>()
const model = defineModel<string>({ required: true })
const messages = computed<InputWrapperMessage>(
() => [info, error].filter(message => message !== undefined) as InputWrapperMessage
)
</script>

View File

@@ -1,7 +1,7 @@
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import { useCredentialsError } from '@/shared/composables/credentials-error.composable.ts'
import { required, withMessage } from '@core/packages/form-validation'
import { useValidatedForm } from '@core/packages/validated-form'
import { computed, type ComputedRef, reactive, ref, watch } from 'vue'
import { computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
export type PoolConnectionFormData = {
@@ -40,21 +40,10 @@ export function usePoolConnectionForm() {
httpProxy: formData.httpProxy === '' ? undefined : formData.httpProxy,
}))
const credentialsError = ref<InputWrapperMessage>()
watch([() => formData.username, () => formData.password], () => {
credentialsError.value = undefined
})
function showCredentialsError() {
credentialsError.value = { content: t('invalid-username-or-password'), accent: 'danger' }
}
function withCredentialsError<T extends { error: InputWrapperMessage | undefined }>(
field: ComputedRef<T>
): ComputedRef<T> {
return computed(() => ({ ...field.value, error: field.value.error ?? credentialsError.value }))
}
const { showCredentialsError, withCredentialsError } = useCredentialsError([
() => formData.username,
() => formData.password,
])
return {
formData,

View File

@@ -0,0 +1,60 @@
import { usePoolConnectionForm } from '@/modules/pool/form/use-pool-connection-form.ts'
import { describeCredentialsErrorWiring } from '@/test/credentials-error-wiring.ts'
import { dangerMessage } from '@/test/form-messages.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
function mountPoolConnectionForm() {
return mountComposableWithI18n(() => usePoolConnectionForm())
}
type PoolConnectionForm = ReturnType<typeof mountPoolConnectionForm>
function fillRequiredFields(form: PoolConnectionForm) {
Object.assign(form.formData, { host: '192.168.1.10', username: 'root', password: 'secret' })
}
describe('validate', () => {
it('reports each missing required field with its own message', async () => {
const form = mountPoolConnectionForm()
expect(await form.validate()).toBe(false)
expect(form.hostInputBindings.error).toEqual(dangerMessage(form.t('ip-address-required')))
expect(form.usernameInputBindings.error).toEqual(dangerMessage(form.t('username-required')))
expect(form.passwordInputBindings.error).toEqual(dangerMessage(form.t('password-required')))
})
it('accepts a form whose required fields are filled', async () => {
const form = mountPoolConnectionForm()
fillRequiredFields(form)
expect(await form.validate()).toBe(true)
expect(form.hostInputBindings.error).toBeUndefined()
expect(form.usernameInputBindings.error).toBeUndefined()
expect(form.passwordInputBindings.error).toBeUndefined()
})
})
describe('payload', () => {
it('carries every field and drops an empty http proxy', () => {
const form = mountPoolConnectionForm()
fillRequiredFields(form)
expect(form.payload).toEqual({
host: '192.168.1.10',
httpProxy: undefined,
username: 'root',
password: 'secret',
readOnly: false,
allowUnauthorized: false,
})
})
it('keeps a filled http proxy', () => {
const form = mountPoolConnectionForm()
form.formData.httpProxy = 'http://proxy:3128'
expect(form.payload.httpProxy).toBe('http://proxy:3128')
})
})
describeCredentialsErrorWiring(mountPoolConnectionForm, fillRequiredFields)

View File

@@ -0,0 +1,62 @@
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import { ONE_GB } from '@/shared/constants.ts'
import { createHost } from '@/test/create-host.ts'
import { createPbd } from '@/test/create-pbd.ts'
import { createSr } from '@/test/create-sr.ts'
import { xoCollectionsMock } from '@/test/mock-xo-collections.ts'
import { mountComposableWithI18n } from '@/test/mount-composable.ts'
vi.mock(
import('@/modules/host/remote-resources/use-xo-host-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
vi.mock(
import('@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'),
() => import('@/test/mock-xo-collections.ts')
)
const HOST = createHost({ name_label: 'Host A' })
const LOCAL_SR = createSr({ shared: false })
const PBD_ON_UNKNOWN_HOST = createPbd({ SR: LOCAL_SR.id, host: 'unknown-host' as FrontXoHost['id'] })
beforeEach(() => {
xoCollectionsMock.reset()
xoCollectionsMock.hosts.value = [HOST]
})
describe('getSrSelectLabel', () => {
it.each([
['the whole GB left', createSr({ shared: true, size: 10 * ONE_GB, physical_usage: 1.5 * ONE_GB }), 8],
[
'no GB left once it uses more than its size',
createSr({ shared: true, size: 10 * ONE_GB, physical_usage: 20 * ONE_GB }),
0,
],
])('labels the SR with its location and %s', (_, sr, gbLeft) => {
const { getSrSelectLabel, t } = mountComposableWithI18n(() => useXoSrUtils())
expect(getSrSelectLabel(sr)).toBe(`${sr.name_label} (${t('shared')}) - ${t('n-gb-left', { n: gbLeft })}`)
})
})
describe('getSrLocation', () => {
it('locates a local SR on the first known host of its PBDs', () => {
xoCollectionsMock.pbds.value = [PBD_ON_UNKNOWN_HOST, createPbd({ SR: LOCAL_SR.id, host: HOST.id })]
const { getSrLocation } = mountComposableWithI18n(() => useXoSrUtils())
expect(getSrLocation(LOCAL_SR)).toBe(HOST.name_label)
})
it.each([
['it has no PBD', []],
['the host of its PBD is unknown', [PBD_ON_UNKNOWN_HOST]],
])('reads unknown for a local SR when %s', (_, pbds) => {
xoCollectionsMock.pbds.value = pbds
const { getSrLocation, t } = mountComposableWithI18n(() => useXoSrUtils())
expect(getSrLocation(LOCAL_SR)).toBe(t('unknown'))
})
})

View File

@@ -1,3 +1,4 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { SR_SCOPE_TYPE, type SrScope } from '@core/types/storage-repository.type.ts'
import type { RouteLocationRaw } from 'vue-router'
@@ -6,6 +7,10 @@ export function isSrWritable(sr: FrontXoSr) {
return sr.content_type !== 'iso' && sr.size > 0
}
export function isWritableSrOfPool(sr: FrontXoSr, poolId: FrontXoPool['id'] | undefined) {
return sr.$pool === poolId && isSrWritable(sr)
}
export function getSrPageLocation(sr: FrontXoSr, scope: SrScope): RouteLocationRaw {
return {
name: '/sr/[id]',

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