@rancher/shell 3.0.9-rc.5 → 3.0.9-rc.6

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 (142) hide show
  1. package/assets/images/providers/oci-open-containers.svg +22 -0
  2. package/assets/images/providers/traefik.png +0 -0
  3. package/assets/styles/themes/_dark.scss +2 -0
  4. package/assets/styles/themes/_light.scss +2 -0
  5. package/assets/styles/themes/_modern.scss +6 -0
  6. package/assets/translations/en-us.yaml +129 -25
  7. package/components/CruResource.vue +3 -1
  8. package/components/ExplorerProjectsNamespaces.vue +12 -12
  9. package/components/Resource/Detail/Card/StatusCard/__tests__/StatusCard.test.ts +109 -0
  10. package/components/Resource/Detail/Card/StatusCard/index.vue +21 -4
  11. package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +19 -2
  12. package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +19 -11
  13. package/components/Resource/Detail/ResourcePopover/__tests__/index.test.ts +12 -0
  14. package/components/Resource/Detail/ResourcePopover/index.vue +2 -0
  15. package/components/Resource/Detail/ResourceRow.vue +2 -2
  16. package/components/ResourceList/index.vue +7 -4
  17. package/components/Window/ContainerLogs.vue +48 -37
  18. package/components/fleet/FleetClusterTargets/TargetsList.vue +2 -2
  19. package/components/fleet/FleetClusterTargets/index.vue +6 -1
  20. package/components/fleet/GitRepoAdvancedTab.vue +333 -0
  21. package/components/fleet/GitRepoMetadataTab.vue +43 -0
  22. package/components/fleet/GitRepoRepositoryTab.vue +101 -0
  23. package/components/fleet/GitRepoTargetTab.vue +77 -0
  24. package/components/fleet/HelmOpAdvancedTab.vue +247 -0
  25. package/components/fleet/HelmOpChartTab.vue +158 -0
  26. package/components/fleet/HelmOpMetadataTab.vue +46 -0
  27. package/components/fleet/HelmOpTargetTab.vue +84 -0
  28. package/components/fleet/HelmOpValuesTab.vue +147 -0
  29. package/components/fleet/__tests__/FleetClusterTargets.test.ts +119 -70
  30. package/components/form/NodeScheduling.vue +81 -7
  31. package/components/form/PodAffinity.vue +1 -36
  32. package/components/form/ResourceLabeledSelect.vue +8 -4
  33. package/components/form/ResourceQuota/Namespace.vue +30 -9
  34. package/components/form/ResourceQuota/NamespaceRow.vue +25 -7
  35. package/components/form/ResourceQuota/Project.vue +140 -82
  36. package/components/form/ResourceQuota/ResourceQuotaEntry.vue +145 -0
  37. package/components/form/ResourceQuota/__tests__/Namespace.test.ts +307 -0
  38. package/components/form/ResourceQuota/__tests__/NamespaceRow.test.ts +281 -0
  39. package/components/form/ResourceQuota/__tests__/Project.test.ts +274 -27
  40. package/components/form/ResourceQuota/__tests__/ResourceQuotaEntry.test.ts +215 -0
  41. package/components/form/SchedulingCustomization.vue +14 -6
  42. package/components/form/SelectOrCreateAuthSecret.vue +107 -18
  43. package/components/form/__tests__/NodeScheduling.test.ts +12 -9
  44. package/components/form/__tests__/PodAffinity.test.ts +21 -2
  45. package/components/form/__tests__/SchedulingCustomization.test.ts +240 -0
  46. package/components/formatter/ClusterLink.vue +8 -0
  47. package/components/formatter/SecretOrigin.vue +79 -0
  48. package/config/labels-annotations.js +7 -6
  49. package/config/pagination-table-headers.js +6 -4
  50. package/config/product/explorer.js +1 -11
  51. package/config/query-params.js +3 -0
  52. package/config/settings.ts +15 -2
  53. package/config/table-headers.js +21 -17
  54. package/config/types.js +23 -8
  55. package/detail/workload/index.vue +11 -16
  56. package/dialog/DeactivateDriverDialog.vue +1 -1
  57. package/dialog/Ipv6NetworkingDialog.vue +156 -0
  58. package/dialog/ScalePoolDownDialog.vue +2 -2
  59. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +1 -1
  60. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +1 -0
  61. package/edit/__tests__/management.cattle.io.project.test.js +56 -128
  62. package/edit/auth/oidc.vue +1 -1
  63. package/edit/catalog.cattle.io.clusterrepo.vue +155 -25
  64. package/edit/fleet.cattle.io.gitrepo.vue +153 -283
  65. package/edit/fleet.cattle.io.helmop.vue +190 -332
  66. package/edit/management.cattle.io.project.vue +5 -42
  67. package/edit/management.cattle.io.setting.vue +6 -0
  68. package/edit/provisioning.cattle.io.cluster/__tests__/Basics.test.ts +55 -24
  69. package/edit/provisioning.cattle.io.cluster/__tests__/Networking.test.ts +1 -103
  70. package/edit/provisioning.cattle.io.cluster/__tests__/index.test.ts +13 -1
  71. package/edit/provisioning.cattle.io.cluster/__tests__/rke2-fleet-cluster-agent.test.ts +283 -0
  72. package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +65 -49
  73. package/edit/provisioning.cattle.io.cluster/ingress/IngressCards.vue +112 -0
  74. package/edit/provisioning.cattle.io.cluster/ingress/IngressConfiguration.vue +158 -0
  75. package/edit/provisioning.cattle.io.cluster/rke2.vue +171 -72
  76. package/edit/provisioning.cattle.io.cluster/shared.ts +36 -1
  77. package/edit/provisioning.cattle.io.cluster/tabs/AgentConfiguration.vue +2 -1
  78. package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +55 -7
  79. package/edit/provisioning.cattle.io.cluster/tabs/Ingress.vue +319 -0
  80. package/edit/provisioning.cattle.io.cluster/tabs/MachinePool.vue +2 -1
  81. package/edit/provisioning.cattle.io.cluster/tabs/etcd/__tests__/S3Config.test.ts +13 -1
  82. package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +10 -44
  83. package/edit/secret/index.vue +1 -1
  84. package/edit/token.vue +68 -29
  85. package/edit/workload/__tests__/index.test.ts +2 -37
  86. package/edit/workload/index.vue +6 -2
  87. package/edit/workload/mixins/workload.js +0 -32
  88. package/list/__tests__/management.cattle.io.setting.test.ts +198 -0
  89. package/list/management.cattle.io.setting.vue +13 -0
  90. package/list/provisioning.cattle.io.cluster.vue +50 -1
  91. package/list/secret.vue +4 -9
  92. package/list/service.vue +6 -8
  93. package/machine-config/amazonec2.vue +11 -4
  94. package/machine-config/components/EC2Networking.vue +46 -30
  95. package/machine-config/components/__tests__/EC2Networking.test.ts +7 -7
  96. package/machine-config/components/__tests__/utils/vpcSubnetMockData.js +0 -9
  97. package/machine-config/digitalocean.vue +3 -3
  98. package/models/__tests__/namespace.test.ts +11 -0
  99. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +96 -0
  100. package/models/__tests__/workload.test.ts +42 -1
  101. package/models/catalog.cattle.io.clusterrepo.js +30 -4
  102. package/models/ext.cattle.io.token.js +48 -0
  103. package/models/kontainerdriver.js +2 -2
  104. package/models/namespace.js +7 -1
  105. package/models/nodedriver.js +2 -2
  106. package/models/provisioning.cattle.io.cluster.js +28 -7
  107. package/models/secret.js +0 -17
  108. package/models/service.js +44 -1
  109. package/models/token.js +4 -0
  110. package/models/workload.js +12 -6
  111. package/package.json +1 -1
  112. package/pages/account/index.vue +96 -67
  113. package/pages/auth/setup.vue +5 -14
  114. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +4 -1
  115. package/pages/c/_cluster/apps/charts/index.vue +93 -4
  116. package/pages/c/_cluster/apps/charts/install.vue +317 -42
  117. package/pages/c/_cluster/manager/drivers/kontainerDriver/index.vue +5 -4
  118. package/pages/c/_cluster/settings/index.vue +3 -1
  119. package/plugins/dashboard-store/__tests__/getters.test.ts +108 -0
  120. package/plugins/dashboard-store/__tests__/resource-class.test.ts +27 -0
  121. package/plugins/dashboard-store/actions.js +3 -8
  122. package/plugins/dashboard-store/getters.js +7 -5
  123. package/plugins/dashboard-store/mutations.js +4 -1
  124. package/plugins/dashboard-store/resource-class.js +3 -3
  125. package/plugins/steve/__tests__/steve-class.test.ts +102 -141
  126. package/plugins/steve/steve-class.js +12 -3
  127. package/plugins/steve/steve-pagination-utils.ts +6 -2
  128. package/rancher-components/RcIcon/types.ts +2 -0
  129. package/rancher-components/RcItemCard/RcItemCard.vue +64 -19
  130. package/store/prefs.js +3 -0
  131. package/types/aws-sdk.d.ts +121 -0
  132. package/types/resources/node.ts +15 -0
  133. package/types/shell/index.d.ts +536 -506
  134. package/types/store/pagination.types.ts +5 -5
  135. package/utils/__tests__/array.test.ts +1 -29
  136. package/utils/__tests__/cluster-agent-configuration.test.ts +203 -0
  137. package/utils/array.ts +0 -11
  138. package/utils/aws.ts +21 -0
  139. package/utils/cluster.js +22 -2
  140. package/utils/selector-typed.ts +1 -1
  141. package/components/__tests__/ProjectRow.test.ts +0 -206
  142. package/components/form/ResourceQuota/ProjectRow.vue +0 -277
@@ -1,17 +1,24 @@
1
- <script>
1
+ <script lang="ts">
2
2
  import { mapGetters } from 'vuex';
3
3
  import { RadioGroup } from '@components/Form/Radio';
4
- import LabeledSelect from '@shell/components/form/LabeledSelect';
5
- import NodeAffinity from '@shell/components/form/NodeAffinity';
4
+ import ResourceLabeledSelect from '@shell/components/form/ResourceLabeledSelect.vue';
5
+ import NodeAffinity from '@shell/components/form/NodeAffinity.vue';
6
6
  import { HARVESTER_NAME as VIRTUAL } from '@shell/config/features';
7
7
  import { _VIEW } from '@shell/config/query-params';
8
8
  import { isEmpty } from '@shell/utils/object';
9
9
  import { HOSTNAME } from '@shell/config/labels-annotations';
10
+ import { ResourceLabeledSelectPaginateSettings, ResourceLabeledSelectSettings } from '@shell/types/components/resourceLabeledSelect';
11
+ import { NODE } from '@shell/config/types';
12
+ import { LabelSelectPaginationFunctionOptions } from '@shell/components/form/labeled-select-utils/labeled-select.utils';
13
+ import { PaginationFilterEquality, PaginationParamFilter } from '@shell/types/store/pagination.types';
14
+ import { KubeNode, KubeNodeTaint } from '@shell/types/resources/node';
15
+
16
+ const parseNode = (node: string | KubeNode) => typeof node === 'string' ? node : node.id;
10
17
 
11
18
  export default {
12
19
  components: {
13
20
  RadioGroup,
14
- LabeledSelect,
21
+ ResourceLabeledSelect,
15
22
  NodeAffinity,
16
23
  },
17
24
 
@@ -23,6 +30,9 @@ export default {
23
30
  }
24
31
  },
25
32
 
33
+ /**
34
+ * HARVESTER ONLY PROPERTY
35
+ */
26
36
  nodes: {
27
37
  type: Array,
28
38
  default: () => []
@@ -39,12 +49,71 @@ export default {
39
49
  },
40
50
  },
41
51
 
42
- data() {
52
+ data(): {
53
+ selectNode: string | null;
54
+ nodeName: string;
55
+ nodeAffinity: any;
56
+ nodeSelector: any;
57
+ nodeSchedulingAllSettings: ResourceLabeledSelectSettings;
58
+ nodeSchedulingPaginationSettings: ResourceLabeledSelectPaginateSettings;
59
+ NODE: string;
60
+ } {
61
+ const keys = [
62
+ `node-role.kubernetes.io/control-plane`,
63
+ `node-role.kubernetes.io/etcd`
64
+ ];
65
+
66
+ // Settings used by ResourceLabeledSelect when node pagination disabled
67
+ const nodeSchedulingAllSettings: ResourceLabeledSelectSettings = {
68
+ updateResources(nodes: (string | KubeNode)[]) {
69
+ return nodes
70
+ .filter((node) => {
71
+ if (typeof node === 'string') {
72
+ // Already passed check
73
+ return true;
74
+ }
75
+
76
+ const taints = node.spec?.taints || [];
77
+
78
+ return taints.every((taint: KubeNodeTaint) => !keys.includes(taint.key));
79
+ })
80
+ .map(parseNode);
81
+ },
82
+ };
83
+
84
+ // Settings used by ResourceLabeledSelect when node pagination enabled
85
+ const nodeSchedulingPaginationSettings: ResourceLabeledSelectPaginateSettings = {
86
+ updateResources(nodes: (string | KubeNode)[]) {
87
+ return nodes.map(parseNode);
88
+ },
89
+ requestSettings: (opts: LabelSelectPaginationFunctionOptions) => {
90
+ const { filter } = opts.opts;
91
+ const filters = !!filter ? [
92
+ PaginationParamFilter.createSingleField({
93
+ field: 'metadata.name', value: filter, exact: false
94
+ })
95
+ ] : [];
96
+
97
+ filters.push(...keys.map((k) => PaginationParamFilter.createSingleField( ({
98
+ field: 'spec.taints.key', value: k, equality: PaginationFilterEquality.NOT_CONTAINS
99
+ }))));
100
+
101
+ opts.filters = filters;
102
+ opts.groupByNamespace = false;
103
+ opts.sort = [{ asc: true, field: 'metadata.name' }];
104
+
105
+ return opts;
106
+ }
107
+ };
108
+
43
109
  return {
44
110
  selectNode: null,
45
111
  nodeName: '',
46
112
  nodeAffinity: {},
47
113
  nodeSelector: {},
114
+ nodeSchedulingAllSettings,
115
+ nodeSchedulingPaginationSettings,
116
+ NODE
48
117
  };
49
118
  },
50
119
 
@@ -147,6 +216,9 @@ export default {
147
216
  watch: {
148
217
  'value.nodeSelector': {
149
218
  handler(nodeSelector) {
219
+ // Harvester specific code should not live in rancher/dashboard components
220
+ // This was brought into harvester/dashboard via https://github.com/harvester/dashboard/pull/342
221
+ // rancher/dashboard via https://github.com/rancher/dashboard/pull/6310
150
222
  if (this.isHarvester && nodeSelector?.[HOSTNAME]) {
151
223
  this.selectNode = 'nodeSelector';
152
224
  const nodeName = nodeSelector[HOSTNAME];
@@ -187,14 +259,16 @@ export default {
187
259
  <template v-if="selectNode === 'nodeSelector'">
188
260
  <div class="row">
189
261
  <div class="col span-6">
190
- <LabeledSelect
262
+ <ResourceLabeledSelect
191
263
  v-model:value="nodeName"
192
264
  :label="t('workload.scheduling.affinity.nodeName')"
193
- :options="nodes || []"
265
+ :resource-type="NODE"
194
266
  :mode="mode"
195
267
  :multiple="false"
196
268
  :loading="loading"
197
269
  :data-testid="'node-scheduling-nodeSelector'"
270
+ :allResourcesSettings="nodeSchedulingAllSettings"
271
+ :paginatedResourceSettings="nodeSchedulingPaginationSettings"
198
272
  @update:value="update"
199
273
  />
200
274
  </div>
@@ -2,7 +2,7 @@
2
2
  import { mapGetters } from 'vuex';
3
3
  import { _VIEW } from '@shell/config/query-params';
4
4
  import { get, set, isEmpty, clone } from '@shell/utils/object';
5
- import { POD, NODE, NAMESPACE } from '@shell/config/types';
5
+ import { POD, NAMESPACE } from '@shell/config/types';
6
6
  import MatchExpressions from '@shell/components/form/MatchExpressions';
7
7
  import LabeledSelect from '@shell/components/form/LabeledSelect';
8
8
  import { RadioGroup } from '@components/Form/Radio';
@@ -11,7 +11,6 @@ import { randomStr } from '@shell/utils/string';
11
11
  import { sortBy } from '@shell/utils/sort';
12
12
  import debounce from 'lodash/debounce';
13
13
  import ArrayListGrouped from '@shell/components/form/ArrayListGrouped';
14
- import { getUniqueLabelKeys } from '@shell/utils/array';
15
14
 
16
15
  const NAMESPACE_SELECTION_OPTION_VALUES = {
17
16
  POD: 'pod',
@@ -47,11 +46,6 @@ export default {
47
46
  default: 'create'
48
47
  },
49
48
 
50
- nodes: {
51
- type: Array,
52
- default: () => []
53
- },
54
-
55
49
  namespaces: {
56
50
  type: Array,
57
51
  default: null
@@ -110,10 +104,6 @@ export default {
110
104
  return POD;
111
105
  },
112
106
 
113
- node() {
114
- return NODE;
115
- },
116
-
117
107
  labeledInputNamespaceLabel() {
118
108
  return this.removeLabeledInputNamespaceLabel ? '' : this.overwriteLabels?.namespaceInputLabel || this.t('workload.scheduling.affinity.matchExpressions.inNamespaces');
119
109
  },
@@ -132,14 +122,6 @@ export default {
132
122
  return out;
133
123
  },
134
124
 
135
- existingNodeLabels() {
136
- return getUniqueLabelKeys(this.nodes);
137
- },
138
-
139
- hasNodes() {
140
- return this.nodes.length;
141
- },
142
-
143
125
  namespaceSelectionOptions() {
144
126
  if (this.allNamespacesOptionAvailable) {
145
127
  return [
@@ -440,24 +422,7 @@ export default {
440
422
  />
441
423
  <div class="row mt-20">
442
424
  <div class="col span-9">
443
- <LabeledSelect
444
- v-if="hasNodes"
445
- v-model:value="props.row.value.topologyKey"
446
- :taggable="true"
447
- :searchable="true"
448
- :close-on-select="false"
449
- :mode="mode"
450
- required
451
- :label="t('workload.scheduling.affinity.topologyKey.label')"
452
- :placeholder="topologyKeyPlaceholder"
453
- :options="existingNodeLabels"
454
- :disabled="mode==='view'"
455
- :loading="loading"
456
- :data-testid="`pod-affinity-topology-select-index${props.i}`"
457
- @update:value="update"
458
- />
459
425
  <LabeledInput
460
- v-else
461
426
  v-model:value="props.row.value.topologyKey"
462
427
  :mode="mode"
463
428
  :label="t('workload.scheduling.affinity.topologyKey.label')"
@@ -137,13 +137,17 @@ export default defineComponent({
137
137
  const filters = !!filter ? [PaginationParamFilter.createSingleField({
138
138
  field: 'metadata.name', value: filter, exact: false
139
139
  })] : [];
140
+ const schema = this.$store.getters[`${ this.validInStore }/schema`](this.resourceType);
141
+ const namespaced = typeof schema?.attributes?.namespaced !== 'undefined' ? schema.attributes.namespaced : false;
142
+
140
143
  const defaultOptions: LabelSelectPaginationFunctionOptions = {
141
144
  opts,
142
145
  filters,
143
- type: this.resourceType,
144
- ctx: { getters: this.$store.getters, dispatch: this.$store.dispatch },
145
- sort: [{ asc: true, field: 'metadata.name' }],
146
- store: this.validInStore
146
+ type: this.resourceType,
147
+ ctx: { getters: this.$store.getters, dispatch: this.$store.dispatch },
148
+ sort: [{ asc: true, field: 'metadata.name' }],
149
+ store: this.validInStore,
150
+ groupByNamespace: namespaced,
147
151
  };
148
152
  const options = this.paginatedResourceSettings?.requestSettings ? this.paginatedResourceSettings.requestSettings(defaultOptions) : defaultOptions;
149
153
  const res = await labelSelectPaginationFunction(options);
@@ -35,11 +35,11 @@ export default {
35
35
  computed: {
36
36
  ...QUOTA_COMPUTED,
37
37
  projectResourceQuotaLimits() {
38
- return this.project?.spec?.resourceQuota?.limit || {};
38
+ return this.flatListFromLimits(this.project?.spec?.resourceQuota?.limit || {});
39
39
  },
40
40
  namespaceResourceQuotaLimits() {
41
41
  return this.project.namespaces.map((namespace) => ({
42
- ...namespace.resourceQuota.limit,
42
+ ...this.flatListFromLimits(namespace.resourceQuota.limit),
43
43
  id: namespace.id
44
44
  }));
45
45
  },
@@ -47,7 +47,7 @@ export default {
47
47
  return Object.keys(this.projectResourceQuotaLimits);
48
48
  },
49
49
  defaultResourceQuotaLimits() {
50
- return this.project.spec.namespaceDefaultResourceQuota.limit;
50
+ return this.flatListFromLimits(this.project.spec.namespaceDefaultResourceQuota.limit || {});
51
51
  }
52
52
  },
53
53
 
@@ -57,14 +57,35 @@ export default {
57
57
  .filter((type) => !this.types.includes(type.value) || type.value === currentType);
58
58
  },
59
59
  update(key, value) {
60
- const resourceQuota = {
61
- limit: {
62
- ...this.value.resourceQuota.limit,
63
- [key]: value
60
+ this.value['resourceQuota'] = { limit: this.limitsFromFlatList(key, value) };
61
+ },
62
+ flatListFromLimits(limit) {
63
+ const result = {};
64
+
65
+ Object.keys(limit || {}).forEach((key) => {
66
+ if (key === 'extended') {
67
+ Object.keys(limit.extended || {}).forEach((extKey) => {
68
+ result[`extended.${ extKey }`] = limit.extended[extKey];
69
+ });
70
+ } else {
71
+ result[key] = limit[key];
64
72
  }
65
- };
73
+ });
74
+
75
+ return result;
76
+ },
77
+ limitsFromFlatList(key, value) {
78
+ const limit = { ...this.value.resourceQuota.limit };
79
+
80
+ if (key.startsWith('extended.')) {
81
+ const resourceIdentifier = key.slice('extended.'.length);
82
+
83
+ limit.extended = { ...(limit.extended || {}), [resourceIdentifier]: value };
84
+ } else {
85
+ limit[key] = value;
86
+ }
66
87
 
67
- this.value['resourceQuota'] = resourceQuota;
88
+ return limit;
68
89
  }
69
90
  },
70
91
  };
@@ -61,17 +61,35 @@ export default {
61
61
  // We want to update the value first so that the value will be rounded to the project limit.
62
62
  // This is relevant when switching projects. If the value is 1200 and the project that it was
63
63
  // switched to only has capacity for 800 more this will force the value to be set to 800.
64
- if (this.value?.limit?.[this.type]) {
65
- this.update(this.value.limit[this.type]);
64
+ if (this.currentLimit) {
65
+ this.update(this.currentLimit);
66
66
  }
67
67
 
68
- if (!this.value?.limit?.[this.type]) {
68
+ if (!this.currentLimit) {
69
69
  this.update(this.defaultResourceQuotaLimits[this.type]);
70
70
  }
71
71
  },
72
72
 
73
73
  computed: {
74
74
  ...ROW_COMPUTED,
75
+ currentLimit() {
76
+ const limit = this.value.limit || {};
77
+
78
+ if (this.type.startsWith('extended.')) {
79
+ const resourceIdentifier = this.type.slice('extended.'.length);
80
+
81
+ return (limit.extended || {})[resourceIdentifier];
82
+ }
83
+
84
+ return limit[this.type];
85
+ },
86
+ displayType() {
87
+ if (this.type.startsWith('extended.')) {
88
+ return this.type.slice('extended.'.length);
89
+ }
90
+
91
+ return this.type;
92
+ },
75
93
  limitValue() {
76
94
  return parseSi(this.projectResourceQuotaLimits[this.type]);
77
95
  },
@@ -92,7 +110,7 @@ export default {
92
110
  return this.namespaceLimits.reduce((sum, limit) => sum + limit, 0);
93
111
  },
94
112
  totalContribution() {
95
- return this.namespaceContribution + parseSi(this.value.limit[this.type] || '0', this.siOptions);
113
+ return this.namespaceContribution + parseSi(this.currentLimit || '0', this.siOptions);
96
114
  },
97
115
  percentageUsed() {
98
116
  return Math.min(this.totalContribution * 100 / this.projectLimit, 100);
@@ -127,7 +145,7 @@ export default {
127
145
  },
128
146
  {
129
147
  label: t('resourceQuota.tooltip.namespace'),
130
- value: this.value.limit[this.type]
148
+ value: this.currentLimit
131
149
  },
132
150
  {
133
151
  label: t('resourceQuota.tooltip.available'),
@@ -178,7 +196,7 @@ export default {
178
196
  <Select
179
197
  class="mr-10"
180
198
  :mode="mode"
181
- :value="type"
199
+ :value="displayType"
182
200
  :disabled="true"
183
201
  :options="types"
184
202
  />
@@ -192,7 +210,7 @@ export default {
192
210
  />
193
211
  </div>
194
212
  <UnitInput
195
- :value="value.limit[type]"
213
+ :value="currentLimit"
196
214
  :mode="mode"
197
215
  :placeholder="typeOption.placeholder"
198
216
  :increment="typeOption.increment"
@@ -1,20 +1,20 @@
1
1
  <script>
2
- import ArrayList from '@shell/components/form/ArrayList';
3
- import Row from './ProjectRow';
4
2
  import { QUOTA_COMPUTED, TYPES } from './shared';
5
3
  import Banner from '@components/Banner/Banner.vue';
4
+ import ResourceQuota from '@shell/components/form/ResourceQuota/ResourceQuotaEntry.vue';
5
+ import { RcButton } from '@components/RcButton';
6
+ import { uniqueId } from 'lodash';
6
7
 
7
8
  export default {
8
9
  emits: [
9
- 'remove',
10
10
  'input',
11
11
  'validationChanged',
12
12
  ],
13
13
 
14
14
  components: {
15
- ArrayList,
16
- Row,
17
15
  Banner,
16
+ RcButton,
17
+ ResourceQuota,
18
18
  },
19
19
 
20
20
  props: {
@@ -37,7 +37,7 @@ export default {
37
37
  },
38
38
 
39
39
  data() {
40
- return { typeValues: null };
40
+ return { resourceQuotas: [] };
41
41
  },
42
42
 
43
43
  created() {
@@ -45,85 +45,129 @@ export default {
45
45
  this.value.spec['namespaceDefaultResourceQuota'] = this.value.spec.namespaceDefaultResourceQuota || { limit: {} };
46
46
  this.value.spec['resourceQuota'] = this.value.spec.resourceQuota || { limit: {} };
47
47
 
48
- const limit = this.value.spec.resourceQuota.limit;
49
- const extendedKeys = Object.keys(limit.extended || {});
48
+ this.quotasFromSpec();
50
49
 
51
- this.typeValues = Object.keys(limit).flatMap((k) => {
52
- if (k !== TYPES.EXTENDED) {
53
- return k;
54
- }
50
+ /**
51
+ * Register watcher using the imperative API to reduce churn when initialing
52
+ * data on first render
53
+ */
54
+ this.$watch(
55
+ 'resourceQuotas',
56
+ () => {
57
+ const { projectLimit, nsLimit } = this.specFromQuotas();
58
+
59
+ this.$emit('input', { projectLimit, nsLimit });
60
+
61
+ const hasMissingExtendedIdentifier = this.resourceQuotas.some(
62
+ (quota) => quota.resourceType === TYPES.EXTENDED && !quota.resourceIdentifier
63
+ );
55
64
 
56
- return extendedKeys.map((ek) => `extended.${ ek }`);
57
- });
65
+ this.$emit('validationChanged', !hasMissingExtendedIdentifier);
66
+ },
67
+ { deep: true }
68
+ );
58
69
  },
59
70
 
60
71
  computed: { ...QUOTA_COMPUTED },
61
72
 
62
73
  methods: {
63
- updateType(event) {
64
- const { index, type } = event;
65
-
66
- this.typeValues[index] = type;
67
-
68
- this.validateTypes();
74
+ addResource() {
75
+ this.resourceQuotas.push({
76
+ id: uniqueId(),
77
+ resourceType: TYPES.EXTENDED,
78
+ resourceIdentifier: '',
79
+ projectLimit: '',
80
+ namespaceDefaultLimit: '',
81
+ });
69
82
  },
70
- updateResourceIdentifier({ type, customType, index }) {
71
- if (type.startsWith(TYPES.EXTENDED)) {
72
- this.typeValues[index] = `extended.${ customType }`;
73
- }
74
83
 
75
- this.validateTypes();
84
+ removeResource(id) {
85
+ this.resourceQuotas = this.resourceQuotas.filter((quota) => quota.id !== id);
76
86
  },
77
- validateTypes(isValid = true) {
78
- if (!isValid) {
79
- this.$emit('validationChanged', false);
80
-
81
- return;
82
- }
83
87
 
84
- const hasMissingExtendedValue = this.typeValues.some((typeValue) => {
85
- if (!typeValue.startsWith(TYPES.EXTENDED)) {
86
- return false;
87
- }
88
+ remainingTypes(currentType) {
89
+ const usedTypes = this.resourceQuotas
90
+ .map((quota) => quota.resourceType)
91
+ .filter((resourceType) => resourceType !== TYPES.EXTENDED);
88
92
 
89
- const [, resourceIdentifier] = typeValue.split('.');
93
+ return this.mappedTypes.filter((mappedType) => mappedType.value === TYPES.EXTENDED ||
94
+ !usedTypes.includes(mappedType.value) ||
95
+ mappedType.value === currentType
96
+ );
97
+ },
90
98
 
91
- return !resourceIdentifier;
92
- });
99
+ specFromQuotas() {
100
+ const projectLimit = {};
101
+ const nsLimit = {};
93
102
 
94
- this.$emit('validationChanged', !hasMissingExtendedValue);
95
- },
96
- remainingTypes(currentType) {
97
- return this.mappedTypes
98
- .filter((mappedType) => {
99
- if (mappedType.value === TYPES.EXTENDED) {
100
- return true;
103
+ this.resourceQuotas.forEach((quota) => {
104
+ if (quota.resourceType === TYPES.EXTENDED) {
105
+ if (quota.resourceIdentifier) {
106
+ if (!projectLimit.extended) {
107
+ projectLimit.extended = {};
108
+ }
109
+ if (!nsLimit.extended) {
110
+ nsLimit.extended = {};
111
+ }
112
+ projectLimit.extended[quota.resourceIdentifier] = quota.projectLimit;
113
+ nsLimit.extended[quota.resourceIdentifier] = quota.namespaceDefaultLimit;
101
114
  }
115
+ } else {
116
+ projectLimit[quota.resourceType] = quota.projectLimit;
117
+ nsLimit[quota.resourceType] = quota.namespaceDefaultLimit;
118
+ }
119
+ });
102
120
 
103
- return !this.typeValues.includes(mappedType.value) || mappedType.value === currentType;
104
- });
121
+ return { projectLimit, nsLimit };
105
122
  },
106
- emitRemove(data) {
107
- this.typeValues = this.typeValues.filter((_typeValue, index) => {
108
- return index !== data.index;
109
- });
110
123
 
111
- this.$emit('remove', data.row?.value);
124
+ quotasFromSpec() {
125
+ const projectLimit = this.value?.spec?.resourceQuota?.limit || {};
126
+ const nsLimit = this.value?.spec?.namespaceDefaultResourceQuota?.limit || {};
112
127
 
113
- this.validateTypes();
128
+ Object.keys(projectLimit).forEach((key) => {
129
+ if (key !== TYPES.EXTENDED) {
130
+ this.resourceQuotas.push({
131
+ id: uniqueId(),
132
+ resourceType: key,
133
+ resourceIdentifier: key,
134
+ projectLimit: projectLimit[key],
135
+ namespaceDefaultLimit: nsLimit[key] || '',
136
+ });
137
+ } else {
138
+ Object.keys(projectLimit.extended || {}).forEach((extKey) => {
139
+ this.resourceQuotas.push({
140
+ id: uniqueId(),
141
+ resourceType: TYPES.EXTENDED,
142
+ resourceIdentifier: extKey,
143
+ projectLimit: projectLimit.extended[extKey],
144
+ namespaceDefaultLimit: (nsLimit.extended || {})[extKey] || '',
145
+ });
146
+ });
147
+ }
148
+ });
114
149
  }
115
150
  },
116
151
  };
117
152
  </script>
118
153
  <template>
119
- <div>
154
+ <div
155
+ role="grid"
156
+ :aria-label="t('resourceQuota.ariaLabel.grid')"
157
+ >
120
158
  <Banner
121
159
  color="info"
122
160
  label-key="resourceQuota.banner"
123
161
  class="mb-20"
124
162
  />
125
- <div class="headers mb-10">
126
- <div class="mr-10">
163
+ <div
164
+ role="row"
165
+ class="headers mb-10"
166
+ >
167
+ <div
168
+ role="columnheader"
169
+ class="mr-10"
170
+ >
127
171
  <label>
128
172
  {{ t('resourceQuota.headers.resourceType') }}
129
173
  <span
@@ -132,7 +176,10 @@ export default {
132
176
  >*</span>
133
177
  </label>
134
178
  </div>
135
- <div class="mr-20">
179
+ <div
180
+ role="columnheader"
181
+ class="mr-20"
182
+ >
136
183
  <label>
137
184
  {{ t('resourceQuota.headers.resourceIdentifier') }}
138
185
  <span
@@ -145,37 +192,44 @@ export default {
145
192
  />
146
193
  </label>
147
194
  </div>
148
- <div class="mr-20">
195
+ <div
196
+ role="columnheader"
197
+ class="mr-20"
198
+ >
149
199
  <label>{{ t('resourceQuota.headers.projectLimit') }}</label>
150
200
  </div>
151
- <div class="mr-10">
201
+ <div
202
+ role="columnheader"
203
+ class="mr-10"
204
+ >
152
205
  <label>{{ t('resourceQuota.headers.namespaceDefaultLimit') }}</label>
153
206
  </div>
154
207
  </div>
155
- <ArrayList
156
- v-model:value="typeValues"
157
- label="Resources"
158
- :add-label="t('resourceQuota.add.label')"
159
- :default-add-value="remainingTypes()[0] ? remainingTypes()[0].value : ''"
160
- :add-allowed="remainingTypes().length > 0"
161
- :mode="mode"
162
- @remove="emitRemove"
163
- @add="validateTypes(false)"
208
+ <template
209
+ v-for="(resourceQuota, resourceQuotaIndex) in resourceQuotas"
210
+ :key="resourceQuota.id"
164
211
  >
165
- <template #columns="props">
166
- <Row
167
- :value="value"
168
- :mode="mode"
169
- :types="remainingTypes(typeValues[props.i])"
170
- :type="typeValues[props.i]"
171
- :type-values="typeValues"
172
- :index="props.i"
173
- @input="$emit('input', $event)"
174
- @type-change="updateType($event)"
175
- @update:resource-identifier="updateResourceIdentifier"
176
- />
177
- </template>
178
- </ArrayList>
212
+ <ResourceQuota
213
+ :id="resourceQuota.id"
214
+ v-model:resource-type="resourceQuota.resourceType"
215
+ v-model:resource-identifier="resourceQuota.resourceIdentifier"
216
+ v-model:project-limit="resourceQuota.projectLimit"
217
+ v-model:namespace-default-limit="resourceQuota.namespaceDefaultLimit"
218
+ :index="resourceQuotaIndex + 1"
219
+ :types="remainingTypes(resourceQuota.resourceType)"
220
+ :mode="mode"
221
+ @remove="removeResource"
222
+ />
223
+ </template>
224
+ <div class="project-quotas-footer">
225
+ <rc-button
226
+ variant="tertiary"
227
+ data-testid="btn-add-resource"
228
+ @click="addResource"
229
+ >
230
+ {{ t('resourceQuota.add.label') }}
231
+ </rc-button>
232
+ </div>
179
233
  </div>
180
234
  </template>
181
235
  <style lang="scss" scoped>
@@ -196,4 +250,8 @@ export default {
196
250
  .required {
197
251
  color: var(--error);
198
252
  }
253
+
254
+ .project-quotas-footer {
255
+ margin-top: 24px;
256
+ }
199
257
  </style>