@rancher/shell 3.0.12-rc.1 → 3.0.12-rc.2

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 (134) hide show
  1. package/assets/images/providers/entraid-black.svg +4 -0
  2. package/assets/images/providers/entraid.svg +9 -0
  3. package/assets/images/vendor/entraid.svg +9 -0
  4. package/assets/styles/app.scss +0 -1
  5. package/assets/translations/en-us.yaml +19 -17
  6. package/assets/translations/zh-hans.yaml +4 -8
  7. package/chart/__tests__/S3.test.ts +10 -3
  8. package/components/CountBox.vue +20 -0
  9. package/components/CreateDriver.vue +0 -12
  10. package/components/DetailText.vue +12 -3
  11. package/components/SelectIconGrid.vue +5 -0
  12. package/components/__tests__/CountBox.test.ts +72 -0
  13. package/components/__tests__/DetailText.test.ts +113 -0
  14. package/components/fleet/FleetClusterTargets/index.vue +18 -1
  15. package/components/form/InputWithSelect.vue +18 -10
  16. package/components/form/KeyValue.vue +17 -1
  17. package/components/form/LabeledSelect.vue +82 -24
  18. package/components/form/Select.vue +73 -56
  19. package/components/form/ServiceNameSelect.vue +13 -11
  20. package/components/form/__tests__/KeyValue.test.ts +66 -0
  21. package/components/form/__tests__/NodeScheduling.test.ts +9 -0
  22. package/components/form/labeled-select-utils/useLabeledSelectPagination.ts +138 -0
  23. package/components/nav/Group.vue +7 -6
  24. package/components/nav/Header.vue +24 -3
  25. package/components/nav/NotificationCenter/Notification.vue +4 -1
  26. package/components/nav/NotificationCenter/NotificationHeader.vue +20 -8
  27. package/components/nav/NotificationCenter/__tests__/NotificationHeader.test.ts +80 -0
  28. package/components/nav/Type.vue +8 -7
  29. package/components/nav/WindowManager/index.vue +2 -1
  30. package/components/nav/WorkspaceSwitcher.vue +13 -0
  31. package/components/nav/__tests__/Group.test.ts +67 -0
  32. package/components/nav/__tests__/Header.test.ts +235 -0
  33. package/components/nav/__tests__/Type.test.ts +20 -3
  34. package/components/templates/default.vue +34 -4
  35. package/components/templates/home.vue +12 -25
  36. package/components/templates/plain.vue +13 -26
  37. package/composables/useLabeledFormElement.ts +10 -2
  38. package/composables/useLabeledSelect.ts +60 -0
  39. package/composables/useUserRetentionValidation.ts +1 -49
  40. package/config/cookies.js +0 -1
  41. package/config/labels-annotations.js +1 -0
  42. package/config/query-params.js +1 -0
  43. package/config/router/routes.js +0 -8
  44. package/core/__tests__/plugin-products.test.ts +616 -25
  45. package/core/plugin-products-base.ts +31 -14
  46. package/core/plugin-products-helpers.ts +5 -4
  47. package/core/plugin-types.ts +18 -3
  48. package/core/types.ts +3 -1
  49. package/detail/__tests__/management.cattle.io.fleetworkspace.test.ts +128 -0
  50. package/detail/management.cattle.io.fleetworkspace.vue +49 -0
  51. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +9 -0
  52. package/edit/__tests__/kontainerDriver.test.ts +0 -13
  53. package/edit/__tests__/nodeDriver.test.ts +5 -11
  54. package/edit/__tests__/resources.cattle.io.restore.test.ts +9 -0
  55. package/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/General.test.ts.snap +6 -0
  56. package/edit/auth/__tests__/oidc.test.ts +54 -0
  57. package/edit/auth/azuread.vue +1 -1
  58. package/edit/auth/oidc.vue +8 -0
  59. package/edit/kontainerDriver.vue +1 -2
  60. package/edit/nodeDriver.vue +0 -2
  61. package/edit/provisioning.cattle.io.cluster/AgentEnv.vue +1 -0
  62. package/edit/provisioning.cattle.io.cluster/__tests__/AgentEnv.test.ts +25 -0
  63. package/edit/provisioning.cattle.io.cluster/index.vue +70 -99
  64. package/initialize/App.vue +29 -2
  65. package/initialize/install-plugins.js +0 -2
  66. package/list/__tests__/management.cattle.io.feature.test.ts +105 -0
  67. package/list/catalog.cattle.io.app.vue +25 -5
  68. package/list/management.cattle.io.feature.vue +1 -1
  69. package/list/management.cattle.io.fleetworkspace.vue +8 -0
  70. package/machine-config/amazonec2.vue +1 -0
  71. package/mixins/chart.js +40 -9
  72. package/models/__tests__/catalog.cattle.io.app.test.ts +15 -1
  73. package/models/__tests__/catalog.cattle.io.clusterrepo.test.ts +84 -0
  74. package/models/__tests__/chart.test.ts +99 -6
  75. package/models/__tests__/management.cattle.io.feature.test.ts +131 -0
  76. package/models/__tests__/monitoring.coreos.com.alertmanagerconfig.test.ts +98 -0
  77. package/models/catalog.cattle.io.app.js +21 -17
  78. package/models/catalog.cattle.io.clusterrepo.js +39 -11
  79. package/models/chart.js +33 -19
  80. package/models/fleet-application.js +1 -1
  81. package/models/fleet.cattle.io.bundle.js +1 -1
  82. package/models/kontainerdriver.js +11 -0
  83. package/models/management.cattle.io.authconfig.js +5 -1
  84. package/models/management.cattle.io.cluster.js +0 -53
  85. package/models/management.cattle.io.feature.js +3 -3
  86. package/models/management.cattle.io.kontainerdriver.js +1 -26
  87. package/models/monitoring.coreos.com.alertmanagerconfig.js +31 -17
  88. package/models/nodedriver.js +7 -0
  89. package/package.json +13 -12
  90. package/pages/c/_cluster/apps/charts/__tests__/chart.test.ts +189 -0
  91. package/pages/c/_cluster/apps/charts/__tests__/index.test.ts +55 -0
  92. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +53 -0
  93. package/pages/c/_cluster/apps/charts/chart.vue +217 -33
  94. package/pages/c/_cluster/apps/charts/index.vue +2 -2
  95. package/pages/c/_cluster/apps/charts/install.vue +8 -3
  96. package/pages/c/_cluster/auth/user.retention/index.vue +55 -22
  97. package/pages/c/_cluster/manager/drivers/kontainerDriver/index.vue +5 -7
  98. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +39 -2
  99. package/pages/c/_cluster/uiplugins/__tests__/PluginInfoPanel.test.ts +61 -0
  100. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +15 -10
  101. package/pages/c/_cluster/uiplugins/index.vue +23 -25
  102. package/rancher-components/Form/LabeledInput/LabeledInput.test.ts +205 -1
  103. package/rancher-components/Form/LabeledInput/LabeledInput.vue +82 -4
  104. package/rancher-components/Form/ToggleSwitch/ToggleSwitch.vue +1 -1
  105. package/scripts/test-plugins-build.sh +5 -2
  106. package/server/server-middleware.js +2 -2
  107. package/static/humans.txt +1 -0
  108. package/static/robots.txt +34 -0
  109. package/static/welcome-cow.svg +18 -0
  110. package/store/__tests__/catalog.test.ts +161 -11
  111. package/store/auth.js +0 -3
  112. package/store/catalog.js +60 -8
  113. package/types/shell/index.d.ts +26 -22
  114. package/utils/__tests__/git.test.ts +270 -0
  115. package/utils/__tests__/inactivity.test.ts +316 -0
  116. package/utils/__tests__/object.test.ts +77 -0
  117. package/utils/__tests__/time.test.ts +14 -1
  118. package/utils/__tests__/url.test.ts +246 -0
  119. package/utils/object.js +33 -2
  120. package/utils/time.ts +5 -0
  121. package/vue.config.js +0 -9
  122. package/assets/images/providers/azuread-black.svg +0 -22
  123. package/assets/images/providers/azuread.svg +0 -25
  124. package/assets/images/vendor/azuread.svg +0 -18
  125. package/assets/styles/fonts/_dots.scss +0 -18
  126. package/components/EmberPage.vue +0 -622
  127. package/components/EmberPageView.vue +0 -39
  128. package/components/form/labeled-select-utils/labeled-select-pagination.ts +0 -116
  129. package/mixins/labeled-form-element.ts +0 -225
  130. package/pages/c/_cluster/explorer/tools/pages/_page.vue +0 -28
  131. package/pages/c/_cluster/manager/pages/_page.vue +0 -22
  132. package/pages/c/_cluster/mcapps/pages/_page.vue +0 -22
  133. package/plugins/ember-cookie.js +0 -17
  134. package/utils/ember-page.js +0 -30
package/mixins/chart.js CHANGED
@@ -28,6 +28,17 @@ export default {
28
28
  ignoreWarning: false,
29
29
 
30
30
  chart: null,
31
+
32
+ // Whether installing a new instance is allowed.
33
+ // This is false when the chart is targeted (has fixed namespace/name annotations)
34
+ // or when the URL query specifies a specific app to edit.
35
+ canInstallNew: true,
36
+
37
+ // Installed instances of this chart that can be selected for edit/upgrade
38
+ // on the chart detail page. When instances exist, `existing` is set to the
39
+ // first one by default, but the user can select a different instance or
40
+ // choose to install a new one.
41
+ installedInstances: [],
31
42
  };
32
43
  },
33
44
 
@@ -375,6 +386,9 @@ export default {
375
386
  // Use those values to check for a catalog app resource.
376
387
  // If found, set the form to edit mode. If not, set the
377
388
  // form to create mode.
389
+ // This is a hard blocker - installing a new instance is NOT allowed.
390
+
391
+ this.canInstallNew = false;
378
392
 
379
393
  try {
380
394
  this.existing = await this.$store.dispatch('cluster/find', {
@@ -399,6 +413,9 @@ export default {
399
413
 
400
414
  // Ask to install a special chart with fixed namespace/name
401
415
  // or edit it if there's an existing install.
416
+ // This is a hard blocker - installing a new instance is NOT allowed.
417
+
418
+ this.canInstallNew = false;
402
419
 
403
420
  try {
404
421
  this.existing = await this.$store.dispatch('cluster/find', {
@@ -410,19 +427,33 @@ export default {
410
427
  this.mode = _CREATE;
411
428
  this.existing = null;
412
429
  }
413
- } else if (this.chart) {
414
- const matching = this.chart.matchingInstalledApps;
430
+ } else {
431
+ // Regular chart (not targeted) - check if there are installed instances.
432
+ // Installing new instances IS allowed (canInstallNew remains true).
433
+ const isChartDetailPage = this.$route.name === 'c-cluster-apps-charts-chart';
415
434
 
416
- if (matching.length === 1) {
417
- this.existing = matching[0];
418
- this.mode = _EDIT;
435
+ if (isChartDetailPage) {
436
+ const matching = this.chart?.matchingInstalledApps || [];
437
+
438
+ // Always refresh the available instances so stale values are removed.
439
+ this.installedInstances = [];
440
+
441
+ if (matching.length >= 1) {
442
+ // Populate the instance selector and preserve the current selection
443
+ // when it is still one of the matching installed apps.
444
+ this.installedInstances = matching;
445
+ const hasExistingMatch = this.existing?.id && matching.some((instance) => instance.id === this.existing.id);
446
+
447
+ if (!hasExistingMatch) {
448
+ this.existing = matching[0];
449
+ }
450
+ } else {
451
+ this.existing = null;
452
+ }
419
453
  } else {
454
+ // Regular create
420
455
  this.mode = _CREATE;
421
456
  }
422
- } else {
423
- // Regular create
424
-
425
- this.mode = _CREATE;
426
457
  }
427
458
  }
428
459
  }, // End of fetchChart
@@ -87,6 +87,19 @@ const certManagerOfficialMatchingChart2 = {
87
87
  }]
88
88
  };
89
89
 
90
+ // Simulates the chart data fetched from the repository where the home URL has changed in newer versions
91
+ // AND the older installed version has been dropped/replaced from the repository index.
92
+ const certManagerOfficialMatchingChartNewHomeOnly = {
93
+ name: chartName,
94
+ repoName: certManagerOfficial.repoName,
95
+ versions: [{
96
+ version: latestVersion,
97
+ home: certManagerOfficial.home,
98
+ repoName: certManagerOfficial.repoName,
99
+ annotations: {},
100
+ }]
101
+ };
102
+
90
103
  const installedCertManagerAppCoFromRancherUI = {
91
104
  metadata: {
92
105
  annotations: { [CATALOG_ANNOTATIONS.SOURCE_REPO_NAME]: appCo.repoName },
@@ -130,7 +143,8 @@ describe('class CatalogApp', () => {
130
143
  [installedCertManagerOfficialFromRancherUI, [], APP_UPGRADE_STATUS.NO_UPGRADE],
131
144
  [installedCertManagerOfficialFromRancherUI, [certManagerOfficialMatchingChart1], APP_UPGRADE_STATUS.SINGLE_UPGRADE],
132
145
  [installedCertManagerOfficialFromRancherUI, [certManagerOfficialMatchingChart1, appCoMatchingChart1], APP_UPGRADE_STATUS.SINGLE_UPGRADE],
133
- [installedCertManagerOfficialFromRancherUI, [certManagerOfficialMatchingChart1, certManagerOfficialMatchingChart2], APP_UPGRADE_STATUS.MULTIPLE_UPGRADES]
146
+ [installedCertManagerOfficialFromRancherUI, [certManagerOfficialMatchingChart1, certManagerOfficialMatchingChart2], APP_UPGRADE_STATUS.MULTIPLE_UPGRADES],
147
+ [installedCertManagerOfficialFromRancherUI, [certManagerOfficialMatchingChartNewHomeOnly], APP_UPGRADE_STATUS.SINGLE_UPGRADE]
134
148
  ];
135
149
 
136
150
  it.each(testCases)('should return the correct upgrade status', (installedChart: Object, matchingCharts: any, expected: any) => {
@@ -0,0 +1,84 @@
1
+ import ClusterRepo from '../catalog.cattle.io.clusterrepo';
2
+
3
+ describe('clusterRepo', () => {
4
+ let model: any;
5
+
6
+ beforeEach(() => {
7
+ model = new ClusterRepo({
8
+ metadata: { name: 'test-repo' },
9
+ spec: { url: 'https://test-url.com' }
10
+ }, {
11
+ getters: {},
12
+ dispatch: jest.fn(),
13
+ rootGetters: {}
14
+ });
15
+
16
+ jest.spyOn(model, '_key', 'get').mockReturnValue('test-key');
17
+ jest.spyOn(model, 'save').mockImplementation().mockResolvedValue(true);
18
+ jest.spyOn(model, 'waitForState').mockImplementation().mockResolvedValue(true);
19
+ jest.spyOn(model, '$dispatch', 'get').mockReturnValue(jest.fn());
20
+ });
21
+
22
+ describe('refresh', () => {
23
+ it('updates forceUpdate, saves, waits for active state, and dispatches load by default', async() => {
24
+ // Mock Date to ensure deterministic forceUpdate value
25
+ const mockDate = new Date('2023-01-01T12:00:00.000Z');
26
+ const spy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
27
+
28
+ await model.refresh();
29
+
30
+ expect(model.spec.forceUpdate).toBe('2023-01-01T12:00:00Z');
31
+ expect(model.save).toHaveBeenCalledWith();
32
+ expect(model.waitForState).toHaveBeenCalledWith('active', 10000, 1000);
33
+ expect(model.$dispatch).toHaveBeenCalledWith('catalog/load', { force: true, repoKeys: [model._key] }, { root: true });
34
+
35
+ spy.mockRestore();
36
+ });
37
+
38
+ it('updates forceUpdate, saves, waits for active state, but DOES NOT dispatch load if dispatchLoad is false', async() => {
39
+ await model.refresh(false);
40
+
41
+ expect(model.save).toHaveBeenCalledWith();
42
+ expect(model.waitForState).toHaveBeenCalledWith('active', 10000, 1000);
43
+ expect(model.$dispatch).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it('dispatches error to growl if save or waitForState fails', async() => {
47
+ const error = new Error('waitForState timeout');
48
+
49
+ model.waitForState.mockRejectedValue(error);
50
+ jest.spyOn(model, 't', 'get').mockReturnValue(jest.fn().mockReturnValue('Error refreshing repository'));
51
+ jest.spyOn(model, 'nameDisplay', 'get').mockReturnValue('Test Repo');
52
+
53
+ await model.refresh();
54
+
55
+ expect(model.$dispatch).toHaveBeenCalledWith('growl/fromError', {
56
+ title: 'Error refreshing repository',
57
+ err: error
58
+ }, { root: true });
59
+ });
60
+ });
61
+
62
+ describe('refreshBulk', () => {
63
+ it('calls refresh(false) on all items and then dispatches a single catalog/load with all repoKeys', async() => {
64
+ const mockItem1 = {
65
+ _key: 'repo-1',
66
+ refresh: jest.fn().mockResolvedValue(true)
67
+ };
68
+ const mockItem2 = {
69
+ _key: 'repo-2',
70
+ refresh: jest.fn().mockResolvedValue(true)
71
+ };
72
+
73
+ await model.refreshBulk([mockItem1, mockItem2]);
74
+
75
+ expect(mockItem1.refresh).toHaveBeenCalledWith(false);
76
+ expect(mockItem2.refresh).toHaveBeenCalledWith(false);
77
+
78
+ expect(model.$dispatch).toHaveBeenCalledWith('catalog/load', {
79
+ force: true,
80
+ repoKeys: ['repo-1', 'repo-2']
81
+ }, { root: true });
82
+ });
83
+ });
84
+ });
@@ -17,7 +17,7 @@ type MockChartContext = {
17
17
  };
18
18
 
19
19
  interface CardContent {
20
- subHeaderItems: { label: string, labelTooltip?: string}[];
20
+ subHeaderItems: { label: string, labelTooltip?: string, icon?: string, iconTooltip?: string }[];
21
21
  footerItems: { labels: string[]; icon?: string }[];
22
22
  statuses: { tooltip: { key?: string; text?: string }; color: string }[];
23
23
  }
@@ -166,7 +166,7 @@ describe('class Chart', () => {
166
166
  expect(chart.isInstalled).toBe(false);
167
167
  });
168
168
 
169
- it('is false when multiple apps match', () => {
169
+ it('is true when multiple apps match', () => {
170
170
  const app = makeInstalledApp();
171
171
 
172
172
  app.spec.chart.metadata.version = '1.2.3';
@@ -174,7 +174,7 @@ describe('class Chart', () => {
174
174
 
175
175
  const chart = new Chart(base, ctx);
176
176
 
177
- expect(chart.isInstalled).toBe(false);
177
+ expect(chart.isInstalled).toBe(true);
178
178
  });
179
179
  });
180
180
 
@@ -206,6 +206,58 @@ describe('class Chart', () => {
206
206
 
207
207
  expect(chart.upgradeable).toBe(false);
208
208
  });
209
+
210
+ it('is true when at least one of multiple installed apps is upgradeable', () => {
211
+ const app1 = makeInstalledApp(APP_UPGRADE_STATUS.NO_UPGRADE);
212
+ const app2 = makeInstalledApp(APP_UPGRADE_STATUS.SINGLE_UPGRADE);
213
+
214
+ ctx.rootGetters['cluster/all'] = () => [app1, app2];
215
+
216
+ const chart = new Chart(base, ctx);
217
+
218
+ expect(chart.upgradeable).toBe(true);
219
+ });
220
+
221
+ it('is false when none of multiple installed apps are upgradeable', () => {
222
+ const app1 = makeInstalledApp(APP_UPGRADE_STATUS.NO_UPGRADE);
223
+ const app2 = makeInstalledApp(APP_UPGRADE_STATUS.NO_UPGRADE);
224
+
225
+ ctx.rootGetters['cluster/all'] = () => [app1, app2];
226
+
227
+ const chart = new Chart(base, ctx);
228
+
229
+ expect(chart.upgradeable).toBe(false);
230
+ });
231
+ });
232
+
233
+ describe('installedCount', () => {
234
+ it('returns 0 when no apps are installed', () => {
235
+ const chart = new Chart(base, ctx);
236
+
237
+ expect(chart.installedCount).toBe(0);
238
+ });
239
+
240
+ it('returns 1 when one app is installed', () => {
241
+ const installedApp = makeInstalledApp();
242
+
243
+ ctx.rootGetters['cluster/all'] = () => [installedApp];
244
+
245
+ const chart = new Chart(base, ctx);
246
+
247
+ expect(chart.installedCount).toBe(1);
248
+ });
249
+
250
+ it('returns correct count when multiple apps are installed', () => {
251
+ const app1 = makeInstalledApp();
252
+ const app2 = makeInstalledApp();
253
+ const app3 = makeInstalledApp();
254
+
255
+ ctx.rootGetters['cluster/all'] = () => [app1, app2, app3];
256
+
257
+ const chart = new Chart(base, ctx);
258
+
259
+ expect(chart.installedCount).toBe(3);
260
+ });
209
261
  });
210
262
 
211
263
  describe('cardContent', () => {
@@ -274,6 +326,25 @@ describe('class Chart', () => {
274
326
  expect(installedStatus?.tooltip?.text).toContain(installedApp.spec.chart.metadata.version);
275
327
  });
276
328
 
329
+ it('does not include version in installed tooltip when multiple instances exist', () => {
330
+ const app1 = makeInstalledApp();
331
+ const app2 = makeInstalledApp();
332
+
333
+ app2.spec.chart.metadata.version = '1.2.0';
334
+ ctx.rootGetters['cluster/all'] = () => [app1, app2];
335
+
336
+ const chart = new Chart(base, ctx);
337
+
338
+ const result = chart.cardContent as CardContent;
339
+
340
+ const installedStatus = result.statuses.find((s) => s.tooltip?.text?.startsWith('generic.installedMultiple'));
341
+
342
+ expect(installedStatus).toBeDefined();
343
+ expect(installedStatus?.color).toBe('success');
344
+ // Should not contain version number when multiple instances
345
+ expect(installedStatus?.tooltip?.text).toBe('generic.installedMultiple');
346
+ });
347
+
277
348
  it('includes upgradeable status when upgrade is available', () => {
278
349
  const installedApp = makeInstalledApp(APP_UPGRADE_STATUS.SINGLE_UPGRADE);
279
350
 
@@ -332,10 +403,32 @@ describe('class Chart', () => {
332
403
  });
333
404
 
334
405
  const result = chart.cardContent as CardContent;
335
- const lastUpdatedItem = result.subHeaderItems[1];
336
406
 
337
- expect(lastUpdatedItem.label).toBe('generic.na');
338
- expect(lastUpdatedItem.labelTooltip).toBe('catalog.charts.appChartCard.subHeaderItem.missingVersionDate');
407
+ expect(result.subHeaderItems).toHaveLength(1);
408
+ expect(result.subHeaderItems[0].icon).toBe('icon-version-alt');
409
+ });
410
+
411
+ it('handles falsy time for last updated date', () => {
412
+ const chartWithFalsyTime = {
413
+ ...base,
414
+ versions: [{
415
+ ...base.versions[0],
416
+ created: '',
417
+ }]
418
+ };
419
+ const chart = new Chart(chartWithFalsyTime, {
420
+ rootGetters: {
421
+ 'cluster/all': () => [],
422
+ 'i18n/t': (key: string) => key,
423
+ currentCluster: { workerOSs: [] },
424
+ 'prefs/get': () => false,
425
+ },
426
+ });
427
+
428
+ const result = chart.cardContent as CardContent;
429
+
430
+ expect(result.subHeaderItems).toHaveLength(1);
431
+ expect(result.subHeaderItems[0].icon).toBe('icon-version-alt');
339
432
  });
340
433
  });
341
434
  });
@@ -0,0 +1,131 @@
1
+ import Feature from '@shell/models/management.cattle.io.feature.js';
2
+ import Resource from '@shell/plugins/dashboard-store/resource-class';
3
+
4
+ describe('class Feature', () => {
5
+ const ctx = {
6
+ dispatch: jest.fn(),
7
+ rootGetters: { 'i18n/t': (key: string) => key },
8
+ getters: { schemaFor: () => ({ linkFor: jest.fn() }) },
9
+ };
10
+
11
+ // The parent Resource._availableActions getter depends on runtime config we don't have
12
+ // in tests — stub it out so we can assert on Feature's own additions.
13
+ beforeEach(() => {
14
+ jest.spyOn(Resource.prototype, '_availableActions', 'get').mockReturnValue([]);
15
+ });
16
+
17
+ afterEach(() => {
18
+ jest.restoreAllMocks();
19
+ });
20
+
21
+ describe('enabled getter', () => {
22
+ it.each([
23
+ [true, true],
24
+ [false, false],
25
+ ])('should return lockedValue (%s) when status.lockedValue is not null', (lockedValue, expected) => {
26
+ const feature = new Feature({
27
+ spec: { value: false },
28
+ status: { lockedValue, default: false }
29
+ }, ctx);
30
+
31
+ expect(feature.enabled).toBe(expected);
32
+ });
33
+
34
+ it('should return spec.value when lockedValue is null and spec.value is set', () => {
35
+ const feature = new Feature({
36
+ spec: { value: true },
37
+ status: { lockedValue: null, default: false }
38
+ }, ctx);
39
+
40
+ expect(feature.enabled).toBe(true);
41
+ });
42
+
43
+ it('should fall back to status.default when lockedValue is null and spec.value is null', () => {
44
+ const feature = new Feature({
45
+ spec: { value: null },
46
+ status: { lockedValue: null, default: true }
47
+ }, ctx);
48
+
49
+ expect(feature.enabled).toBe(true);
50
+ });
51
+
52
+ it('should not throw when status is missing (malformed feature flag)', () => {
53
+ const feature = new Feature({ spec: { value: true } }, ctx);
54
+
55
+ expect(() => feature.enabled).not.toThrow();
56
+ expect(feature.enabled).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('restartRequired getter', () => {
61
+ it('should return false when status.dynamic is true', () => {
62
+ const feature = new Feature({ spec: {}, status: { dynamic: true, lockedValue: null } }, ctx);
63
+
64
+ expect(feature.restartRequired).toBe(false);
65
+ });
66
+
67
+ it('should return true when status.dynamic is false', () => {
68
+ const feature = new Feature({ spec: {}, status: { dynamic: false, lockedValue: null } }, ctx);
69
+
70
+ expect(feature.restartRequired).toBe(true);
71
+ });
72
+
73
+ it('should return true when status is missing (malformed feature flag)', () => {
74
+ const feature = new Feature({ spec: {} }, ctx);
75
+
76
+ expect(() => feature.restartRequired).not.toThrow();
77
+ expect(feature.restartRequired).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe('_availableActions getter', () => {
82
+ it('should disable the toggle action when lockedValue is not null', () => {
83
+ const feature = new Feature({
84
+ spec: { value: false },
85
+ status: {
86
+ lockedValue: true, default: false, dynamic: true
87
+ },
88
+ }, ctx);
89
+
90
+ jest.spyOn(feature, 'canUpdate', 'get').mockReturnValue(true);
91
+
92
+ const actions = feature._availableActions;
93
+
94
+ expect(actions[0].action).toBe('toggleFeatureFlag');
95
+ expect(actions[0].enabled).toBe(false);
96
+ });
97
+
98
+ it('should enable the toggle action when lockedValue is null and user canUpdate', () => {
99
+ const feature = new Feature({
100
+ spec: { value: false },
101
+ status: {
102
+ lockedValue: null, default: false, dynamic: true
103
+ },
104
+ id: 'some-feature',
105
+ }, ctx);
106
+
107
+ jest.spyOn(feature, 'canUpdate', 'get').mockReturnValue(true);
108
+
109
+ const actions = feature._availableActions;
110
+
111
+ expect(actions[0].action).toBe('toggleFeatureFlag');
112
+ expect(actions[0].enabled).toBe(true);
113
+ });
114
+
115
+ it('should not throw and should disable the toggle action when status is missing (malformed feature flag)', () => {
116
+ const feature = new Feature({
117
+ spec: { value: false },
118
+ id: 'some-feature',
119
+ }, ctx);
120
+
121
+ jest.spyOn(feature, 'canUpdate', 'get').mockReturnValue(true);
122
+
123
+ expect(() => feature._availableActions).not.toThrow();
124
+
125
+ const actions = feature._availableActions;
126
+
127
+ expect(actions[0].action).toBe('toggleFeatureFlag');
128
+ expect(actions[0].enabled).toBeFalsy();
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,98 @@
1
+ import AlertmanagerConfig from '@shell/models/monitoring.coreos.com.alertmanagerconfig';
2
+
3
+ const base = {
4
+ apiVersion: 'monitoring.coreos.com/v1alpha1',
5
+ kind: 'AlertmanagerConfig',
6
+ metadata: { name: 'test', namespace: 'default' },
7
+ };
8
+
9
+ const build = (data: Record<string, any>) => new AlertmanagerConfig(data) as any;
10
+
11
+ describe('class AlertmanagerConfig', () => {
12
+ describe('applyDefaults', () => {
13
+ it('on a fresh resource, seeds the route with defaults and no match/matchRe', () => {
14
+ const amc = build({ ...base });
15
+
16
+ amc.applyDefaults();
17
+
18
+ expect(amc.spec.receivers).toStrictEqual([]);
19
+ expect(amc.spec.route).toStrictEqual({
20
+ groupBy: [],
21
+ groupWait: '30s',
22
+ groupInterval: '5m',
23
+ repeatInterval: '4h',
24
+ matchers: [],
25
+ });
26
+ });
27
+
28
+ it('backfills route defaults on a resource loaded without a route', () => {
29
+ const amc = build({
30
+ ...base,
31
+ spec: { receivers: [{ name: 'existing' }] },
32
+ });
33
+
34
+ amc.applyDefaults();
35
+
36
+ expect(amc.spec.route).toBeDefined();
37
+ expect(amc.spec.route.receiver).toBeUndefined();
38
+ expect(amc.spec.route.matchers).toStrictEqual([]);
39
+ });
40
+
41
+ it('preserves existing matchers on load', () => {
42
+ const matchers = [{
43
+ name: 'severity', value: 'warning', matchType: '='
44
+ }];
45
+ const amc = build({
46
+ ...base,
47
+ spec: {
48
+ receivers: [{ name: 'existing' }],
49
+ route: { receiver: 'existing', matchers },
50
+ },
51
+ });
52
+
53
+ amc.applyDefaults();
54
+
55
+ expect(amc.spec.route.matchers).toStrictEqual(matchers);
56
+ });
57
+ });
58
+
59
+ describe('cleanForSave', () => {
60
+ it('drops spec.route when no receiver is set — this is what fixes #17347 on 109+ charts', () => {
61
+ const amc = build({ ...base });
62
+
63
+ const out = amc.cleanForSave({
64
+ ...base,
65
+ spec: {
66
+ receivers: [],
67
+ route: {
68
+ groupBy: [],
69
+ groupWait: '30s',
70
+ groupInterval: '5m',
71
+ repeatInterval: '4h',
72
+ matchers: [],
73
+ },
74
+ },
75
+ }, true);
76
+
77
+ expect(out.spec.route).toBeUndefined();
78
+ expect(out.spec.receivers).toStrictEqual([]);
79
+ });
80
+
81
+ it('keeps spec.route when a receiver is set', () => {
82
+ const amc = build({ ...base });
83
+
84
+ const out = amc.cleanForSave({
85
+ ...base,
86
+ spec: {
87
+ receivers: [{ name: 'existing' }],
88
+ route: {
89
+ receiver: 'existing', groupBy: [], matchers: []
90
+ },
91
+ },
92
+ }, false);
93
+
94
+ expect(out.spec.route).toBeDefined();
95
+ expect(out.spec.route.receiver).toBe('existing');
96
+ });
97
+ });
98
+ });
@@ -78,28 +78,32 @@ export default class CatalogApp extends SteveModel {
78
78
  return [];
79
79
  }
80
80
 
81
- // Filtering matches by verifying if the current version is in the matched chart's available versions, and that the home value matches as well
82
- const thisHome = chart?.metadata?.home;
83
- const bestMatches = matchingCharts.filter(({ versions }) => {
84
- // First checking if the latest version has the same home value
85
- if (thisHome === versions[0]?.home) {
86
- return true;
87
- }
81
+ if (!repoName || matchingCharts.length > 1) {
82
+ // Filtering matches by verifying if the current version is in the matched chart's available versions, and that the home value matches as well
83
+ const thisHome = chart?.metadata?.home;
84
+ const bestMatches = matchingCharts.filter(({ versions }) => {
85
+ // First checking if the latest version has the same home value
86
+ if (thisHome === versions[0]?.home) {
87
+ return true;
88
+ }
88
89
 
89
- for (let i = 1; i < versions.length; i++) {
90
- const { version, home } = versions[i];
90
+ for (let i = 1; i < versions.length; i++) {
91
+ const { version, home } = versions[i];
91
92
 
92
- // Finding the exact version, if the version is not there, then most likely it's not a match
93
- // if the exact version is found, then we can compare the home value
94
- if (version === this.currentVersion && (home === thisHome)) {
95
- return true;
93
+ // Finding the exact version, if the version is not there, then most likely it's not a match
94
+ // if the exact version is found, then we can compare the home value
95
+ if (version === this.currentVersion && (home === thisHome)) {
96
+ return true;
97
+ }
96
98
  }
97
- }
98
99
 
99
- return false;
100
- });
100
+ return false;
101
+ });
102
+
103
+ return bestMatches;
104
+ }
101
105
 
102
- return bestMatches;
106
+ return matchingCharts;
103
107
  }
104
108
 
105
109
  get currentVersion() {
@@ -41,26 +41,54 @@ export default class ClusterRepo extends SteveModel {
41
41
  });
42
42
 
43
43
  insertAt(out, 0, {
44
- action: 'refresh',
45
- label: this.t('action.refresh'),
46
- icon: 'icon icon-refresh',
47
- enabled: !!this.links.update,
48
- bulkable: true,
44
+ action: 'refresh',
45
+ label: this.t('action.refresh'),
46
+ icon: 'icon icon-refresh',
47
+ enabled: !!this.links.update,
48
+ bulkable: true,
49
+ bulkAction: 'refreshBulk',
49
50
  });
50
51
  }
51
52
 
52
53
  return out;
53
54
  }
54
55
 
55
- async refresh() {
56
- const now = (new Date()).toISOString().replace(/\.\d+Z$/, 'Z');
56
+ /**
57
+ * Refreshes the repository by updating its forceUpdate annotation and waiting for it to become active.
58
+ * @param {boolean} dispatchLoad - Whether to dispatch the catalog/load action after refreshing. Defaults to true.
59
+ */
60
+ async refresh(dispatchLoad = true) {
61
+ try {
62
+ const now = (new Date()).toISOString().replace(/\.\d+Z$/, 'Z');
57
63
 
58
- this.spec.forceUpdate = now;
59
- await this.save();
64
+ this.spec.forceUpdate = now;
65
+ await this.save();
66
+
67
+ await this.waitForState('active', 10000, 1000);
60
68
 
61
- await this.waitForState('active', 10000, 1000);
69
+ if (dispatchLoad) {
70
+ this.$dispatch('catalog/load', { force: true, repoKeys: [this._key] }, { root: true });
71
+ }
72
+ } catch (err) {
73
+ this.$dispatch('growl/fromError', {
74
+ title: this.t('catalog.repo.error.refresh', {}, 'Error refreshing repository'),
75
+ err,
76
+ }, { root: true });
77
+ }
78
+ }
62
79
 
63
- this.$dispatch('catalog/load', { force: true, reset: true }, { root: true });
80
+ /**
81
+ * Performs a bulk refresh on multiple repositories concurrently, bypassing individual
82
+ * catalog loads, and dispatches a single catalog/load for all repositories once they are active.
83
+ * @param {ClusterRepo[]} items - Array of repository instances to refresh.
84
+ */
85
+ async refreshBulk(items) {
86
+ await Promise.allSettled(items.map((item) => item.refresh(false)));
87
+
88
+ this.$dispatch('catalog/load', {
89
+ force: true,
90
+ repoKeys: items.map((item) => item._key)
91
+ }, { root: true });
64
92
  }
65
93
 
66
94
  async disableClusterRepo() {