@rancher/shell 3.0.9-rc.2 → 3.0.9-rc.4

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 (59) hide show
  1. package/assets/translations/en-us.yaml +24 -2
  2. package/assets/translations/zh-hans.yaml +13 -0
  3. package/components/ActionMenu.vue +7 -8
  4. package/components/ActionMenuShell.vue +19 -20
  5. package/components/Resource/Detail/Card/Scaler.vue +10 -2
  6. package/components/Resource/Detail/Card/StatusCard/index.vue +4 -1
  7. package/components/ResourceTable.vue +1 -1
  8. package/components/Tabbed/Tab.vue +4 -0
  9. package/components/Tabbed/index.vue +11 -3
  10. package/components/__tests__/ProjectRow.test.ts +102 -15
  11. package/components/form/ResourceQuota/Project.vue +59 -8
  12. package/components/form/ResourceQuota/ProjectRow.vue +116 -21
  13. package/components/form/ResourceQuota/shared.js +42 -18
  14. package/components/formatter/KubeconfigClusters.vue +74 -0
  15. package/components/formatter/LinkName.vue +3 -2
  16. package/components/formatter/__tests__/KubeconfigClusters.test.ts +125 -0
  17. package/config/product/explorer.js +1 -1
  18. package/config/product/manager.js +29 -2
  19. package/config/router/routes.js +4 -1
  20. package/config/table-headers.js +9 -7
  21. package/config/types.js +4 -1
  22. package/detail/management.cattle.io.oidcclient.vue +15 -4
  23. package/edit/__tests__/management.cattle.io.project.test.js +137 -0
  24. package/edit/management.cattle.io.project.vue +36 -6
  25. package/edit/monitoring.coreos.com.alertmanagerconfig/index.vue +16 -3
  26. package/edit/provisioning.cattle.io.cluster/defaults.ts +1 -0
  27. package/edit/provisioning.cattle.io.cluster/rke2.vue +2 -1
  28. package/edit/provisioning.cattle.io.cluster/tabs/etcd/S3Config.vue +57 -2
  29. package/edit/provisioning.cattle.io.cluster/tabs/etcd/__tests__/S3Config.test.ts +109 -0
  30. package/edit/provisioning.cattle.io.cluster/tabs/etcd/index.vue +1 -0
  31. package/initialize/install-plugins.js +0 -2
  32. package/list/ext.cattle.io.kubeconfig.vue +118 -0
  33. package/mixins/__tests__/chart.test.ts +147 -0
  34. package/mixins/chart.js +10 -8
  35. package/models/__tests__/ext.cattle.io.kubeconfig.test.ts +364 -0
  36. package/models/__tests__/secret.test.ts +55 -0
  37. package/models/ext.cattle.io.kubeconfig.ts +97 -0
  38. package/models/management.cattle.io.cluster.js +22 -30
  39. package/models/provisioning.cattle.io.cluster.js +2 -2
  40. package/models/secret.js +1 -1
  41. package/package.json +2 -2
  42. package/pages/__tests__/diagnostic.test.ts +71 -0
  43. package/pages/about.vue +3 -2
  44. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +38 -14
  45. package/pages/c/_cluster/apps/charts/index.vue +1 -0
  46. package/pages/c/_cluster/explorer/tools/index.vue +23 -5
  47. package/pages/c/_cluster/monitoring/alertmanagerconfig/_alertmanagerconfigid/receiver.vue +18 -5
  48. package/pages/c/_cluster/uiplugins/index.vue +40 -8
  49. package/pages/diagnostic.vue +17 -3
  50. package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +1 -0
  51. package/rancher-components/RcItemCard/RcItemCard.test.ts +20 -8
  52. package/rancher-components/RcItemCard/RcItemCard.vue +38 -31
  53. package/store/__tests__/auth.test.ts +21 -5
  54. package/store/auth.js +6 -3
  55. package/types/shell/index.d.ts +177 -157
  56. package/utils/__tests__/chart.test.ts +96 -0
  57. package/utils/__tests__/version.test.ts +1 -19
  58. package/utils/chart.js +64 -0
  59. package/utils/version.js +5 -17
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useStore } from 'vuex';
4
+ import PaginatedResourceTable from '@shell/components/PaginatedResourceTable.vue';
5
+ import { CAPI, MANAGEMENT } from '@shell/config/types';
6
+ import { ActionFindPageArgs } from '@shell/types/store/dashboard-store.types';
7
+ import { FilterArgs, PaginationFilterField, PaginationParamFilter } from '@shell/types/store/pagination.types';
8
+ import { PagTableFetchPageSecondaryResourcesOpts, PagTableFetchSecondaryResourcesOpts, PagTableFetchSecondaryResourcesReturns } from '@shell/types/components/paginatedResourceTable';
9
+
10
+ defineProps({
11
+ schema: {
12
+ type: Object,
13
+ required: true
14
+ },
15
+
16
+ useQueryParamsForSimpleFiltering: {
17
+ type: Boolean,
18
+ default: false
19
+ }
20
+ });
21
+
22
+ const store = useStore();
23
+
24
+ const canViewProvClusters = computed<boolean>(() => {
25
+ return !!store.getters['management/canList'](CAPI.RANCHER_CLUSTER);
26
+ });
27
+
28
+ const canViewMgmtClusters = computed<boolean>(() => {
29
+ return !!store.getters['management/canList'](MANAGEMENT.CLUSTER);
30
+ });
31
+
32
+ /**
33
+ * Fetch all clusters when not using pagination
34
+ */
35
+ async function fetchSecondaryResources({ canPaginate }: PagTableFetchSecondaryResourcesOpts): PagTableFetchSecondaryResourcesReturns {
36
+ if (canPaginate) {
37
+ return;
38
+ }
39
+
40
+ const promises = [];
41
+
42
+ if (canViewProvClusters.value) {
43
+ promises.push(store.dispatch('management/findAll', { type: CAPI.RANCHER_CLUSTER }));
44
+ }
45
+
46
+ if (canViewMgmtClusters.value) {
47
+ promises.push(store.dispatch('management/findAll', { type: MANAGEMENT.CLUSTER }));
48
+ }
49
+
50
+ await Promise.all(promises);
51
+ }
52
+
53
+ /**
54
+ * Fetch only the clusters referenced by kubeconfigs on the current page
55
+ *
56
+ * NOTE: For the time being this isn't validated because ext.cattle.io.kubeconfig is not one of the indexed resources. I'm putting this in for future support since secondary resources are needed.
57
+ */
58
+ async function fetchPageSecondaryResources({ force, page }: PagTableFetchPageSecondaryResourcesOpts) {
59
+ if (!page?.length) {
60
+ return;
61
+ }
62
+
63
+ const uniqueClusterIds = new Set<string>();
64
+
65
+ page.forEach((kubeconfig: any) => {
66
+ const ids = kubeconfig.spec?.clusters || [];
67
+
68
+ ids.forEach((id: string) => uniqueClusterIds.add(id));
69
+ });
70
+
71
+ if (uniqueClusterIds.size === 0) {
72
+ return;
73
+ }
74
+
75
+ const clusterIdArray = Array.from(uniqueClusterIds);
76
+
77
+ if (canViewProvClusters.value) {
78
+ const opt: ActionFindPageArgs = {
79
+ force,
80
+ pagination: new FilterArgs({
81
+ filters: PaginationParamFilter.createMultipleFields(
82
+ clusterIdArray.map((id) => new PaginationFilterField({
83
+ field: 'status.clusterName', // Verified it's one of the attribute fields listed in the schema, according to steve-pagination-utils that means it should be filterable
84
+ value: id
85
+ }))
86
+ )
87
+ })
88
+ };
89
+
90
+ store.dispatch('management/findPage', { type: CAPI.RANCHER_CLUSTER, opt });
91
+ }
92
+
93
+ if (canViewMgmtClusters.value) {
94
+ const opt: ActionFindPageArgs = {
95
+ force,
96
+ pagination: new FilterArgs({
97
+ filters: PaginationParamFilter.createMultipleFields(
98
+ clusterIdArray.map((id) => new PaginationFilterField({
99
+ field: 'metadata.name', // Verified it's one of the attribute fields listed in the schema, according to steve-pagination-utils that means it should be filterable
100
+ value: id
101
+ }))
102
+ )
103
+ })
104
+ };
105
+
106
+ store.dispatch('management/findPage', { type: MANAGEMENT.CLUSTER, opt });
107
+ }
108
+ }
109
+ </script>
110
+
111
+ <template>
112
+ <PaginatedResourceTable
113
+ :schema="schema"
114
+ :use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
115
+ :fetch-secondary-resources="fetchSecondaryResources"
116
+ :fetch-page-secondary-resources="fetchPageSecondaryResources"
117
+ />
118
+ </template>
@@ -322,5 +322,152 @@ describe('chartMixin', () => {
322
322
  icon: 'icon-upgrade-alt',
323
323
  });
324
324
  });
325
+
326
+ it('should return "upgrade" action when upgrading from a pre-release to a stable version with "up" build metadata', () => {
327
+ const wrapper = mount(DummyComponent, {
328
+ data: () => ({
329
+ existing: { spec: { chart: { metadata: { version: '108.0.0+up0.25.0-rc.4' } } } },
330
+ version: { version: '108.0.0+up0.25.0' }
331
+ }),
332
+ global: {
333
+ mocks: {
334
+ $store: mockStore,
335
+ $route: { query: {} }
336
+ }
337
+ }
338
+ });
339
+
340
+ expect(wrapper.vm.action).toStrictEqual({
341
+ name: 'upgrade',
342
+ tKey: 'upgrade',
343
+ icon: 'icon-upgrade-alt',
344
+ });
345
+ });
346
+
347
+ it('should return "upgrade" action when upgrading with build metadata change', () => {
348
+ const wrapper = mount(DummyComponent, {
349
+ data: () => ({
350
+ existing: { spec: { chart: { metadata: { version: '1.0.0+1' } } } },
351
+ version: { version: '1.0.0+2' }
352
+ }),
353
+ global: {
354
+ mocks: {
355
+ $store: mockStore,
356
+ $route: { query: {} }
357
+ }
358
+ }
359
+ });
360
+
361
+ expect(wrapper.vm.action).toStrictEqual({
362
+ name: 'upgrade',
363
+ tKey: 'upgrade',
364
+ icon: 'icon-upgrade-alt',
365
+ });
366
+ });
367
+ });
368
+
369
+ describe('mappedVersions', () => {
370
+ it('should return versions sorted by semver (descending)', () => {
371
+ const versions = [
372
+ { version: '0.1.0', created: '2026-01-01' },
373
+ { version: '0.2.0-rc1', created: '2026-01-01' },
374
+ { version: '0.2.0', created: '2026-01-01' },
375
+ { version: '1.2.3', created: '2026-01-01' },
376
+ { version: '1.2.3-dev', created: '2026-01-01' },
377
+ { version: '10.0.0', created: '2026-01-01' },
378
+ { version: '2.0.0', created: '2026-01-01' },
379
+ { version: '2.0.0-rc2', created: '2026-01-01' },
380
+ { version: '2.0.0-rc1', created: '2026-01-01' },
381
+ { version: '2.0.0-beta.1', created: '2026-01-01' },
382
+ { version: '2.0.0-alpha', created: '2026-01-01' },
383
+ { version: '3.0.0-rc.3', created: '2026-01-01' },
384
+ { version: '3.0.0-rc.2', created: '2026-01-01' },
385
+ { version: '3.0.0-rc.10', created: '2026-01-01' },
386
+ { version: '108.0.0+up0.25.0-rc.4', created: '2026-01-01' },
387
+ { version: '108.0.0+up0.25.0', created: '2026-01-01' },
388
+ { version: '1.0.0-alpha.beta', created: '2026-01-01' },
389
+ { version: '1.0.0-alpha.1', created: '2026-01-01' },
390
+ { version: '1.0.0-alpha.2', created: '2026-01-01' },
391
+ { version: '1.0.0-alpha', created: '2026-01-01' },
392
+ { version: '1.0.0-beta.11', created: '2026-01-01' },
393
+ { version: '1.0.0-beta.2', created: '2026-01-01' },
394
+ { version: '1.0.0-beta', created: '2026-01-01' },
395
+ { version: '1.0.0+build.1', created: '2026-01-01' },
396
+ { version: '1.0.0+build.2', created: '2026-01-01' },
397
+ { version: '1.0.0+up1.0.0', created: '2026-01-01' },
398
+ { version: '1.0.0+upFoo', created: '2026-01-01' },
399
+ { version: '108.0.0+up0.25.0-rc.5', created: '2026-01-01' },
400
+ { version: '108.0.0+up0.25.1', created: '2026-01-01' },
401
+ { version: '0.0.1', created: '2026-01-01' }
402
+ ];
403
+
404
+ const mockStore = {
405
+ dispatch: jest.fn(() => Promise.resolve()),
406
+ getters: {
407
+ currentCluster: () => {},
408
+ isRancher: () => true,
409
+ 'catalog/repo': () => () => 'repo',
410
+ 'catalog/chart': () => ({ versions }),
411
+ 'prefs/get': () => (key: string) => true,
412
+ 'i18n/t': () => jest.fn()
413
+ }
414
+ };
415
+
416
+ const DummyComponent = {
417
+ mixins: [ChartMixin],
418
+ template: '<div></div>',
419
+ };
420
+
421
+ const wrapper = mount(
422
+ DummyComponent,
423
+ {
424
+ data() {
425
+ return { chart: { versions } };
426
+ },
427
+ global: {
428
+ mocks: {
429
+ $store: mockStore,
430
+ $route: { query: { version: '10.0.0' } }
431
+ }
432
+ }
433
+ });
434
+
435
+ // mappedVersions is a computed property, so we access it directly
436
+ const result = wrapper.vm.mappedVersions;
437
+ const resultVersions = result.map((v: any) => v.version);
438
+
439
+ expect(resultVersions).toStrictEqual([
440
+ '108.0.0+up0.25.1',
441
+ '108.0.0+up0.25.0',
442
+ '108.0.0+up0.25.0-rc.5',
443
+ '108.0.0+up0.25.0-rc.4',
444
+ '10.0.0',
445
+ '3.0.0-rc.10',
446
+ '3.0.0-rc.3',
447
+ '3.0.0-rc.2',
448
+ '2.0.0',
449
+ '2.0.0-rc2',
450
+ '2.0.0-rc1',
451
+ '2.0.0-beta.1',
452
+ '2.0.0-alpha',
453
+ '1.2.3',
454
+ '1.2.3-dev',
455
+ '1.0.0+up1.0.0',
456
+ '1.0.0+upFoo',
457
+ '1.0.0+build.2',
458
+ '1.0.0+build.1',
459
+ '1.0.0-beta.11',
460
+ '1.0.0-beta.2',
461
+ '1.0.0-beta',
462
+ '1.0.0-alpha.beta',
463
+ '1.0.0-alpha.2',
464
+ '1.0.0-alpha.1',
465
+ '1.0.0-alpha',
466
+ '0.2.0',
467
+ '0.2.0-rc1',
468
+ '0.1.0',
469
+ '0.0.1'
470
+ ]);
471
+ });
325
472
  });
326
473
  });
package/mixins/chart.js CHANGED
@@ -10,7 +10,8 @@ import { NAME as MANAGER } from '@shell/config/product/manager';
10
10
  import { OPA_GATE_KEEPER_ID } from '@shell/pages/c/_cluster/gatekeeper/index.vue';
11
11
  import { formatSi, parseSi } from '@shell/utils/units';
12
12
  import { CAPI, CATALOG } from '@shell/config/types';
13
- import { isPrerelease, compare, isUpgradeFromPreToStable } from '@shell/utils/version';
13
+ import { isPrerelease } from '@shell/utils/version';
14
+ import { compareChartVersions } from '@shell/utils/chart';
14
15
  import difference from 'lodash/difference';
15
16
  import { LINUX, APP_UPGRADE_STATUS } from '@shell/store/catalog';
16
17
  import { clone } from '@shell/utils/object';
@@ -51,7 +52,12 @@ export default {
51
52
  },
52
53
 
53
54
  mappedVersions() {
54
- const versions = this.chart?.versions || [];
55
+ const versions = (this.chart?.versions || []).slice();
56
+
57
+ versions.sort((a, b) => {
58
+ return compareChartVersions(b.version, a.version);
59
+ });
60
+
55
61
  const selectedVersion = this.targetVersion;
56
62
  const OSs = this.currentCluster?.workerOSs;
57
63
  const out = [];
@@ -240,13 +246,9 @@ export default {
240
246
  };
241
247
  }
242
248
 
243
- if (isUpgradeFromPreToStable(this.currentVersion, this.targetVersion)) {
244
- return {
245
- name: 'upgrade', tKey: 'upgrade', icon: 'icon-upgrade-alt'
246
- };
247
- }
249
+ const diff = compareChartVersions(this.currentVersion, this.targetVersion);
248
250
 
249
- if (compare(this.currentVersion, this.targetVersion) < 0) {
251
+ if (diff < 0) {
250
252
  return {
251
253
  name: 'upgrade', tKey: 'upgrade', icon: 'icon-upgrade-alt'
252
254
  };
@@ -0,0 +1,364 @@
1
+ import Kubeconfig from '@shell/models/ext.cattle.io.kubeconfig';
2
+ import { CAPI, MANAGEMENT } from '@shell/config/types';
3
+
4
+ // SteveModel is JS, so we need to type the constructor
5
+ const KubeconfigModel = Kubeconfig as unknown as new (data: object) => Kubeconfig;
6
+
7
+ describe('class Kubeconfig', () => {
8
+ const mockT = jest.fn((key: string, opts?: { name: string }) => {
9
+ if (key === '"ext.cattle.io.kubeconfig".deleted') {
10
+ return `${ opts?.name } (deleted)`;
11
+ }
12
+
13
+ return key;
14
+ });
15
+
16
+ const createKubeconfig = (data: object, rootGetters: object = {}) => {
17
+ const kubeconfig = new KubeconfigModel(data);
18
+
19
+ // Mock $rootGetters before any getters are accessed
20
+ // Cast to any since $rootGetters is inherited from JS SteveModel
21
+ jest.spyOn(kubeconfig as any, '$rootGetters', 'get').mockReturnValue({
22
+ 'i18n/t': mockT,
23
+ 'management/all': () => [],
24
+ ...rootGetters
25
+ });
26
+
27
+ return kubeconfig;
28
+ };
29
+
30
+ beforeEach(() => {
31
+ jest.clearAllMocks();
32
+ });
33
+
34
+ describe('expiresAt', () => {
35
+ it('should return null when ttl is not set', () => {
36
+ const kubeconfig = createKubeconfig({
37
+ metadata: { creationTimestamp: '2024-01-01T00:00:00Z' },
38
+ spec: {}
39
+ });
40
+
41
+ expect(kubeconfig.expiresAt).toBeNull();
42
+ });
43
+
44
+ it('should return null when creationTimestamp is not set', () => {
45
+ const kubeconfig = createKubeconfig({
46
+ metadata: {},
47
+ spec: { ttl: 3600 }
48
+ });
49
+
50
+ expect(kubeconfig.expiresAt).toBeNull();
51
+ });
52
+
53
+ it('should calculate expiry correctly', () => {
54
+ const kubeconfig = createKubeconfig({
55
+ metadata: { creationTimestamp: '2024-01-01T00:00:00Z' },
56
+ spec: { ttl: 3600 } // 1 hour in seconds
57
+ });
58
+
59
+ expect(kubeconfig.expiresAt).toBe('2024-01-01T01:00:00.000Z');
60
+ });
61
+
62
+ it('should handle large ttl values', () => {
63
+ const kubeconfig = createKubeconfig({
64
+ metadata: { creationTimestamp: '2024-01-01T00:00:00Z' },
65
+ spec: { ttl: 86400 } // 24 hours in seconds
66
+ });
67
+
68
+ expect(kubeconfig.expiresAt).toBe('2024-01-02T00:00:00.000Z');
69
+ });
70
+ });
71
+
72
+ describe('referencedClusters', () => {
73
+ const mockProvCluster = {
74
+ mgmt: { id: 'c-m-abc123' },
75
+ status: { clusterName: 'c-m-abc123' },
76
+ nameDisplay: 'my-cluster',
77
+ detailLocation: { name: 'c-cluster-product-resource-id', params: { cluster: 'my-cluster' } }
78
+ };
79
+
80
+ const mockMgmtCluster = {
81
+ id: 'c-m-def456',
82
+ nameDisplay: 'mgmt-cluster',
83
+ detailLocation: { name: 'c-cluster-product-resource-id', params: { cluster: 'mgmt-cluster' } }
84
+ };
85
+
86
+ it('should return empty array when no clusters are specified', () => {
87
+ const kubeconfig = createKubeconfig({
88
+ metadata: {},
89
+ spec: {}
90
+ });
91
+
92
+ expect(kubeconfig.referencedClusters).toStrictEqual([]);
93
+ });
94
+
95
+ it('should map provisioning cluster by mgmt id', () => {
96
+ const kubeconfig = createKubeconfig(
97
+ {
98
+ metadata: {},
99
+ spec: { clusters: ['c-m-abc123'] }
100
+ },
101
+ {
102
+ 'management/all': (type: string) => {
103
+ if (type === CAPI.RANCHER_CLUSTER) {
104
+ return [mockProvCluster];
105
+ }
106
+
107
+ return [];
108
+ }
109
+ }
110
+ );
111
+
112
+ expect(kubeconfig.referencedClusters).toStrictEqual([
113
+ {
114
+ label: 'my-cluster',
115
+ location: mockProvCluster.detailLocation
116
+ }
117
+ ]);
118
+ });
119
+
120
+ it('should map management cluster when no provisioning cluster found', () => {
121
+ const kubeconfig = createKubeconfig(
122
+ {
123
+ metadata: {},
124
+ spec: { clusters: ['c-m-def456'] }
125
+ },
126
+ {
127
+ 'management/all': (type: string) => {
128
+ if (type === MANAGEMENT.CLUSTER) {
129
+ return [mockMgmtCluster];
130
+ }
131
+
132
+ return [];
133
+ }
134
+ }
135
+ );
136
+
137
+ expect(kubeconfig.referencedClusters).toStrictEqual([
138
+ {
139
+ label: 'mgmt-cluster',
140
+ location: mockMgmtCluster.detailLocation
141
+ }
142
+ ]);
143
+ });
144
+
145
+ it('should return deleted label when cluster not found', () => {
146
+ const kubeconfig = createKubeconfig({
147
+ metadata: {},
148
+ spec: { clusters: ['c-m-deleted'] }
149
+ });
150
+
151
+ expect(kubeconfig.referencedClusters).toStrictEqual([
152
+ {
153
+ label: 'c-m-deleted (deleted)',
154
+ location: null
155
+ }
156
+ ]);
157
+ expect(mockT).toHaveBeenCalledWith('"ext.cattle.io.kubeconfig".deleted', { name: 'c-m-deleted' });
158
+ });
159
+
160
+ it('should prefer provisioning cluster over management cluster', () => {
161
+ const mgmtClusterSameId = {
162
+ id: 'c-m-abc123',
163
+ nameDisplay: 'mgmt-version',
164
+ detailLocation: { name: 'mgmt-location' }
165
+ };
166
+
167
+ const kubeconfig = createKubeconfig(
168
+ {
169
+ metadata: {},
170
+ spec: { clusters: ['c-m-abc123'] }
171
+ },
172
+ {
173
+ 'management/all': (type: string) => {
174
+ if (type === CAPI.RANCHER_CLUSTER) {
175
+ return [mockProvCluster];
176
+ }
177
+ if (type === MANAGEMENT.CLUSTER) {
178
+ return [mgmtClusterSameId];
179
+ }
180
+
181
+ return [];
182
+ }
183
+ }
184
+ );
185
+
186
+ expect(kubeconfig.referencedClusters).toStrictEqual([
187
+ {
188
+ label: 'my-cluster',
189
+ location: mockProvCluster.detailLocation
190
+ }
191
+ ]);
192
+ });
193
+ });
194
+
195
+ describe('sortedReferencedClusters', () => {
196
+ it('should sort existing clusters before deleted clusters', () => {
197
+ const existingCluster = {
198
+ mgmt: { id: 'c-m-exists' },
199
+ nameDisplay: 'existing-cluster',
200
+ detailLocation: { name: 'location' }
201
+ };
202
+
203
+ const kubeconfig = createKubeconfig(
204
+ {
205
+ metadata: {},
206
+ spec: { clusters: ['deleted-1', 'c-m-exists', 'deleted-2'] }
207
+ },
208
+ {
209
+ 'management/all': (type: string) => {
210
+ if (type === CAPI.RANCHER_CLUSTER) {
211
+ return [existingCluster];
212
+ }
213
+
214
+ return [];
215
+ }
216
+ }
217
+ );
218
+
219
+ const sorted = kubeconfig.sortedReferencedClusters;
220
+
221
+ expect(sorted[0].label).toBe('existing-cluster');
222
+ expect(sorted[0].location).not.toBeNull();
223
+ expect(sorted[1].location).toBeNull();
224
+ expect(sorted[2].location).toBeNull();
225
+ });
226
+
227
+ it('should sort existing clusters alphabetically', () => {
228
+ const clusters = [
229
+ {
230
+ mgmt: { id: 'c-m-zebra' }, nameDisplay: 'zebra', detailLocation: { name: 'z' }
231
+ },
232
+ {
233
+ mgmt: { id: 'c-m-alpha' }, nameDisplay: 'alpha', detailLocation: { name: 'a' }
234
+ },
235
+ {
236
+ mgmt: { id: 'c-m-beta' }, nameDisplay: 'beta', detailLocation: { name: 'b' }
237
+ }
238
+ ];
239
+
240
+ const kubeconfig = createKubeconfig(
241
+ {
242
+ metadata: {},
243
+ spec: { clusters: ['c-m-zebra', 'c-m-alpha', 'c-m-beta'] }
244
+ },
245
+ {
246
+ 'management/all': (type: string) => {
247
+ if (type === CAPI.RANCHER_CLUSTER) {
248
+ return clusters;
249
+ }
250
+
251
+ return [];
252
+ }
253
+ }
254
+ );
255
+
256
+ const sorted = kubeconfig.sortedReferencedClusters;
257
+
258
+ expect(sorted.map((c) => c.label)).toStrictEqual(['alpha', 'beta', 'zebra']);
259
+ });
260
+
261
+ it('should sort numerically when names contain numbers', () => {
262
+ const clusters = [
263
+ {
264
+ mgmt: { id: 'c-m-2' }, nameDisplay: 'cluster2', detailLocation: { name: 'c2' }
265
+ },
266
+ {
267
+ mgmt: { id: 'c-m-10' }, nameDisplay: 'cluster10', detailLocation: { name: 'c10' }
268
+ },
269
+ {
270
+ mgmt: { id: 'c-m-1' }, nameDisplay: 'cluster1', detailLocation: { name: 'c1' }
271
+ }
272
+ ];
273
+
274
+ const kubeconfig = createKubeconfig(
275
+ {
276
+ metadata: {},
277
+ spec: { clusters: ['c-m-2', 'c-m-10', 'c-m-1'] }
278
+ },
279
+ {
280
+ 'management/all': (type: string) => {
281
+ if (type === CAPI.RANCHER_CLUSTER) {
282
+ return clusters;
283
+ }
284
+
285
+ return [];
286
+ }
287
+ }
288
+ );
289
+
290
+ const sorted = kubeconfig.sortedReferencedClusters;
291
+
292
+ expect(sorted.map((c) => c.label)).toStrictEqual(['cluster1', 'cluster2', 'cluster10']);
293
+ });
294
+ });
295
+
296
+ describe('referencedClustersSortable', () => {
297
+ it('should return comma-separated lowercase labels', () => {
298
+ const clusters = [
299
+ {
300
+ mgmt: { id: 'c-m-1' }, nameDisplay: 'Alpha', detailLocation: { name: 'a' }
301
+ },
302
+ {
303
+ mgmt: { id: 'c-m-2' }, nameDisplay: 'Beta', detailLocation: { name: 'b' }
304
+ }
305
+ ];
306
+
307
+ const kubeconfig = createKubeconfig(
308
+ {
309
+ metadata: {},
310
+ spec: { clusters: ['c-m-1', 'c-m-2'] }
311
+ },
312
+ {
313
+ 'management/all': (type: string) => {
314
+ if (type === CAPI.RANCHER_CLUSTER) {
315
+ return clusters;
316
+ }
317
+
318
+ return [];
319
+ }
320
+ }
321
+ );
322
+
323
+ expect(kubeconfig.referencedClustersSortable).toBe('alpha,beta');
324
+ });
325
+
326
+ it('should return empty string when no clusters', () => {
327
+ const kubeconfig = createKubeconfig({
328
+ metadata: {},
329
+ spec: {}
330
+ });
331
+
332
+ expect(kubeconfig.referencedClustersSortable).toBe('');
333
+ });
334
+ });
335
+
336
+ describe('_availableActions', () => {
337
+ it('should filter out goToEdit, goToEditYaml, cloneYaml, and download actions', () => {
338
+ const kubeconfig = createKubeconfig({
339
+ metadata: {},
340
+ spec: {}
341
+ });
342
+
343
+ const mockActions = [
344
+ { action: 'goToClone' },
345
+ { action: 'divider' },
346
+ { action: 'goToEdit' },
347
+ { action: 'goToEditYaml' },
348
+ { action: 'cloneYaml' },
349
+ { action: 'download' },
350
+ { action: 'promptRemove' }
351
+ ];
352
+
353
+ jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(kubeconfig)), '_availableActions', 'get')
354
+ .mockReturnValue(mockActions);
355
+
356
+ const actions = kubeconfig._availableActions;
357
+
358
+ expect(actions).toStrictEqual([
359
+ { action: 'goToClone' },
360
+ { action: 'promptRemove' }
361
+ ]);
362
+ });
363
+ });
364
+ });