@openshift-assisted/ui-lib 2.47.1 → 2.47.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/build/@types/common/components/clusterConfiguration/utils.d.ts +1 -3
  2. package/build/@types/common/components/clusterConfiguration/utils.d.ts.map +1 -1
  3. package/build/@types/common/components/ui/formik/validationSchemas.d.ts.map +1 -1
  4. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.d.ts.map +1 -1
  5. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.d.ts.map +1 -1
  6. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.d.ts.map +1 -1
  7. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.d.ts +2 -1
  8. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.d.ts.map +1 -1
  9. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.d.ts.map +1 -1
  10. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/reorderNetworks.d.ts +5 -0
  11. package/build/@types/ocm/components/clusterConfiguration/networkConfiguration/reorderNetworks.d.ts.map +1 -0
  12. package/build/@types/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.d.ts.map +1 -1
  13. package/build/@types/ocm/components/clusterDetail/ClusterProperties.d.ts.map +1 -1
  14. package/build/cjs/common/components/clusterConfiguration/utils.js.map +1 -1
  15. package/build/cjs/common/components/ui/formik/validationSchemas.js +110 -6
  16. package/build/cjs/common/components/ui/formik/validationSchemas.js.map +1 -1
  17. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.js +1 -50
  18. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.js.map +1 -1
  19. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.js +41 -3
  20. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.js.map +1 -1
  21. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.js +26 -4
  22. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.js.map +1 -1
  23. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.js +6 -2
  24. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.js.map +1 -1
  25. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.js +32 -37
  26. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.js.map +1 -1
  27. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/reorderNetworks.js +60 -0
  28. package/build/cjs/ocm/components/clusterConfiguration/networkConfiguration/reorderNetworks.js.map +1 -0
  29. package/build/cjs/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.js +27 -9
  30. package/build/cjs/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.js.map +1 -1
  31. package/build/cjs/ocm/components/clusterDetail/ClusterProperties.js +2 -2
  32. package/build/cjs/ocm/components/clusterDetail/ClusterProperties.js.map +1 -1
  33. package/build/cjs/ocm/components/clusters/AssistedInstallerHeader.js +1 -1
  34. package/build/cjs/ocm/components/clusters/AssistedInstallerHeader.js.map +1 -1
  35. package/lib/common/components/clusterConfiguration/utils.ts +1 -3
  36. package/lib/common/components/ui/formik/validationSchemas.ts +132 -16
  37. package/lib/ocm/components/clusterConfiguration/networkConfiguration/AdvancedNetworkFields.tsx +1 -54
  38. package/lib/ocm/components/clusterConfiguration/networkConfiguration/AvailableSubnetsControl.tsx +43 -1
  39. package/lib/ocm/components/clusterConfiguration/networkConfiguration/StackTypeControl.tsx +28 -3
  40. package/lib/ocm/components/clusterConfiguration/networkConfiguration/SubnetsDropdown.tsx +7 -1
  41. package/lib/ocm/components/clusterConfiguration/networkConfiguration/networkConfigurationValidation.ts +6 -14
  42. package/lib/ocm/components/clusterConfiguration/networkConfiguration/reorderNetworks.ts +72 -0
  43. package/lib/ocm/components/clusterConfiguration/review/ReviewNetworkingTable.tsx +39 -14
  44. package/lib/ocm/components/clusterDetail/ClusterProperties.tsx +2 -2
  45. package/lib/ocm/components/clusters/AssistedInstallerHeader.tsx +1 -1
  46. package/package.json +3 -3
@@ -173,6 +173,17 @@ export const ipValidationSchema = Yup.string().test(
173
173
  (value?: string) => Address4.isValid(value || '') || Address6.isValid(value || ''),
174
174
  );
175
175
 
176
+ // Helpers to classify literal IPs more robustly
177
+ const isIPv4Address = (ip?: string) => {
178
+ if (!ip) return false;
179
+ return ip.includes('.') && Address4.isValid(ip);
180
+ };
181
+
182
+ const isIPv6Address = (ip?: string) => {
183
+ if (!ip) return false;
184
+ return ip.includes(':') && Address6.isValid(ip);
185
+ };
186
+
176
187
  export const ipNoSuffixValidationSchema = Yup.string().test(
177
188
  'ip-validation-no-suffix',
178
189
  'Not a valid IP address',
@@ -202,7 +213,6 @@ export const vipRangeValidationSchema = (
202
213
  } catch (err) {
203
214
  return true;
204
215
  }
205
-
206
216
  const foundHostSubnets = [];
207
217
  if (machineNetworks) {
208
218
  const cidrs = machineNetworks?.map((network) => network.cidr);
@@ -303,12 +313,51 @@ export const vipNoSuffixValidationSchema = (
303
313
  vipDhcpAllocation: NetworkConfigurationValues['vipDhcpAllocation'],
304
314
  managedNetworkingType: NetworkConfigurationValues['managedNetworkingType'],
305
315
  ) => !vipDhcpAllocation && managedNetworkingType !== 'userManaged',
306
- then: () =>
307
- requiredOnceSet(head(initialValues)?.ip, 'Required. Please provide an IP address')
316
+ then: () => {
317
+ // Per-item schema for VIPs (apiVips[*].ip / ingressVips[*].ip)
318
+ // 1) Require value once set
319
+ // 2) Validate IP without suffix
320
+ // 3) Ensure VIP family matches machineNetworks[idx] family
321
+ // 4) Validate range vs selected subnets
322
+ // 5) No broadcast/network address
323
+ // 6) Uniqueness across API/Ingress
324
+ const vipFamilyMatchSchema = Yup.string().test(
325
+ 'vip-family-match-machine-network',
326
+ 'IP family must match the corresponding machine network family.',
327
+ function (value?: string) {
328
+ if (!value) {
329
+ return true;
330
+ }
331
+ // this.path looks like: "apiVips[0].ip" or "ingressVips[1].ip"
332
+ const path = this.path || '';
333
+ const match = path.match(/\[(\d+)\]/);
334
+ const index = match ? parseInt(match[1], 10) : NaN;
335
+ if (Number.isNaN(index)) {
336
+ return true;
337
+ }
338
+ const cidr = values.machineNetworks?.[index]?.cidr || '';
339
+ const mnIsV4 = isCIDR.v4(cidr);
340
+ const mnIsV6 = isCIDR.v6(cidr);
341
+ // If machine network at index is not selected/valid, don't block validation here
342
+ if (!mnIsV4 && !mnIsV6) {
343
+ return true;
344
+ }
345
+ const ipIsV4 = isIPv4Address(value);
346
+ const ipIsV6 = isIPv6Address(value);
347
+ if (!ipIsV4 && !ipIsV6) {
348
+ return true;
349
+ }
350
+ return mnIsV4 ? ipIsV4 : ipIsV6;
351
+ },
352
+ );
353
+
354
+ return requiredOnceSet(head(initialValues)?.ip, 'Required. Please provide an IP address')
308
355
  .concat(ipNoSuffixValidationSchema)
356
+ .concat(vipFamilyMatchSchema)
309
357
  .concat(vipRangeValidationSchema(hostSubnets, values, false))
310
358
  .concat(vipBroadcastValidationSchema(values))
311
- .concat(vipUniqueValidationSchema(values, true)),
359
+ .concat(vipUniqueValidationSchema(values, true));
360
+ },
312
361
  });
313
362
 
314
363
  export const vipArrayValidationSchema = <T extends Yup.Maybe<Yup.AnyObject>>(
@@ -324,11 +373,39 @@ export const vipArrayValidationSchema = <T extends Yup.Maybe<Yup.AnyObject>>(
324
373
  }),
325
374
  )
326
375
  : Yup.array<T>()
327
- ).test(
328
- 'vips-length',
329
- 'Both API and ingress APIs must be provided.',
330
- (_value) => values.apiVips?.length === values.ingressVips?.length,
331
- );
376
+ )
377
+ .test(
378
+ 'vips-length',
379
+ 'Both API and ingress APIs must be provided.',
380
+ (_value) => values.apiVips?.length === values.ingressVips?.length,
381
+ )
382
+ .test(
383
+ 'vips-match-machine-networks',
384
+ 'Primary API and Ingress IPs must match the primary machine network family; secondary must match the secondary machine network family.',
385
+ (_vips?: { ip?: string }[]) => {
386
+ const machineNetworks = values.machineNetworks || [];
387
+ const validateIndex = (idx: number) => {
388
+ const cidr = machineNetworks[idx]?.cidr || '';
389
+ const mIsIpv4 = isCIDR.v4(cidr);
390
+ const mIsIpv6 = isCIDR.v6(cidr);
391
+ if (!mIsIpv4 && !mIsIpv6) {
392
+ return true;
393
+ }
394
+ const api = values.apiVips?.[idx]?.ip || '';
395
+ const ing = values.ingressVips?.[idx]?.ip || '';
396
+ if (!api || !ing) {
397
+ return true;
398
+ }
399
+ const apiOk = mIsIpv4 ? isIPv4Address(api) : isIPv6Address(api);
400
+ const ingOk = mIsIpv4 ? isIPv4Address(ing) : isIPv6Address(ing);
401
+ return apiOk && ingOk;
402
+ };
403
+
404
+ const primaryOk = validateIndex(0);
405
+ const secondaryOk = validateIndex(1);
406
+ return primaryOk && secondaryOk;
407
+ },
408
+ );
332
409
 
333
410
  export const ipBlockValidationSchema = (reservedCidrs: string | string[] | undefined) =>
334
411
  Yup.string()
@@ -440,11 +517,11 @@ export const hostPrefixValidationSchema = (
440
517
  const errorMsgIPv4 = `${errorMsgPrefix} (${netBlockNumber}) and 25.`;
441
518
  const errorMsgIPv6 = `${errorMsgPrefix} (8) and 128.`;
442
519
 
443
- if (Address6.isValid(clusterNetworkCidr || '')) {
520
+ if (isCIDR.v6(clusterNetworkCidr || '')) {
444
521
  return Yup.number().required(requiredText).min(8, errorMsgIPv6).max(128, errorMsgIPv6);
445
522
  }
446
523
 
447
- if (Address4.isValid(clusterNetworkCidr || '')) {
524
+ if (isCIDR.v4(clusterNetworkCidr || '')) {
448
525
  return Yup.number()
449
526
  .required(requiredText)
450
527
  .min(netBlockNumber, errorMsgIPv4)
@@ -715,13 +792,52 @@ export const dualStackValidationSchema = (field: string, openshiftVersion?: stri
715
792
  (values?: { cidr: MachineNetwork['cidr'] }[]): boolean => {
716
793
  // For OCP versions > 4.11, allow IPv6 as primary network
717
794
  if (openshiftVersion && isMajorMinorVersionEqualOrGreater(openshiftVersion, '4.12')) {
718
- return (
719
- !!values?.[0].cidr &&
720
- (Address4.isValid(values[0].cidr) || Address6.isValid(values[0].cidr))
721
- );
795
+ return !!values?.[0].cidr && (isCIDR.v4(values[0].cidr) || isCIDR.v6(values[0].cidr));
722
796
  }
723
797
  // For older versions, require IPv4 as primary network
724
- return !!values?.[0].cidr && Address4.isValid(values[0].cidr);
798
+ return !!values?.[0].cidr && isCIDR.v4(values[0].cidr);
799
+ },
800
+ )
801
+ .test(
802
+ 'dual-stack-unique-cidrs',
803
+ `Provided ${field} subnets must be unique.`,
804
+ (values?: { cidr?: MachineNetwork['cidr'] }[]) => {
805
+ if (!values || values.length < 2) {
806
+ return true;
807
+ }
808
+ const first = values[0]?.cidr || '';
809
+ const second = values[1]?.cidr || '';
810
+ if (!first || !second) {
811
+ return true;
812
+ }
813
+ const firstIsCidr = isCIDR.v4(first) || isCIDR.v6(first);
814
+ const secondIsCidr = isCIDR.v4(second) || isCIDR.v6(second);
815
+ if (!firstIsCidr || !secondIsCidr) {
816
+ return true;
817
+ }
818
+ return first !== second;
819
+ },
820
+ )
821
+ .test(
822
+ 'dual-stack-opposite-families',
823
+ `When two ${field} are provided, one must be IPv4 and the other IPv6.`,
824
+ (values?: { cidr?: MachineNetwork['cidr'] }[]) => {
825
+ if (!values || values.length < 2) {
826
+ return true;
827
+ }
828
+ const a = values[0]?.cidr || '';
829
+ const b = values[1]?.cidr || '';
830
+ if (!a || !b) {
831
+ return true;
832
+ }
833
+ const a4 = isCIDR.v4(a);
834
+ const a6 = isCIDR.v6(a);
835
+ const b4 = isCIDR.v4(b);
836
+ const b6 = isCIDR.v6(b);
837
+ if (!((a4 || a6) && (b4 || b6))) {
838
+ return true;
839
+ }
840
+ return (a4 && b6) || (a6 && b4);
725
841
  },
726
842
  );
727
843
 
@@ -8,7 +8,6 @@ import {
8
8
  Grid,
9
9
  } from '@patternfly/react-core';
10
10
  import { FieldArray, useFormikContext } from 'formik';
11
- import { Address6 } from 'ip-address';
12
11
  import {
13
12
  DUAL_STACK,
14
13
  PREFIX_MAX_RESTRICTION,
@@ -33,62 +32,10 @@ const serviceCidrHelperText =
33
32
  'Enter only 1 IP address pool. If you need to access the services from an external network, configure load balancers and routers to manage the traffic.';
34
33
 
35
34
  const AdvancedNetworkFields = () => {
36
- const { values, errors, setFieldValue } = useFormikContext<NetworkConfigurationValues>();
35
+ const { values, errors } = useFormikContext<NetworkConfigurationValues>();
37
36
 
38
37
  const isDualStack = values.stackType === DUAL_STACK;
39
38
 
40
- // Reorder Cluster Networks and Service Networks when Machine Network primary changes
41
- React.useEffect(() => {
42
- if (!isDualStack || !values.machineNetworks?.[0]?.cidr) return;
43
-
44
- const primaryMachineNetworkCidr = values.machineNetworks[0].cidr;
45
- const isPrimaryIPv6 = Address6.isValid(primaryMachineNetworkCidr);
46
-
47
- // Check Cluster Networks
48
- if (values.clusterNetworks && values.clusterNetworks.length >= 2) {
49
- const firstClusterCidr = values.clusterNetworks[0].cidr;
50
- if (!firstClusterCidr) return;
51
-
52
- const isFirstClusterIPv6 = Address6.isValid(firstClusterCidr);
53
-
54
- // If primary machine is IPv6 but first cluster is IPv4, swap them
55
- if (isPrimaryIPv6 && !isFirstClusterIPv6) {
56
- const reordered = [values.clusterNetworks[1], values.clusterNetworks[0]];
57
- setFieldValue('clusterNetworks', reordered, false);
58
- }
59
- // If primary machine is IPv4 but first cluster is IPv6, swap them
60
- else if (!isPrimaryIPv6 && isFirstClusterIPv6) {
61
- const reordered = [values.clusterNetworks[1], values.clusterNetworks[0]];
62
- setFieldValue('clusterNetworks', reordered, false);
63
- }
64
- }
65
-
66
- // Check Service Networks
67
- if (values.serviceNetworks && values.serviceNetworks.length >= 2) {
68
- const firstServiceCidr = values.serviceNetworks[0].cidr;
69
- if (!firstServiceCidr) return;
70
-
71
- const isFirstServiceIPv6 = Address6.isValid(firstServiceCidr);
72
-
73
- // If primary machine is IPv6 but first service is IPv4, swap them
74
- if (isPrimaryIPv6 && !isFirstServiceIPv6) {
75
- const reordered = [values.serviceNetworks[1], values.serviceNetworks[0]];
76
- setFieldValue('serviceNetworks', reordered, false);
77
- }
78
- // If primary machine is IPv4 but first service is IPv6, swap them
79
- else if (!isPrimaryIPv6 && isFirstServiceIPv6) {
80
- const reordered = [values.serviceNetworks[1], values.serviceNetworks[0]];
81
- setFieldValue('serviceNetworks', reordered, false);
82
- }
83
- }
84
- }, [
85
- isDualStack,
86
- values.machineNetworks,
87
- values.clusterNetworks,
88
- values.serviceNetworks,
89
- setFieldValue,
90
- ]);
91
-
92
39
  const clusterNetworkCidrPrefix = (index: number) =>
93
40
  parseInt(
94
41
  ((values.clusterNetworks && values.clusterNetworks[index].cidr) || '').split('/')[1],
@@ -13,6 +13,7 @@ import {
13
13
  } from '../../../../common';
14
14
  import { selectCurrentClusterPermissionsState } from '../../../store/slices/current-cluster/selectors';
15
15
  import { SubnetsDropdown } from './SubnetsDropdown';
16
+ import { reorderNetworksForPrimary } from './reorderNetworks';
16
17
  import { Cluster, MachineNetwork } from '@openshift-assisted/types/assisted-installer-service';
17
18
 
18
19
  const subnetSort = (subA: HostSubnet, subB: HostSubnet) =>
@@ -26,7 +27,7 @@ const useAutoSelectSingleAvailableSubnet = (
26
27
  ) => {
27
28
  useEffect(() => {
28
29
  if (autoSelectNetwork) {
29
- setFieldValue('machineNetworks', [{ cidr, clusterId }], true);
30
+ setFieldValue('machineNetworks', [{ cidr, clusterId }], false);
30
31
  }
31
32
  }, [autoSelectNetwork, cidr, clusterId, setFieldValue]);
32
33
  };
@@ -72,6 +73,41 @@ export const AvailableSubnetsControl = ({
72
73
  const autoSelectNetwork = !isViewerMode && hasEmptySelection;
73
74
  useAutoSelectSingleAvailableSubnet(autoSelectNetwork, setFieldValue, cidr, clusterId);
74
75
 
76
+ // Ensure primary and secondary machine networks are not duplicated.
77
+ // If the user switches primary to the same CIDR as secondary, adjust secondary to the opposite family or clear it.
78
+ React.useEffect(() => {
79
+ if (!isDualStack) {
80
+ return;
81
+ }
82
+ if (!values.machineNetworks || values.machineNetworks.length < 2) {
83
+ return;
84
+ }
85
+ const first = values.machineNetworks?.[0]?.cidr || '';
86
+ const second = values.machineNetworks?.[1]?.cidr || '';
87
+ if (!first || !second) {
88
+ return;
89
+ }
90
+ if (first === second) {
91
+ if (Address6.isValid(first)) {
92
+ const nextIPv4 = IPv4Subnets[0]?.subnet || NO_SUBNET_SET;
93
+ const replacement = nextIPv4 !== first ? nextIPv4 : NO_SUBNET_SET;
94
+ if (replacement !== second) {
95
+ setFieldValue('machineNetworks.1.cidr', replacement, false);
96
+ }
97
+ } else if (Address4.isValid(first)) {
98
+ const nextIPv6 = IPv6Subnets[0]?.subnet || NO_SUBNET_SET;
99
+ const replacement = nextIPv6 !== first ? nextIPv6 : NO_SUBNET_SET;
100
+ if (replacement !== second) {
101
+ setFieldValue('machineNetworks.1.cidr', replacement, false);
102
+ }
103
+ } else {
104
+ if (second !== NO_SUBNET_SET) {
105
+ setFieldValue('machineNetworks.1.cidr', NO_SUBNET_SET, false);
106
+ }
107
+ }
108
+ }
109
+ }, [isDualStack, values.machineNetworks, IPv4Subnets, IPv6Subnets, setFieldValue]);
110
+
75
111
  return (
76
112
  <>
77
113
  <FormGroup
@@ -95,6 +131,9 @@ export const AvailableSubnetsControl = ({
95
131
  name={`machineNetworks.${index}.cidr`}
96
132
  machineSubnets={machineSubnets}
97
133
  isDisabled={isDisabled}
134
+ onAfterSelect={(newSelection) =>
135
+ reorderNetworksForPrimary(newSelection, values, setFieldValue)
136
+ }
98
137
  data-testid={`subnets-dropdown-toggle-${index ? 'ipv6' : 'ipv4'}`}
99
138
  />
100
139
  </StackItem>
@@ -106,6 +145,9 @@ export const AvailableSubnetsControl = ({
106
145
  name={`machineNetworks.0.cidr`}
107
146
  machineSubnets={IPv4Subnets}
108
147
  isDisabled={isDisabled}
148
+ onAfterSelect={(newSelection) =>
149
+ reorderNetworksForPrimary(newSelection, values, setFieldValue)
150
+ }
109
151
  data-testid={'subnets-dropdown-toggle-primary'}
110
152
  />
111
153
  </StackItem>
@@ -18,6 +18,7 @@ import { ConfirmationModal, PopoverIcon } from '../../../../common/components/ui
18
18
  import { useDefaultConfiguration } from '../ClusterDefaultConfigurationContext';
19
19
  import { selectCurrentClusterPermissionsState } from '../../../store/slices/current-cluster/selectors';
20
20
  import { OcmRadioField } from '../../ui/OcmFormFields';
21
+ import { reorderNetworksByCurrentPrimary } from './reorderNetworks';
21
22
  import {
22
23
  Cluster,
23
24
  ClusterNetwork,
@@ -68,11 +69,12 @@ export const StackTypeControlGroup = ({
68
69
  const setSingleStack = React.useCallback(() => {
69
70
  setFieldValue('stackType', IPV4_STACK);
70
71
 
72
+ // Determine whether the first machine network is IPv4
73
+ const firstNetwork = values.machineNetworks?.[0];
74
+ const isFirstIPv4 = Boolean(firstNetwork?.cidr && Address4.isValid(firstNetwork.cidr));
75
+
71
76
  if (values.machineNetworks && values.machineNetworks?.length >= 2) {
72
77
  // For single-stack IPv4, prefer IPv4 machine network
73
- const firstNetwork = values.machineNetworks[0];
74
- const isFirstIPv4 = firstNetwork?.cidr && Address4.isValid(firstNetwork.cidr);
75
-
76
78
  if (isFirstIPv4) {
77
79
  // Keep the first network if it's IPv4
78
80
  setFieldValue('machineNetworks', [firstNetwork]);
@@ -123,6 +125,24 @@ export const StackTypeControlGroup = ({
123
125
  setFieldValue('serviceNetworks', [ipv4Service]);
124
126
  }
125
127
 
128
+ // Keep only IPv4 VIPs when switching to single-stack (unless DHCP allocation is used)
129
+ if (!values.vipDhcpAllocation) {
130
+ const pruneVips = (
131
+ vips?: { ip?: string; clusterId?: Cluster['id'] }[],
132
+ ): { ip: string; clusterId: Cluster['id'] }[] => {
133
+ if (!vips || vips.length === 0) return [];
134
+ // Choose which index to keep based on whether the first network is IPv4
135
+ const keepIndex = isFirstIPv4 ? 0 : 1;
136
+ const chosen = vips[keepIndex];
137
+ return chosen && chosen.ip
138
+ ? [{ ip: chosen.ip, clusterId: chosen.clusterId || clusterId }]
139
+ : [];
140
+ };
141
+
142
+ setFieldValue('apiVips', pruneVips(values.apiVips));
143
+ setFieldValue('ingressVips', pruneVips(values.ingressVips));
144
+ }
145
+
126
146
  void validateForm();
127
147
  }, [
128
148
  setFieldValue,
@@ -133,6 +153,9 @@ export const StackTypeControlGroup = ({
133
153
  values.clusterNetworks,
134
154
  values.machineNetworks,
135
155
  values.serviceNetworks,
156
+ values.apiVips,
157
+ values.ingressVips,
158
+ values.vipDhcpAllocation,
136
159
  ]);
137
160
 
138
161
  const setDualStack = () => {
@@ -193,6 +216,8 @@ export const StackTypeControlGroup = ({
193
216
  false,
194
217
  );
195
218
  }
219
+ // Ensure cluster/service networks ordering matches the new primary family
220
+ reorderNetworksByCurrentPrimary(values, setFieldValue);
196
221
  };
197
222
 
198
223
  const setStackType = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -22,6 +22,7 @@ type SubnetsDropdownProps = {
22
22
  name: string;
23
23
  machineSubnets: HostSubnet[];
24
24
  isDisabled: boolean;
25
+ onAfterSelect?: (selectedValue: string) => void;
25
26
  };
26
27
 
27
28
  const toFormSelectOptions = (subnets: HostSubnet[]) => {
@@ -51,6 +52,7 @@ export const SubnetsDropdown = ({
51
52
  name,
52
53
  machineSubnets,
53
54
  isDisabled,
55
+ onAfterSelect,
54
56
  ...props
55
57
  }: SubnetsDropdownProps & MenuToggleProps) => {
56
58
  const [field, , { setValue }] = useField(name);
@@ -142,8 +144,12 @@ export const SubnetsDropdown = ({
142
144
  }, [machineSubnets, ipv4Subnets, ipv6Subnets, itemsSubnets]);
143
145
 
144
146
  const onSelect = (event?: React.MouseEvent<Element, MouseEvent>): void => {
145
- setValue(event?.currentTarget.id);
147
+ const nextValue = event?.currentTarget.id;
148
+ setValue(nextValue);
146
149
  setOpen(false);
150
+ if (onAfterSelect && nextValue) {
151
+ onAfterSelect(nextValue);
152
+ }
147
153
  };
148
154
 
149
155
  const currentItem = itemsSubnets.find((i) => i.label === currentDisplayValue);
@@ -34,7 +34,7 @@ export const getNetworkInitialValues = (
34
34
  isClusterManagedNetworkingUnsupported: boolean,
35
35
  ): NetworkConfigurationValues => {
36
36
  const isSNOCluster = isSNO(cluster);
37
- const isDualStackType = isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion });
37
+ const isDualStackType = isDualStack(cluster);
38
38
 
39
39
  return {
40
40
  apiVips: cluster.apiVips,
@@ -74,17 +74,9 @@ export const getNetworkConfigurationValidationSchema = (
74
74
  hostSubnets: HostSubnets,
75
75
  openshiftVersion?: string,
76
76
  ) =>
77
- Yup.lazy((values: NetworkConfigurationValues) => {
78
- const apiVipSchema =
79
- values.stackType === DUAL_STACK
80
- ? vipArrayValidationSchema<ApiVip>(hostSubnets, values, initialValues.apiVips).min(
81
- 2,
82
- 'Provide both Primary and Secondary API IPs.',
83
- )
84
- : vipArrayValidationSchema<ApiVip>(hostSubnets, values, initialValues.apiVips);
85
-
86
- return Yup.object<NetworkConfigurationValues>().shape({
87
- apiVips: apiVipSchema,
77
+ Yup.lazy((values: NetworkConfigurationValues) =>
78
+ Yup.object<NetworkConfigurationValues>().shape({
79
+ apiVips: vipArrayValidationSchema<ApiVip>(hostSubnets, values, initialValues.apiVips),
88
80
  ingressVips: vipArrayValidationSchema<IngressVip>(
89
81
  hostSubnets,
90
82
  values,
@@ -124,5 +116,5 @@ export const getNetworkConfigurationValidationSchema = (
124
116
  )
125
117
  : Yup.array(),
126
118
  }),
127
- });
128
- });
119
+ }),
120
+ );
@@ -0,0 +1,72 @@
1
+ import { Address6 } from 'ip-address';
2
+ import { DUAL_STACK, NetworkConfigurationValues } from '../../../../common';
3
+ import type { FormikHelpers } from 'formik';
4
+
5
+ const reorderByPrimaryCidr = (
6
+ primaryCidr: string,
7
+ values: NetworkConfigurationValues,
8
+ setFieldValue: FormikHelpers<NetworkConfigurationValues>['setFieldValue'],
9
+ ) => {
10
+ const isDualStack = values.stackType === DUAL_STACK;
11
+ if (!isDualStack || !primaryCidr) return;
12
+
13
+ const isPrimaryIPv6 = Address6.isValid(primaryCidr);
14
+
15
+ // Reorder Cluster Networks
16
+ if (Array.isArray(values.clusterNetworks) && values.clusterNetworks.length >= 2) {
17
+ const [first, second] = values.clusterNetworks;
18
+ const firstClusterCidr = first?.cidr;
19
+ const secondClusterCidr = second?.cidr;
20
+ if (firstClusterCidr && secondClusterCidr) {
21
+ const isFirstClusterIPv6 = Address6.isValid(firstClusterCidr);
22
+ const isSecondClusterIPv6 = Address6.isValid(secondClusterCidr);
23
+ if (isFirstClusterIPv6 !== isSecondClusterIPv6) {
24
+ if (
25
+ (isPrimaryIPv6 && !isFirstClusterIPv6 && isSecondClusterIPv6) ||
26
+ (!isPrimaryIPv6 && isFirstClusterIPv6 && !isSecondClusterIPv6)
27
+ ) {
28
+ const reordered = [values.clusterNetworks[1], values.clusterNetworks[0]];
29
+ setFieldValue('clusterNetworks', reordered, false);
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ // Reorder Service Networks
36
+ if (Array.isArray(values.serviceNetworks) && values.serviceNetworks.length >= 2) {
37
+ const [first, second] = values.serviceNetworks;
38
+ const firstServiceCidr = first?.cidr;
39
+ const secondServiceCidr = second?.cidr;
40
+ if (firstServiceCidr && secondServiceCidr) {
41
+ const isFirstServiceIPv6 = Address6.isValid(firstServiceCidr);
42
+ const isSecondServiceIPv6 = Address6.isValid(secondServiceCidr);
43
+ if (isFirstServiceIPv6 !== isSecondServiceIPv6) {
44
+ if (
45
+ (isPrimaryIPv6 && !isFirstServiceIPv6 && isSecondServiceIPv6) ||
46
+ (!isPrimaryIPv6 && isFirstServiceIPv6 && !isSecondServiceIPv6)
47
+ ) {
48
+ const reordered = [values.serviceNetworks[1], values.serviceNetworks[0]];
49
+ setFieldValue('serviceNetworks', reordered, false);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ };
55
+
56
+ export const reorderNetworksForPrimary = (
57
+ nextPrimaryCidr: string,
58
+ values: NetworkConfigurationValues,
59
+ setFieldValue: FormikHelpers<NetworkConfigurationValues>['setFieldValue'],
60
+ ) => {
61
+ if (!nextPrimaryCidr) return;
62
+ reorderByPrimaryCidr(nextPrimaryCidr, values, setFieldValue);
63
+ };
64
+
65
+ export const reorderNetworksByCurrentPrimary = (
66
+ values: NetworkConfigurationValues,
67
+ setFieldValue: FormikHelpers<NetworkConfigurationValues>['setFieldValue'],
68
+ ) => {
69
+ const primaryCidr = values.machineNetworks?.[0]?.cidr;
70
+ if (!primaryCidr) return;
71
+ reorderByPrimaryCidr(primaryCidr, values, setFieldValue);
72
+ };
@@ -1,12 +1,7 @@
1
1
  import { Title } from '@patternfly/react-core';
2
2
  import { Table, TableVariant, Tbody, Td, Tr } from '@patternfly/react-table';
3
3
  import React from 'react';
4
- import {
5
- genericTableRowKey,
6
- isDualStack,
7
- selectApiVip,
8
- selectIngressVip,
9
- } from '../../../../common';
4
+ import { genericTableRowKey, isDualStack } from '../../../../common';
10
5
  import {
11
6
  getManagementType,
12
7
  getStackTypeLabel,
@@ -58,7 +53,7 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => {
58
53
  )),
59
54
  props: { 'data-testid': 'machine-networks' },
60
55
  },
61
- isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion })
56
+ isDualStack(cluster)
62
57
  ? {
63
58
  title: cluster.machineNetworks?.map((network, index) => (
64
59
  <span key={network.cidr}>
@@ -78,9 +73,24 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => {
78
73
  cells: [
79
74
  { title: 'API IP' },
80
75
  {
81
- title: selectApiVip(cluster),
82
- props: { 'data-testid': 'api-vip', colSpan: 2 },
76
+ title: cluster.apiVips?.map((apiVip) => (
77
+ <span key={apiVip.ip}>
78
+ {apiVip.ip}
79
+ <br />
80
+ </span>
81
+ )),
82
+ props: { 'data-testid': 'api-vip' },
83
83
  },
84
+ isDualStack(cluster)
85
+ ? {
86
+ title: cluster.apiVips?.map((apiVip, index) => (
87
+ <span key={apiVip.ip}>
88
+ {index === 0 ? 'Primary' : 'Secondary'}
89
+ <br />
90
+ </span>
91
+ )),
92
+ }
93
+ : { title: '' },
84
94
  ],
85
95
  });
86
96
 
@@ -91,9 +101,24 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => {
91
101
  cells: [
92
102
  { title: 'Ingress IP' },
93
103
  {
94
- title: selectIngressVip(cluster),
95
- props: { 'data-testid': 'ingress-vip', colSpan: 2 },
104
+ title: cluster.ingressVips?.map((ingressVip) => (
105
+ <span key={ingressVip.ip}>
106
+ {ingressVip.ip}
107
+ <br />
108
+ </span>
109
+ )),
110
+ props: { 'data-testid': 'ingress-vip' },
96
111
  },
112
+ isDualStack(cluster)
113
+ ? {
114
+ title: cluster.ingressVips?.map((ingressVip, index) => (
115
+ <span key={ingressVip.ip}>
116
+ {index === 0 ? 'Primary' : 'Secondary'}
117
+ <br />
118
+ </span>
119
+ )),
120
+ }
121
+ : { title: '' },
97
122
  ],
98
123
  });
99
124
 
@@ -115,7 +140,7 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => {
115
140
  )),
116
141
  props: { 'data-testid': 'cluster-network-cidr' },
117
142
  },
118
- isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) && {
143
+ isDualStack(cluster) && {
119
144
  title: cluster.clusterNetworks?.map((network, index) => (
120
145
  <span key={network.cidr}>
121
146
  {index === 0 ? 'Primary' : 'Secondary'}
@@ -138,7 +163,7 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => {
138
163
  )),
139
164
  props: { 'data-testid': 'cluster-network-prefix' },
140
165
  },
141
- isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) && {
166
+ isDualStack(cluster) && {
142
167
  title: cluster.clusterNetworks?.map((network, index) => (
143
168
  <span key={network.hostPrefix}>
144
169
  {index === 0 ? 'Primary' : 'Secondary'}
@@ -161,7 +186,7 @@ export const ReviewNetworkingTable = ({ cluster }: { cluster: Cluster }) => {
161
186
  )),
162
187
  props: { 'data-testid': 'service-network-cidr' },
163
188
  },
164
- isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) && {
189
+ isDualStack(cluster) && {
165
190
  title: cluster.serviceNetworks?.map((network, index) => (
166
191
  <span key={network.cidr}>
167
192
  {index === 0 ? 'Primary' : 'Secondary'}
@@ -41,7 +41,7 @@ export const getManagementType = ({ userManagedNetworking }: Cluster): string =>
41
41
  };
42
42
 
43
43
  export const getStackTypeLabel = (cluster: Cluster): string =>
44
- isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion }) ? 'Dual-stack' : 'IPv4';
44
+ isDualStack(cluster) ? 'Dual-stack' : 'IPv4';
45
45
 
46
46
  export const getDiskEncryptionEnabledOnStatus = (diskEncryption: DiskEncryption['enableOn']) => {
47
47
  let diskEncryptionType = null;
@@ -67,7 +67,7 @@ export const getDiskEncryptionEnabledOnStatus = (diskEncryption: DiskEncryption[
67
67
 
68
68
  const ClusterProperties = ({ cluster, externalMode = false }: ClusterPropertiesProps) => {
69
69
  const { t } = useTranslation();
70
- const isDualStackType = isDualStack({ ...cluster, openshiftVersion: cluster.openshiftVersion });
70
+ const isDualStackType = isDualStack(cluster);
71
71
  const featureSupportLevelContext = useNewFeatureSupportLevel();
72
72
 
73
73
  const activeFeatureConfiguration = featureSupportLevelContext.activeFeatureConfiguration;