@rancher/shell 3.0.10 → 3.0.12-rc.1

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 (154) hide show
  1. package/assets/styles/base/_mixins.scss +31 -0
  2. package/assets/styles/base/_variables.scss +2 -0
  3. package/assets/styles/themes/_modern.scss +6 -5
  4. package/assets/translations/en-us.yaml +12 -9
  5. package/assets/translations/zh-hans.yaml +0 -3
  6. package/chart/__tests__/rancher-backup-index.test.ts +248 -0
  7. package/chart/rancher-backup/index.vue +41 -2
  8. package/components/BrandImage.vue +6 -5
  9. package/components/ConsumptionGauge.vue +12 -4
  10. package/components/DynamicContent/DynamicContentIcon.vue +3 -2
  11. package/components/EmptyProductPage.vue +76 -0
  12. package/components/ExplorerProjectsNamespaces.vue +1 -4
  13. package/components/LazyImage.vue +2 -1
  14. package/components/Resource/Detail/Card/Scaler.vue +4 -4
  15. package/components/Resource/Detail/CopyToClipboard.vue +1 -2
  16. package/components/Resource/Detail/Metadata/KeyValueRow.vue +9 -3
  17. package/components/Resource/Detail/TitleBar/__tests__/__snapshots__/index.test.ts.snap +31 -0
  18. package/components/Resource/Detail/TitleBar/__tests__/index.test.ts +45 -1
  19. package/components/Resource/Detail/TitleBar/index.vue +1 -1
  20. package/components/Resource/Detail/ViewOptions/__tests__/__snapshots__/index.test.ts.snap +9 -0
  21. package/components/Resource/Detail/ViewOptions/__tests__/index.test.ts +62 -0
  22. package/components/Resource/Detail/ViewOptions/index.vue +2 -1
  23. package/components/ResourceList/Masthead.vue +25 -2
  24. package/components/SideNav.vue +13 -0
  25. package/components/Tabbed/index.vue +6 -0
  26. package/components/__tests__/ConsumptionGauge.test.ts +31 -0
  27. package/components/__tests__/PromptModal.test.ts +2 -0
  28. package/components/fleet/FleetClusters.vue +1 -0
  29. package/components/fleet/__tests__/FleetClusters.test.ts +71 -0
  30. package/components/form/NodeScheduling.vue +17 -3
  31. package/components/form/PrivateRegistry.vue +69 -0
  32. package/components/form/ProjectMemberEditor.vue +0 -10
  33. package/components/form/__tests__/PrivateRegistry.test.ts +133 -0
  34. package/components/formatter/WorkloadHealthScale.vue +3 -1
  35. package/components/nav/Group.vue +26 -3
  36. package/components/nav/Header.vue +32 -7
  37. package/components/nav/TopLevelMenu.helper.ts +7 -79
  38. package/components/nav/TopLevelMenu.vue +15 -1
  39. package/components/nav/__tests__/TopLevelMenu.helper.test.ts +2 -53
  40. package/config/pagination-table-headers.js +8 -1
  41. package/config/private-label.js +2 -1
  42. package/config/product/apps.js +3 -1
  43. package/config/product/auth.js +1 -0
  44. package/config/product/backup.js +1 -0
  45. package/config/product/compliance.js +1 -1
  46. package/config/product/explorer.js +25 -6
  47. package/config/product/fleet.js +1 -0
  48. package/config/product/gatekeeper.js +1 -0
  49. package/config/product/istio.js +1 -0
  50. package/config/product/logging.js +1 -0
  51. package/config/product/longhorn.js +2 -1
  52. package/config/product/manager.js +1 -0
  53. package/config/product/monitoring.js +1 -0
  54. package/config/product/navlinks.js +1 -0
  55. package/config/product/neuvector.js +2 -1
  56. package/config/product/settings.js +1 -0
  57. package/config/product/uiplugins.js +1 -0
  58. package/core/__tests__/extension-manager-impl.test.js +187 -2
  59. package/core/__tests__/plugin-products-helpers.test.ts +454 -0
  60. package/core/__tests__/plugin-products.test.ts +3219 -0
  61. package/core/extension-manager-impl.js +34 -3
  62. package/core/plugin-helpers.ts +31 -0
  63. package/core/plugin-products-base.ts +375 -0
  64. package/core/plugin-products-extending.ts +44 -0
  65. package/core/plugin-products-helpers.ts +262 -0
  66. package/core/plugin-products-top-level.ts +66 -0
  67. package/core/plugin-products-type-guards.ts +33 -0
  68. package/core/plugin-products.ts +50 -0
  69. package/core/plugin-types.ts +222 -0
  70. package/core/plugin.ts +45 -10
  71. package/core/productDebugger.js +48 -0
  72. package/core/types.ts +95 -11
  73. package/detail/__tests__/__snapshots__/fleet.cattle.io.bundle.test.ts.snap +52 -0
  74. package/detail/__tests__/fleet.cattle.io.bundle.test.ts +171 -0
  75. package/detail/__tests__/node.test.ts +83 -0
  76. package/detail/fleet.cattle.io.bundle.vue +21 -34
  77. package/detail/management.cattle.io.oidcclient.vue +2 -1
  78. package/detail/node.vue +1 -0
  79. package/dialog/ExtensionCatalogInstallDialog.vue +1 -1
  80. package/dialog/InstallExtensionDialog.vue +6 -27
  81. package/dialog/UninstallExistingExtensionDialog.vue +141 -0
  82. package/dialog/UninstallExtensionDialog.vue +4 -26
  83. package/dialog/__tests__/UninstallExistingExtensionDialog.test.ts +114 -0
  84. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +1 -0
  85. package/edit/catalog.cattle.io.clusterrepo.vue +17 -3
  86. package/edit/cloudcredential.vue +2 -1
  87. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +11 -6
  88. package/edit/provisioning.cattle.io.cluster/__tests__/Ingress.test.ts +176 -0
  89. package/edit/provisioning.cattle.io.cluster/index.vue +5 -4
  90. package/edit/provisioning.cattle.io.cluster/rke2.vue +4 -1
  91. package/edit/provisioning.cattle.io.cluster/shared.ts +4 -2
  92. package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +6 -0
  93. package/edit/provisioning.cattle.io.cluster/tabs/Ingress.vue +7 -2
  94. package/edit/secret/generic.vue +1 -0
  95. package/edit/secret/index.vue +2 -1
  96. package/edit/service.vue +2 -14
  97. package/list/management.cattle.io.feature.vue +7 -1
  98. package/list/provisioning.cattle.io.cluster.vue +0 -50
  99. package/list/workload.vue +11 -4
  100. package/mixins/brand.js +2 -1
  101. package/mixins/resource-fetch.js +12 -3
  102. package/models/catalog.cattle.io.clusterrepo.js +9 -0
  103. package/models/cluster.x-k8s.io.machinedeployment.js +8 -3
  104. package/models/management.cattle.io.authconfig.js +2 -1
  105. package/models/management.cattle.io.cluster.js +4 -3
  106. package/models/monitoring.coreos.com.receiver.js +11 -6
  107. package/models/pod.js +18 -0
  108. package/models/provisioning.cattle.io.cluster.js +2 -2
  109. package/models/workload.js +20 -2
  110. package/package.json +5 -6
  111. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +0 -1
  112. package/pages/c/_cluster/apps/charts/index.vue +3 -8
  113. package/pages/c/_cluster/apps/charts/install.vue +8 -9
  114. package/pages/c/_cluster/istio/index.vue +4 -2
  115. package/pages/c/_cluster/longhorn/index.vue +2 -1
  116. package/pages/c/_cluster/monitoring/index.vue +2 -2
  117. package/pages/c/_cluster/neuvector/index.vue +2 -1
  118. package/pages/c/_cluster/settings/brand.vue +4 -4
  119. package/pages/c/_cluster/settings/performance.vue +0 -5
  120. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +2 -1
  121. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +231 -13
  122. package/pages/c/_cluster/uiplugins/index.vue +145 -38
  123. package/plugins/dashboard-store/__tests__/resource-class.test.ts +1 -0
  124. package/plugins/dashboard-store/actions.js +3 -2
  125. package/plugins/dashboard-store/resource-class.js +62 -6
  126. package/plugins/plugin.js +16 -0
  127. package/plugins/steve/steve-pagination-utils.ts +8 -2
  128. package/plugins/steve/subscribe.js +29 -4
  129. package/rancher-components/RcButton/RcButton.vue +3 -3
  130. package/rancher-components/RcButtonSplit/RcButtonSplit.test.ts +253 -0
  131. package/rancher-components/RcButtonSplit/RcButtonSplit.vue +158 -0
  132. package/rancher-components/RcButtonSplit/index.ts +1 -0
  133. package/scripts/test-plugins-build.sh +4 -4
  134. package/scripts/typegen.sh +13 -1
  135. package/store/__tests__/type-map.test.ts +84 -24
  136. package/store/type-map.js +42 -3
  137. package/tsconfig.paths.json +1 -0
  138. package/types/resources/pod.ts +18 -0
  139. package/types/shell/index.d.ts +8506 -2908
  140. package/types/store/dashboard-store.types.ts +5 -0
  141. package/types/store/pagination.types.ts +6 -0
  142. package/utils/__tests__/require-asset.test.ts +98 -0
  143. package/utils/async.ts +1 -5
  144. package/utils/axios.js +1 -4
  145. package/utils/brand.ts +3 -1
  146. package/utils/dynamic-importer.js +3 -2
  147. package/utils/favicon.js +4 -3
  148. package/utils/pagination-utils.ts +1 -1
  149. package/utils/require-asset.ts +95 -0
  150. package/utils/uiplugins.ts +12 -16
  151. package/utils/validators/__tests__/private-registry.test.ts +76 -0
  152. package/utils/validators/private-registry.ts +28 -0
  153. package/vue.config.js +4 -3
  154. package/components/HarvesterServiceAddOnConfig.vue +0 -207
@@ -207,6 +207,68 @@ describe('page: UI plugins/Extensions', () => {
207
207
 
208
208
  expect(items).toHaveLength(0);
209
209
  });
210
+
211
+ it('should display repository name for non-installed plugins with chart info', () => {
212
+ const plugin = {
213
+ installed: false,
214
+ chart: { repoNameDisplay: 'rancher-charts' },
215
+ certified: true
216
+ };
217
+ const items = wrapper.vm.getFooterItems(plugin);
218
+
219
+ expect(items).toHaveLength(1);
220
+ expect(items[0].icon).toBe('repository-alt');
221
+ expect(items[0].labels).toStrictEqual(['rancher-charts']);
222
+ });
223
+
224
+ it('should display repository name for installed plugins when chart info is present (original repo exists)', () => {
225
+ const plugin = {
226
+ installed: true,
227
+ chart: { repoNameDisplay: 'rancher-charts' },
228
+ certified: true
229
+ };
230
+ const items = wrapper.vm.getFooterItems(plugin);
231
+
232
+ expect(items).toHaveLength(1);
233
+ expect(items[0].icon).toBe('repository-alt');
234
+ expect(items[0].labels).toStrictEqual(['rancher-charts']);
235
+ });
236
+
237
+ it('should display original repository name for installed plugins when original repo was removed', () => {
238
+ const plugin = {
239
+ installed: true,
240
+ originalRepoNameDisplay: 'removed-repo',
241
+ certified: true
242
+ };
243
+ const items = wrapper.vm.getFooterItems(plugin);
244
+
245
+ expect(items).toHaveLength(1);
246
+ expect(items[0].icon).toBe('repository-alt');
247
+ expect(items[0].labels).toStrictEqual(['removed-repo']);
248
+ });
249
+
250
+ it('should prefer chart.repoNameDisplay over originalRepoNameDisplay when both are present', () => {
251
+ const plugin = {
252
+ installed: true,
253
+ chart: { repoNameDisplay: 'current-repo' },
254
+ originalRepoNameDisplay: 'original-repo',
255
+ certified: true
256
+ };
257
+ const items = wrapper.vm.getFooterItems(plugin);
258
+
259
+ expect(items).toHaveLength(1);
260
+ expect(items[0].labels).toStrictEqual(['current-repo']);
261
+ });
262
+
263
+ it('should NOT display repository name when neither chart nor originalRepoNameDisplay are present', () => {
264
+ const plugin = {
265
+ installed: true,
266
+ certified: true
267
+ };
268
+ const items = wrapper.vm.getFooterItems(plugin);
269
+
270
+ expect(items).toHaveLength(0);
271
+ });
210
272
  });
211
273
 
212
274
  describe('getStatuses', () => {
@@ -269,6 +331,162 @@ describe('page: UI plugins/Extensions', () => {
269
331
  });
270
332
  });
271
333
 
334
+ describe('showInstallDialog', () => {
335
+ let dispatchMock: jest.Mock;
336
+
337
+ const createWrapper = (availablePlugins: any[] = []) => {
338
+ dispatchMock = jest.fn().mockResolvedValue(true);
339
+
340
+ const store = {
341
+ getters: {
342
+ 'prefs/get': jest.fn(),
343
+ 'catalog/rawCharts': {},
344
+ 'uiplugins/plugins': [],
345
+ 'uiplugins/errors': {},
346
+ 'management/all': () => [],
347
+ },
348
+ dispatch: dispatchMock,
349
+ };
350
+
351
+ const wrapper = shallowMount(UiPluginsPage, {
352
+ global: {
353
+ mocks: {
354
+ $store: store,
355
+ t,
356
+ },
357
+ stubs: { ActionMenu: { template: '<div />' } }
358
+ }
359
+ });
360
+
361
+ // Mock the available computed property
362
+ Object.defineProperty(wrapper.vm, 'available', { get: () => availablePlugins });
363
+
364
+ return wrapper;
365
+ };
366
+
367
+ it('should open UninstallExistingExtensionDialog when installing a plugin that is already installed from a different source', () => {
368
+ const installedPlugin = {
369
+ id: 'other-repo/my-plugin',
370
+ name: 'my-plugin',
371
+ installed: true
372
+ };
373
+ const pluginToInstall = {
374
+ id: 'new-repo/my-plugin',
375
+ name: 'my-plugin'
376
+ };
377
+
378
+ const wrapper = createWrapper([installedPlugin, pluginToInstall]);
379
+
380
+ wrapper.vm.showInstallDialog(pluginToInstall, 'install', {});
381
+
382
+ expect(dispatchMock).toHaveBeenCalledWith(
383
+ 'management/promptModal',
384
+ expect.objectContaining({ component: 'UninstallExistingExtensionDialog' })
385
+ );
386
+ });
387
+
388
+ it('should NOT open InstallExtensionDialog when plugin is already installed from a different source', () => {
389
+ const installedPlugin = {
390
+ id: 'other-repo/my-plugin',
391
+ name: 'my-plugin',
392
+ installed: true
393
+ };
394
+ const pluginToInstall = {
395
+ id: 'new-repo/my-plugin',
396
+ name: 'my-plugin'
397
+ };
398
+
399
+ const wrapper = createWrapper([installedPlugin, pluginToInstall]);
400
+
401
+ wrapper.vm.showInstallDialog(pluginToInstall, 'install', {});
402
+
403
+ expect(dispatchMock).not.toHaveBeenCalledWith(
404
+ 'management/promptModal',
405
+ expect.objectContaining({ component: 'InstallExtensionDialog' })
406
+ );
407
+ });
408
+
409
+ it('should pass the installedPlugin to UninstallExistingExtensionDialog', () => {
410
+ const installedPlugin = {
411
+ id: 'other-repo/my-plugin',
412
+ name: 'my-plugin',
413
+ installed: true
414
+ };
415
+ const pluginToInstall = {
416
+ id: 'new-repo/my-plugin',
417
+ name: 'my-plugin'
418
+ };
419
+
420
+ const wrapper = createWrapper([installedPlugin, pluginToInstall]);
421
+
422
+ wrapper.vm.showInstallDialog(pluginToInstall, 'install', {});
423
+
424
+ expect(dispatchMock).toHaveBeenCalledWith(
425
+ 'management/promptModal',
426
+ expect.objectContaining({
427
+ component: 'UninstallExistingExtensionDialog',
428
+ componentProps: expect.objectContaining({ installedPlugin })
429
+ })
430
+ );
431
+ });
432
+
433
+ it('should open InstallExtensionDialog when installing a plugin that is NOT installed from another source', () => {
434
+ const pluginToInstall = {
435
+ id: 'repo/my-plugin',
436
+ name: 'my-plugin'
437
+ };
438
+
439
+ const wrapper = createWrapper([pluginToInstall]);
440
+
441
+ wrapper.vm.showInstallDialog(pluginToInstall, 'install', {});
442
+
443
+ expect(dispatchMock).toHaveBeenCalledWith(
444
+ 'management/promptModal',
445
+ expect.objectContaining({ component: 'InstallExtensionDialog' })
446
+ );
447
+ });
448
+
449
+ it('should open InstallExtensionDialog when the same plugin id is being re-installed (same source)', () => {
450
+ const installedPlugin = {
451
+ id: 'repo/my-plugin',
452
+ name: 'my-plugin',
453
+ installed: true
454
+ };
455
+
456
+ const wrapper = createWrapper([installedPlugin]);
457
+
458
+ wrapper.vm.showInstallDialog(installedPlugin, 'install', {});
459
+
460
+ expect(dispatchMock).toHaveBeenCalledWith(
461
+ 'management/promptModal',
462
+ expect.objectContaining({ component: 'InstallExtensionDialog' })
463
+ );
464
+ });
465
+
466
+ it('should open InstallExtensionDialog for upgrade action even if same name exists in another repo', () => {
467
+ const installedPlugin = {
468
+ id: 'repo-a/my-plugin',
469
+ name: 'my-plugin',
470
+ installed: true
471
+ };
472
+ const otherPlugin = {
473
+ id: 'repo-b/my-plugin',
474
+ name: 'my-plugin',
475
+ installed: false
476
+ };
477
+
478
+ const wrapper = createWrapper([installedPlugin, otherPlugin]);
479
+
480
+ // Upgrade action should always go to InstallExtensionDialog
481
+ wrapper.vm.showInstallDialog(installedPlugin, 'upgrade', {});
482
+
483
+ expect(dispatchMock).toHaveBeenCalledWith(
484
+ 'management/promptModal',
485
+ expect.objectContaining({ component: 'InstallExtensionDialog' })
486
+ );
487
+ });
488
+ });
489
+
272
490
  describe('watch: helmOps', () => {
273
491
  let wrapper: VueWrapper<any>;
274
492
  let updatePluginInstallStatusMock: jest.Mock;
@@ -296,10 +514,10 @@ describe('page: UI plugins/Extensions', () => {
296
514
  computed: {
297
515
  // Override the computed property for this test suite
298
516
  available: () => [
299
- { name: 'plugin1' },
300
- { name: 'plugin2' },
301
- { name: 'plugin3' },
302
- { name: 'plugin4' },
517
+ { name: 'plugin1', id: 'repo/plugin1' },
518
+ { name: 'plugin2', id: 'repo/plugin2' },
519
+ { name: 'plugin3', id: 'repo/plugin3' },
520
+ { name: 'plugin4', id: 'repo/plugin4' },
303
521
  ],
304
522
  hasMenuActions: () => true,
305
523
  menuActions: () => []
@@ -311,9 +529,9 @@ describe('page: UI plugins/Extensions', () => {
311
529
 
312
530
  // Set the 'installing' status on the component instance
313
531
  wrapper.vm.installing = {
314
- plugin1: 'install',
315
- plugin2: 'downgrade',
316
- plugin3: 'uninstall',
532
+ 'repo/plugin1': 'install',
533
+ 'repo/plugin2': 'downgrade',
534
+ 'repo/plugin3': 'uninstall',
317
535
  };
318
536
 
319
537
  // Reset errors
@@ -330,7 +548,7 @@ describe('page: UI plugins/Extensions', () => {
330
548
  wrapper.vm.helmOps = helmOps;
331
549
  await wrapper.vm.$nextTick();
332
550
 
333
- expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin1', 'install');
551
+ expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('repo/plugin1', 'install');
334
552
  });
335
553
 
336
554
  it('should not update status for an upgrade op when a downgrade was initiated', async() => {
@@ -344,7 +562,7 @@ describe('page: UI plugins/Extensions', () => {
344
562
  await wrapper.vm.$nextTick();
345
563
 
346
564
  // It should not be called with 'upgrade' for plugin2
347
- expect(updatePluginInstallStatusMock).not.toHaveBeenCalledWith('plugin2', 'upgrade');
565
+ expect(updatePluginInstallStatusMock).not.toHaveBeenCalledWith('repo/plugin2', 'upgrade');
348
566
  });
349
567
 
350
568
  it('should clear status for a completed uninstall operation', async() => {
@@ -357,7 +575,7 @@ describe('page: UI plugins/Extensions', () => {
357
575
  wrapper.vm.helmOps = helmOps;
358
576
  await wrapper.vm.$nextTick();
359
577
 
360
- expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin3', false);
578
+ expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('repo/plugin3', false);
361
579
  });
362
580
 
363
581
  it('should set error and clear status for a failed operation', async() => {
@@ -370,8 +588,8 @@ describe('page: UI plugins/Extensions', () => {
370
588
  wrapper.vm.helmOps = helmOps;
371
589
  await wrapper.vm.$nextTick();
372
590
 
373
- expect(wrapper.vm.errors.plugin1).toBe(true);
374
- expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin1', false);
591
+ expect(wrapper.vm.errors['repo/plugin1']).toBe(true);
592
+ expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('repo/plugin1', false);
375
593
  });
376
594
 
377
595
  it('should clear status for plugins with no active operation', async() => {
@@ -385,7 +603,7 @@ describe('page: UI plugins/Extensions', () => {
385
603
  await wrapper.vm.$nextTick();
386
604
 
387
605
  // plugin4 has no op, so its status should be cleared
388
- expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin4', false);
606
+ expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('repo/plugin4', false);
389
607
  });
390
608
  });
391
609
  });
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
3
3
  import day from 'dayjs';
4
4
  import { mapPref, PLUGIN_DEVELOPER } from '@shell/store/prefs';
5
5
  import { sortBy } from '@shell/utils/sort';
6
+ import genericPluginSvg from '~shell/assets/images/generic-plugin.svg';
6
7
  import { allHash } from '@shell/utils/promise';
7
8
  import { CATALOG, UI_PLUGIN, MANAGEMENT, ZERO_TIME } from '@shell/config/types';
8
9
  import { SETTING } from '@shell/config/settings';
@@ -73,7 +74,7 @@ export default {
73
74
  menuTargetEvent: null,
74
75
  menuOpen: false,
75
76
  hasFeatureFlag: true,
76
- defaultIcon: require('~shell/assets/images/generic-plugin.svg'),
77
+ defaultIcon: genericPluginSvg,
77
78
  reloadRequired: false,
78
79
  rancherVersion: null
79
80
  };
@@ -100,6 +101,14 @@ export default {
100
101
  hash.helmOps = this.$store.dispatch('management/findAll', { type: CATALOG.OPERATION });
101
102
  }
102
103
 
104
+ // Load apps in UI_PLUGIN_NAMESPACE to determine which repo an extension was installed from
105
+ if (this.$store.getters['management/schemaFor'](CATALOG.APP)) {
106
+ hash.apps = this.$store.dispatch('management/findAll', {
107
+ type: CATALOG.APP,
108
+ opt: { namespaced: UI_PLUGIN_NAMESPACE }
109
+ });
110
+ }
111
+
103
112
  if (this.$store.getters['management/schemaFor'](CATALOG.CLUSTER_REPO)) {
104
113
  hash.repos = this.$store.dispatch('management/findAll', { type: CATALOG.CLUSTER_REPO }, { force: true });
105
114
  }
@@ -126,6 +135,12 @@ export default {
126
135
  ...mapGetters({ uiErrors: 'uiplugins/errors' }),
127
136
  ...mapGetters({ theme: 'prefs/theme' }),
128
137
 
138
+ // Computed to reactively update when new apps are installed
139
+ apps() {
140
+ return this.$store.getters['management/all'](CATALOG.APP)
141
+ .filter((app) => app.metadata?.namespace === UI_PLUGIN_NAMESPACE);
142
+ },
143
+
129
144
  charts() {
130
145
  const c = this.$store.getters['catalog/rawCharts'];
131
146
 
@@ -312,8 +327,8 @@ export default {
312
327
  }
313
328
  }
314
329
 
315
- if (this.installing[item.name]) {
316
- item.installing = this.installing[item.name];
330
+ if (this.installing[item.id]) {
331
+ item.installing = this.installing[item.id];
317
332
  }
318
333
 
319
334
  return item;
@@ -358,15 +373,23 @@ export default {
358
373
 
359
374
  // Go through the CRs for the plugins and wire them into the catalog
360
375
  this.plugins.forEach((p) => {
361
- const chart = all.find((c) => c.name === p.name);
376
+ let chart;
377
+ const app = this.apps.find((a) => a.metadata.name === p.name && a.metadata.namespace === UI_PLUGIN_NAMESPACE);
378
+ const originalRepoName = app?.spec?.chart?.metadata?.annotations?.[CATALOG_ANNOTATIONS.SOURCE_REPO_NAME] || app?.metadata?.labels?.[CATALOG_ANNOTATIONS.CLUSTER_REPO_NAME];
379
+
380
+ // Find the chart from the original repo to avoid picking a wrong chart with the same name
381
+ if (originalRepoName) {
382
+ chart = all.find((c) => c.name === p.name && c.chart?.repoName === originalRepoName);
383
+ }
362
384
 
385
+ // If original repo was removed, don't fall back to another repo's chart (would break Available tab)
363
386
  if (chart) {
364
387
  chart.installed = true;
365
388
  chart.uiplugin = p;
366
389
  chart.installedVersion = p.version;
367
390
 
368
391
  // Can't do this here
369
- chart.installing = this.installing[chart.name];
392
+ chart.installing = this.installing[chart.id];
370
393
 
371
394
  // Check for upgrade
372
395
  const latestInstallableVersion = chart.installableVersions?.[0];
@@ -384,20 +407,34 @@ export default {
384
407
  chart.upgrade = getPluginChartVersionLabel(latestInstallableVersion);
385
408
  }
386
409
  } else {
387
- // No chart, so add a card for the plugin based on its Custom resource being present
410
+ // No chart available - original repo was removed or developer-loaded plugin
411
+ const appChartMeta = app?.spec?.chart?.metadata;
412
+ const appAnnotations = appChartMeta?.annotations || {};
413
+ let originalRepoDisplayName = null;
414
+
415
+ if (originalRepoName) {
416
+ originalRepoDisplayName = this.$store.getters['i18n/withFallback'](`catalog.repo.name."${ originalRepoName }"`, null, originalRepoName);
417
+ }
418
+
388
419
  const item = {
389
- name: p.name,
390
- label: p.name,
391
- description: p.description || '-',
392
- id: `${ p.name }-${ p.version }`,
393
- versions: [],
394
- displayVersion: p.version,
395
- displayVersionLabel: p.version || '-',
396
- isDeveloper: p.isDeveloper,
397
- installed: true,
398
- installing: false,
399
- builtin: false,
400
- uiplugin: p,
420
+ name: p.name,
421
+ label: appAnnotations[UI_PLUGIN_CHART_ANNOTATIONS.DISPLAY_NAME] || appChartMeta?.name || p.name,
422
+ description: appChartMeta?.description || p.description || '-',
423
+ icon: appChartMeta?.icon || appAnnotations['catalog.cattle.io/ui-icon'],
424
+ id: `${ p.name }-${ p.version }`,
425
+ versions: [],
426
+ displayVersion: p.version,
427
+ displayVersionLabel: p.version || '-',
428
+ isDeveloper: p.isDeveloper,
429
+ installed: true,
430
+ installedVersion: p.version,
431
+ installing: false,
432
+ builtin: false,
433
+ uiplugin: p,
434
+ primeOnly: appAnnotations[CATALOG_ANNOTATIONS.PRIME_ONLY] === 'true',
435
+ experimental: appAnnotations[CATALOG_ANNOTATIONS.EXPERIMENTAL] === 'true',
436
+ certified: appAnnotations[CATALOG_ANNOTATIONS.CERTIFIED] === CATALOG_ANNOTATIONS._RANCHER,
437
+ originalRepoNameDisplay: originalRepoDisplayName,
401
438
  };
402
439
 
403
440
  all.push(item);
@@ -421,7 +458,7 @@ export default {
421
458
 
422
459
  // Merge in the plugin load errors from help ops
423
460
  Object.keys(this.errors).forEach((e) => {
424
- const chart = all.find((c) => c.name === e);
461
+ const chart = all.find((c) => c.id === e);
425
462
 
426
463
  if (chart) {
427
464
  chart.helmError = !!this.errors[e];
@@ -455,28 +492,50 @@ export default {
455
492
  const op = pluginOps.find((o) => o.status?.releaseName === plugin.name);
456
493
 
457
494
  if (op) {
495
+ const allWithSameName = this.available.filter((p) => p.name === plugin.name);
496
+ let targetPluginId;
497
+
498
+ // When multiple plugins share the same name (from different repositories),
499
+ // a single helm operation will trigger updates. We need to correctly identify
500
+ // the specific plugin that is either currently being installed/updated or is already installed
501
+ // so we don't accidentally mark all identically named plugins as "installing".
502
+ const installingPlugin = allWithSameName.find((p) => this.installing[p.id]);
503
+ const installedPlugin = allWithSameName.find((p) => p.installed);
504
+
505
+ if (installingPlugin) {
506
+ targetPluginId = installingPlugin.id;
507
+ } else if (installedPlugin) {
508
+ targetPluginId = installedPlugin.id;
509
+ } else {
510
+ targetPluginId = allWithSameName[0]?.id;
511
+ }
512
+
513
+ if (plugin.id !== targetPluginId) {
514
+ return;
515
+ }
516
+
458
517
  const active = op.metadata.state?.transitioning;
459
518
  const error = op.metadata.state?.error;
460
519
 
461
- this.errors[plugin.name] = error;
520
+ this.errors[plugin.id] = error;
462
521
 
463
522
  if (active) {
464
523
  // Can use the status directly, apart from upgrade, which maps to update
465
524
  const status = op.status.action;
466
525
 
467
- if (status === 'upgrade' && this.installing[plugin.name] === 'downgrade') {
526
+ if (status === 'upgrade' && this.installing[plugin.id] === 'downgrade') {
468
527
  // Helm op is an upgrade, but we initiated a downgrade, so keep the 'downgrade' status
469
528
  } else {
470
- this.updatePluginInstallStatus(plugin.name, status);
529
+ this.updatePluginInstallStatus(plugin.id, status);
471
530
  }
472
531
  } else if (op.status.action === 'uninstall') {
473
532
  // Uninstall has finished
474
- this.updatePluginInstallStatus(plugin.name, false);
533
+ this.updatePluginInstallStatus(plugin.id, false);
475
534
  } else if (error) {
476
- this.updatePluginInstallStatus(plugin.name, false);
535
+ this.updatePluginInstallStatus(plugin.id, false);
477
536
  }
478
537
  } else {
479
- this.updatePluginInstallStatus(plugin.name, false);
538
+ this.updatePluginInstallStatus(plugin.id, false);
480
539
  }
481
540
  });
482
541
  },
@@ -502,7 +561,11 @@ export default {
502
561
  changes++;
503
562
  }
504
563
 
505
- this.updatePluginInstallStatus(plugin.name, false);
564
+ (this.available || []).forEach((c) => {
565
+ if (c.name === plugin.name) {
566
+ this.updatePluginInstallStatus(c.id, false);
567
+ }
568
+ });
506
569
  }
507
570
  });
508
571
 
@@ -595,6 +658,35 @@ export default {
595
658
  ev?.preventDefault?.();
596
659
  ev?.stopPropagation?.();
597
660
 
661
+ // Check if a plugin with the same name is already installed from a different repo
662
+ if (action === 'install') {
663
+ const installedPlugin = this.available.find((p) => p.name === plugin.name && p.installed);
664
+ const isInstalledFromDifferentSource = !!installedPlugin && installedPlugin.id !== plugin.id;
665
+
666
+ if (isInstalledFromDifferentSource) {
667
+ // Show a different dialog that prompts the user to uninstall the existing version first
668
+ this.$store.dispatch('management/promptModal', {
669
+ component: 'UninstallExistingExtensionDialog',
670
+ testId: 'uninstall-existing-extension-modal',
671
+ returnFocusSelector: `[data-testid="extension-card-${ action }-btn-${ plugin?.name }"]`,
672
+ returnFocusFirstIterableNodeSelector: '#extensions-main-page',
673
+ componentProps: {
674
+ installedPlugin,
675
+ updateStatus: (pluginId, type) => {
676
+ this.updatePluginInstallStatus(pluginId, type);
677
+ },
678
+ closed: (res) => {
679
+ if (res?.uninstalled) {
680
+ this.didUninstall(res.plugin);
681
+ }
682
+ }
683
+ }
684
+ });
685
+
686
+ return;
687
+ }
688
+ }
689
+
598
690
  this.$store.dispatch('management/promptModal', {
599
691
  component: 'InstallExtensionDialog',
600
692
  testId: 'install-extension-modal',
@@ -604,8 +696,8 @@ export default {
604
696
  plugin,
605
697
  action,
606
698
  initialVersion,
607
- updateStatus: (pluginName, type) => {
608
- this.updatePluginInstallStatus(pluginName, type);
699
+ updateStatus: (pluginId, type) => {
700
+ this.updatePluginInstallStatus(pluginId, type);
609
701
  },
610
702
  closed: (res) => {
611
703
  this.didInstall(res);
@@ -626,8 +718,8 @@ export default {
626
718
  returnFocusFirstIterableNodeSelector: '#extensions-main-page',
627
719
  componentProps: {
628
720
  plugin,
629
- updateStatus: (pluginName, type) => {
630
- this.updatePluginInstallStatus(pluginName, type);
721
+ updateStatus: (pluginId, type) => {
722
+ this.updatePluginInstallStatus(pluginId, type);
631
723
  },
632
724
  closed: (res) => {
633
725
  this.didUninstall(res);
@@ -638,7 +730,7 @@ export default {
638
730
 
639
731
  didUninstall(plugin) {
640
732
  if (plugin) {
641
- this.updatePluginInstallStatus(plugin.name, 'uninstall');
733
+ this.updatePluginInstallStatus(plugin.id, 'uninstall');
642
734
 
643
735
  if (plugin.catalog) {
644
736
  this.refreshCharts();
@@ -665,8 +757,8 @@ export default {
665
757
  this.$refs.infoPanel.show({ ...plugin, tags });
666
758
  },
667
759
 
668
- updatePluginInstallStatus(name, status) {
669
- this.installing[name] = status;
760
+ updatePluginInstallStatus(id, status) {
761
+ this.installing[id] = status;
670
762
  },
671
763
 
672
764
  setMenu(event) {
@@ -849,6 +941,17 @@ export default {
849
941
  },
850
942
 
851
943
  getFooterItems(plugin) {
944
+ const footerItems = [];
945
+ const repoNameToDisplay = plugin?.chart?.repoNameDisplay || plugin?.originalRepoNameDisplay;
946
+
947
+ if (repoNameToDisplay) {
948
+ footerItems.push({
949
+ icon: 'repository-alt',
950
+ iconTooltip: { key: 'tableHeaders.repoName' },
951
+ labels: [repoNameToDisplay]
952
+ });
953
+ }
954
+
852
955
  const labels = [];
853
956
 
854
957
  // "developer load" tag
@@ -872,11 +975,15 @@ export default {
872
975
  labels.push(this.t('plugins.labels.experimental'));
873
976
  }
874
977
 
875
- return labels.length ? [{
876
- icon: 'tag-alt',
877
- iconTooltip: { key: 'generic.tags' },
878
- labels,
879
- }] : [];
978
+ if (labels.length) {
979
+ footerItems.push({
980
+ icon: 'tag-alt',
981
+ iconTooltip: { key: 'generic.tags' },
982
+ labels,
983
+ });
984
+ }
985
+
986
+ return footerItems;
880
987
  },
881
988
 
882
989
  getStatuses(plugin) {
@@ -436,6 +436,7 @@ describe('class: Resource', () => {
436
436
  }, {
437
437
  getters: { schemaFor: () => ({ linkFor: jest.fn() }) },
438
438
  dispatch: jest.fn(),
439
+ rootState: { $extension: { getPlugins: () => ({}) } },
439
440
  rootGetters: {
440
441
  'i18n/t': (key: string) => key,
441
442
  currentCluster: undefined,
@@ -494,8 +494,9 @@ export default {
494
494
  // Of type @StorePaginationResult
495
495
  const pagination = opt.pagination ? {
496
496
  request: {
497
- namespace: opt.namespaced,
498
- pagination: opt.pagination
497
+ namespace: opt.namespaced,
498
+ pagination: opt.pagination,
499
+ includeAssociatedData: opt.includeAssociatedData,
499
500
  },
500
501
  result: {
501
502
  count: out.count,