@rancher/shell 3.0.10 → 3.0.11

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 (64) hide show
  1. package/assets/translations/en-us.yaml +7 -5
  2. package/chart/__tests__/rancher-backup-index.test.ts +248 -0
  3. package/chart/rancher-backup/index.vue +41 -2
  4. package/components/BrandImage.vue +6 -5
  5. package/components/ConsumptionGauge.vue +12 -4
  6. package/components/DynamicContent/DynamicContentIcon.vue +3 -2
  7. package/components/ExplorerProjectsNamespaces.vue +1 -4
  8. package/components/LazyImage.vue +2 -1
  9. package/components/Resource/Detail/Card/Scaler.vue +4 -4
  10. package/components/Tabbed/index.vue +6 -0
  11. package/components/__tests__/ConsumptionGauge.test.ts +31 -0
  12. package/components/form/ProjectMemberEditor.vue +0 -10
  13. package/components/nav/TopLevelMenu.helper.ts +7 -79
  14. package/components/nav/__tests__/TopLevelMenu.helper.test.ts +2 -53
  15. package/config/private-label.js +2 -1
  16. package/config/product/apps.js +1 -0
  17. package/core/__tests__/extension-manager-impl.test.js +187 -2
  18. package/core/extension-manager-impl.js +4 -2
  19. package/core/plugin-helpers.ts +31 -0
  20. package/detail/__tests__/node.test.ts +83 -0
  21. package/detail/management.cattle.io.oidcclient.vue +2 -1
  22. package/detail/node.vue +1 -0
  23. package/edit/catalog.cattle.io.clusterrepo.vue +17 -3
  24. package/edit/cloudcredential.vue +2 -1
  25. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +11 -6
  26. package/edit/provisioning.cattle.io.cluster/index.vue +5 -4
  27. package/edit/provisioning.cattle.io.cluster/shared.ts +4 -2
  28. package/edit/secret/generic.vue +1 -0
  29. package/edit/secret/index.vue +2 -1
  30. package/edit/service.vue +2 -14
  31. package/list/management.cattle.io.feature.vue +7 -1
  32. package/list/provisioning.cattle.io.cluster.vue +0 -49
  33. package/mixins/brand.js +2 -1
  34. package/models/catalog.cattle.io.clusterrepo.js +9 -0
  35. package/models/cluster.x-k8s.io.machinedeployment.js +8 -3
  36. package/models/management.cattle.io.authconfig.js +2 -1
  37. package/models/management.cattle.io.cluster.js +4 -3
  38. package/models/monitoring.coreos.com.receiver.js +11 -6
  39. package/models/provisioning.cattle.io.cluster.js +2 -2
  40. package/package.json +5 -5
  41. package/pages/c/_cluster/apps/charts/index.vue +3 -8
  42. package/pages/c/_cluster/apps/charts/install.vue +8 -9
  43. package/pages/c/_cluster/istio/index.vue +4 -2
  44. package/pages/c/_cluster/longhorn/index.vue +2 -1
  45. package/pages/c/_cluster/monitoring/index.vue +2 -2
  46. package/pages/c/_cluster/neuvector/index.vue +2 -1
  47. package/pages/c/_cluster/settings/performance.vue +0 -5
  48. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +2 -1
  49. package/pages/c/_cluster/uiplugins/index.vue +2 -1
  50. package/plugins/steve/steve-pagination-utils.ts +1 -2
  51. package/plugins/steve/subscribe.js +29 -4
  52. package/rancher-components/RcButton/RcButton.vue +3 -3
  53. package/rancher-components/RcButtonSplit/RcButtonSplit.test.ts +253 -0
  54. package/rancher-components/RcButtonSplit/RcButtonSplit.vue +158 -0
  55. package/rancher-components/RcButtonSplit/index.ts +1 -0
  56. package/scripts/test-plugins-build.sh +4 -4
  57. package/types/shell/index.d.ts +1 -0
  58. package/utils/__tests__/require-asset.test.ts +98 -0
  59. package/utils/async.ts +1 -5
  60. package/utils/brand.ts +3 -1
  61. package/utils/favicon.js +4 -3
  62. package/utils/require-asset.ts +95 -0
  63. package/vue.config.js +4 -3
  64. package/components/HarvesterServiceAddOnConfig.vue +0 -207
@@ -107,7 +107,6 @@ export interface TopLevelMenuHelper {
107
107
 
108
108
  export abstract class BaseTopLevelMenuHelper {
109
109
  protected $store: VuexStore;
110
- protected hasProvCluster: boolean;
111
110
 
112
111
  /**
113
112
  * Filter mgmt clusters by
@@ -143,11 +142,9 @@ export abstract class BaseTopLevelMenuHelper {
143
142
  $store: VuexStore,
144
143
  }) {
145
144
  this.$store = $store;
146
-
147
- this.hasProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER);
148
145
  }
149
146
 
150
- protected convertToCluster(mgmtCluster: MgmtCluster, provCluster: ProvCluster): TopLevelMenuCluster {
147
+ protected convertToCluster(mgmtCluster: MgmtCluster, provCluster?: ProvCluster): TopLevelMenuCluster {
151
148
  return {
152
149
  id: mgmtCluster.id,
153
150
  label: mgmtCluster.nameDisplay,
@@ -173,7 +170,6 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
173
170
 
174
171
  private clustersPinnedWrapper: PaginationWrapper<any>;
175
172
  private clustersOthersWrapper: PaginationWrapper<any>;
176
- private provClusterWrapper: PaginationWrapper<any>;
177
173
 
178
174
  private clusterCount = 0;
179
175
 
@@ -223,43 +219,10 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
223
219
  },
224
220
  formatResponse: { classify: true },
225
221
  });
226
- // Fetch all prov clusters for the mgmt clusters we have
227
- this.provClusterWrapper = new PaginationWrapper({
228
- $store,
229
- id: 'top-level-menu-prov-clusters',
230
- onChange: async({ forceWatch, revision }) => {
231
- if (!this.args) {
232
- return;
233
- }
234
- try {
235
- await this.update({
236
- ...this.args,
237
- forceWatch,
238
- provClusterRevision: revision,
239
- });
240
- } catch {
241
- // Failures should be logged lower down, not much we can do here except catch to prevent whole ui page warnings in dev mode
242
- }
243
- },
244
- enabledFor: {
245
- store: STORE.MANAGEMENT,
246
- resource: {
247
- id: CAPI.RANCHER_CLUSTER,
248
- context: 'side-bar',
249
- }
250
- },
251
- formatResponse: { classify: true }
252
- });
253
222
  }
254
223
 
255
224
  // ---------- requests ----------
256
225
  async update(args: UpdateArgs) {
257
- if (!this.hasProvCluster) {
258
- // We're filtering out mgmt clusters without prov clusters, so if the user can't see any prov clusters at all
259
- // exit early
260
- return;
261
- }
262
-
263
226
  this.args = args;
264
227
  const promises = {
265
228
  pinned: this.updatePinned(args),
@@ -271,22 +234,11 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
271
234
  notPinned: MgmtCluster[]
272
235
  } = await allHash(promises) as any;
273
236
 
274
- const provClusters = await this.updateProvCluster(res.notPinned, res.pinned, args);
275
- const provClustersByMgmtId = provClusters.reduce((res: { [mgmtId: string]: ProvCluster}, provCluster: ProvCluster) => {
276
- if (provCluster.mgmtClusterId) {
277
- res[provCluster.mgmtClusterId] = provCluster;
278
- }
279
-
280
- return res;
281
- }, {} as { [mgmtId: string]: ProvCluster});
282
-
283
237
  // Filter out mgmt clusters that don't have matching prov cluster and convert remaining to required format
284
238
  const _clustersNotPinned = res.notPinned
285
- .filter((mgmtCluster) => !!provClustersByMgmtId[mgmtCluster.id])
286
- .map((mgmtCluster) => this.convertToCluster(mgmtCluster, provClustersByMgmtId[mgmtCluster.id]));
239
+ .map((mgmtCluster) => this.convertToCluster(mgmtCluster));
287
240
  const _clustersPinned = res.pinned
288
- .filter((mgmtCluster) => !!provClustersByMgmtId[mgmtCluster.id])
289
- .map((mgmtCluster) => this.convertToCluster(mgmtCluster, provClustersByMgmtId[mgmtCluster.id]));
241
+ .map((mgmtCluster) => this.convertToCluster(mgmtCluster));
290
242
 
291
243
  this.clustersPinned.length = 0;
292
244
  this.clustersOthers.length = 0;
@@ -298,7 +250,6 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
298
250
  async destroy() {
299
251
  this.clustersPinnedWrapper.onDestroy();
300
252
  this.clustersOthersWrapper.onDestroy();
301
- this.provClusterWrapper.onDestroy();
302
253
  }
303
254
 
304
255
  /**
@@ -445,41 +396,21 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
445
396
  console.warn('Unable to set saved count for clusters', err); // eslint-disable-line no-console
446
397
  }
447
398
  }
448
-
449
- /**
450
- * Find all provisioning clusters associated with the displayed mgmt clusters
451
- */
452
- private async updateProvCluster(notPinned: MgmtCluster[], pinned: MgmtCluster[], args: UpdateArgs): Promise<ProvCluster[]> {
453
- return this.provClusterWrapper.request({
454
- forceWatch: args.forceWatch,
455
- pagination: {
456
- filters: [
457
- PaginationParamFilter.createMultipleFields(
458
- [...notPinned, ...pinned]
459
- .map((mgmtCluster) => ({
460
- field: 'status.clusterName', value: mgmtCluster.id, equals: true, exact: true
461
- }))
462
- )
463
- ],
464
- page: 1,
465
- sort: [],
466
- projectsOrNamespaces: []
467
- },
468
- revision: args.provClusterRevision
469
- })
470
- .then((r) => r.data);
471
- }
472
399
  }
473
400
 
474
401
  /**
475
402
  * Helper designed to supply non-paginated results for the top level menu cluster resources
476
403
  */
477
404
  export class TopLevelMenuHelperLegacy extends BaseTopLevelMenuHelper implements TopLevelMenuHelper {
405
+ protected hasProvCluster: boolean;
406
+
478
407
  constructor({ $store }: {
479
408
  $store: VuexStore,
480
409
  }) {
481
410
  super({ $store });
482
411
 
412
+ this.hasProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER);
413
+
483
414
  if (this.hasProvCluster) {
484
415
  $store.dispatch('management/findAll', { type: CAPI.RANCHER_CLUSTER });
485
416
  }
@@ -664,9 +595,6 @@ class TopLevelMenuHelperService {
664
595
  const canPagination = $store.getters[`management/paginationEnabled`]({
665
596
  id: MANAGEMENT.CLUSTER,
666
597
  context: 'side-bar',
667
- }) && $store.getters[`management/paginationEnabled`]({
668
- id: CAPI.RANCHER_CLUSTER,
669
- context: 'side-bar',
670
598
  });
671
599
 
672
600
  this._helper = canPagination ? new TopLevelMenuHelperPagination({ $store }) : new TopLevelMenuHelperLegacy({ $store });
@@ -102,7 +102,7 @@ describe('topLevelMenu.helper', () => {
102
102
  it('should initialize PaginationWrappers', () => {
103
103
  mockStore.getters['management/schemaFor'].mockReturnValue(true);
104
104
  new TopLevelMenuHelperPagination({ $store: mockStore });
105
- expect(PaginationWrapper).toHaveBeenCalledTimes(3);
105
+ expect(PaginationWrapper).toHaveBeenCalledTimes(2);
106
106
  });
107
107
 
108
108
  it('should update clusters correctly', async() => {
@@ -113,19 +113,13 @@ describe('topLevelMenu.helper', () => {
113
113
  const mgmtOthers = [{
114
114
  id: 'c2', nameDisplay: 'Other', isReady: true, pinned: false, pin: jest.fn(), unpin: jest.fn()
115
115
  }];
116
- const provClusters = [
117
- { mgmtClusterId: 'c1' },
118
- { mgmtClusterId: 'c2' }
119
- ];
120
116
 
121
117
  const mockRequestPinned = jest.fn().mockResolvedValue({ data: mgmtPinned });
122
118
  const mockRequestOthers = jest.fn().mockResolvedValue({ data: mgmtOthers });
123
- const mockRequestProv = jest.fn().mockResolvedValue({ data: provClusters });
124
119
 
125
120
  (PaginationWrapper as unknown as jest.Mock)
126
121
  .mockImplementationOnce(() => ({ request: mockRequestPinned, onDestroy: jest.fn() }))
127
- .mockImplementationOnce(() => ({ request: mockRequestOthers, onDestroy: jest.fn() }))
128
- .mockImplementationOnce(() => ({ request: mockRequestProv, onDestroy: jest.fn() }));
122
+ .mockImplementationOnce(() => ({ request: mockRequestOthers, onDestroy: jest.fn() }));
129
123
 
130
124
  const helper = new TopLevelMenuHelperPagination({ $store: mockStore });
131
125
 
@@ -169,57 +163,12 @@ describe('topLevelMenu.helper', () => {
169
163
  },
170
164
  revision: undefined
171
165
  });
172
- expect(mockRequestProv).toHaveBeenCalledWith({
173
- forceWatch: undefined,
174
- pagination: {
175
- filters: [{
176
- equals: true,
177
- fields: [{
178
- equals: true, exact: true, field: 'status.clusterName', value: mgmtOthers[0].id
179
- }, {
180
- equals: true, exact: true, field: 'status.clusterName', value: mgmtPinned[0].id
181
- }],
182
- param: 'filter'
183
- }],
184
- page: 1,
185
- projectsOrNamespaces: [],
186
- sort: []
187
- },
188
- revision: undefined
189
- });
190
166
 
191
167
  expect(helper.clustersPinned).toHaveLength(1);
192
168
  expect(helper.clustersPinned[0].id).toBe('c1');
193
169
  expect(helper.clustersOthers).toHaveLength(1);
194
170
  expect(helper.clustersOthers[0].id).toBe('c2');
195
171
  });
196
-
197
- it('should filter out mgmt clusters without matching prov clusters', async() => {
198
- mockStore.getters['management/schemaFor'].mockReturnValue(true);
199
- const mgmtOthers = [{
200
- id: 'c2', nameDisplay: 'Other', isReady: true, pinned: false, pin: jest.fn(), unpin: jest.fn()
201
- }];
202
- // No prov cluster for c2
203
- const provClusters: any[] = [];
204
-
205
- const mockRequestPinned = jest.fn().mockResolvedValue({ data: [] });
206
- const mockRequestOthers = jest.fn().mockResolvedValue({ data: mgmtOthers });
207
- const mockRequestProv = jest.fn().mockResolvedValue({ data: provClusters });
208
-
209
- (PaginationWrapper as unknown as jest.Mock)
210
- .mockImplementationOnce(() => ({ request: mockRequestPinned, onDestroy: jest.fn() }))
211
- .mockImplementationOnce(() => ({ request: mockRequestOthers, onDestroy: jest.fn() }))
212
- .mockImplementationOnce(() => ({ request: mockRequestProv, onDestroy: jest.fn() }));
213
-
214
- const helper = new TopLevelMenuHelperPagination({ $store: mockStore });
215
-
216
- await helper.update({
217
- searchTerm: '',
218
- pinnedIds: [],
219
- });
220
-
221
- expect(helper.clustersOthers).toHaveLength(0);
222
- });
223
172
  });
224
173
 
225
174
  describe('class: TopLevelMenuHelperService', () => {
@@ -1,5 +1,6 @@
1
1
  import { SETTING } from './settings';
2
2
  import { CURRENT_RANCHER_VERSION } from './version';
3
+ import { requireAsset } from '@shell/utils/require-asset';
3
4
 
4
5
  export const ANY = 0;
5
6
  export const STANDARD = 1;
@@ -78,7 +79,7 @@ export function setTitle() {
78
79
  const v = getVendor();
79
80
 
80
81
  if (v === 'Harvester') {
81
- const ico = require(`~shell/assets/images/pl/harvester.png`);
82
+ const ico = requireAsset(`~shell/assets/images/pl/harvester.png`);
82
83
 
83
84
  document.title = 'Harvester';
84
85
  const link = document.createElement('link');
@@ -53,6 +53,7 @@ export function init(store) {
53
53
 
54
54
  configureType(CATALOG.APP, { isCreatable: false, isEditable: false });
55
55
  configureType(CATALOG.OPERATION, { isCreatable: false, isEditable: false });
56
+ configureType(CATALOG.CLUSTER_REPO, { listCreateButtonLabelKey: 'catalog.repo.add' });
56
57
 
57
58
  const repoType = {
58
59
  name: 'type',
@@ -38,8 +38,30 @@ jest.mock('@shell/core/plugin', () => {
38
38
  };
39
39
  });
40
40
 
41
- // Mock ExtensionPoint
42
- jest.mock('@shell/core/types', () => ({ ExtensionPoint: { EDIT_YAML: 'edit-yaml' } }));
41
+ // Mock ExtensionPoint — only EDIT_YAML, simulating an older dashboard (e.g. 2.13)
42
+ // that does not know about newer ExtensionPoints like 'Table'.
43
+ // The ensureUIConfigCompat tests override this to include TABLE for plugin-helpers,
44
+ // simulating an extension shipping a newer shell.
45
+ jest.mock('@shell/core/types', () => ({
46
+ ExtensionPoint: { EDIT_YAML: 'edit-yaml' },
47
+ ActionLocation: { TABLE: 'table-action' },
48
+ CardLocation: {},
49
+ }));
50
+
51
+ jest.mock('@shell/utils/platform', () => ({ isMac: false }));
52
+ jest.mock('@shell/utils/string', () => ({
53
+ ucFirst: jest.fn((s) => s),
54
+ randomStr: jest.fn(() => 'abc123'),
55
+ }));
56
+ jest.mock('@shell/config/query-params', () => ({
57
+ _EDIT: 'edit',
58
+ _CONFIG: 'config',
59
+ _DETAIL: 'detail',
60
+ _LIST: 'list',
61
+ _CREATE: 'create',
62
+ }));
63
+ jest.mock('@shell/utils/router', () => ({ getProductFromRoute: jest.fn() }));
64
+ jest.mock('@shell/utils/object', () => ({ isEqual: jest.fn() }));
43
65
 
44
66
  // Mock PluginRoutes
45
67
  jest.mock('@shell/core/plugin-routes', () => {
@@ -299,6 +321,28 @@ describe('extension Manager', () => {
299
321
  delete window[pluginId];
300
322
  });
301
323
 
324
+ it('surfaces the plugin id and original error message when initialization fails', async() => {
325
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
326
+ const pluginId = 'surface-error-plugin';
327
+ const originalError = new Error('something went wrong');
328
+ const mockPluginInit = jest.fn().mockImplementation(() => {
329
+ throw originalError;
330
+ });
331
+
332
+ window[pluginId] = { default: mockPluginInit };
333
+
334
+ const loadPromise = manager.loadAsync(pluginId, 'test.js');
335
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
336
+
337
+ script.onload();
338
+
339
+ await expect(loadPromise).rejects.toThrow(`Could not initialize plugin ${ pluginId } - ${ originalError.message }`);
340
+ expect(consoleErrorSpy).toHaveBeenCalledWith(`Could not initialize plugin ${ pluginId }`, originalError);
341
+
342
+ consoleErrorSpy.mockRestore();
343
+ delete window[pluginId];
344
+ });
345
+
302
346
  it('rejects if script load fails', async() => {
303
347
  const pluginId = 'fail-plugin';
304
348
  const loadPromise = manager.loadAsync(pluginId, 'bad-url.js');
@@ -433,5 +477,146 @@ describe('extension Manager', () => {
433
477
 
434
478
  expect(config).toStrictEqual([]);
435
479
  });
480
+
481
+ it('returns empty array for non-existent type', () => {
482
+ // Simulates an extension using a newer ExtensionPoint (e.g. 'Table')
483
+ // that the running dashboard does not have in its uiConfig
484
+ const config = manager.getUIConfig('unknown-type', 'some-area');
485
+
486
+ expect(config).toStrictEqual([]);
487
+ });
488
+ });
489
+
490
+ describe('ensureUIConfigCompat (via plugin-helpers)', () => {
491
+ // These tests verify that getApplicableExtensionEnhancements tracks missing
492
+ // ExtensionPoint keys and exits early when accessing them.
493
+ // This is the forwards-compatibility mechanism for extensions running on
494
+ // older dashboards that don't know about newer ExtensionPoints (e.g. 'Table').
495
+
496
+ const mockRoute = {
497
+ name: 'test',
498
+ params: {},
499
+ query: {},
500
+ meta: {},
501
+ hash: '',
502
+ path: '/test',
503
+ };
504
+
505
+ // Each test needs a fresh plugin-helpers module to reset the _uiConfigPatched tracking.
506
+ // The extension-manager is created with the "old" ExtensionPoint (only EDIT_YAML),
507
+ // then plugin-helpers is re-required with an "upgraded" ExtensionPoint that includes TABLE,
508
+ // simulating an extension built with a newer shell running on an older dashboard.
509
+ let freshGetApplicable;
510
+ let freshManager;
511
+
512
+ beforeEach(() => {
513
+ freshManager = createExtensionManager(context);
514
+
515
+ // Reset modules so plugin-helpers._uiConfigPatched starts as empty object
516
+ jest.resetModules();
517
+
518
+ // Override the types mock so plugin-helpers sees a newer ExtensionPoint with TABLE
519
+ jest.doMock('@shell/core/types', () => ({
520
+ ExtensionPoint: {
521
+ EDIT_YAML: 'edit-yaml',
522
+ TABLE: 'Table',
523
+ },
524
+ ActionLocation: { TABLE: 'table-action' },
525
+ CardLocation: {},
526
+ }));
527
+
528
+ freshGetApplicable = require('@shell/core/plugin-helpers').getApplicableExtensionEnhancements;
529
+ });
530
+
531
+ it('returns empty array for missing ExtensionPoint and does not mutate uiConfig', () => {
532
+ // Verify 'Table' is NOT in uiConfig before the call
533
+ const uiConfig = freshManager.getAllUIConfig();
534
+
535
+ expect(uiConfig['Table']).toBeUndefined();
536
+
537
+ // Call getApplicableExtensionEnhancements which triggers ensureUIConfigCompat
538
+ const result = freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
539
+
540
+ // Should return empty array for missing extension point
541
+ expect(result).toStrictEqual([]);
542
+
543
+ // uiConfig should NOT be mutated - 'Table' should remain undefined
544
+ expect(uiConfig['Table']).toBeUndefined();
545
+ });
546
+
547
+ it('does not affect existing ExtensionPoint keys', () => {
548
+ const mockAction = { label: 'Test', locationConfig: {} };
549
+ const plugin = {
550
+ types: {},
551
+ uiConfig: { 'edit-yaml': { header: [mockAction] } },
552
+ l10n: {},
553
+ modelExtensions: {},
554
+ stores: [],
555
+ locales: [],
556
+ routes: [],
557
+ validators: {},
558
+ productNames: [],
559
+ };
560
+
561
+ freshManager.applyPlugin(plugin);
562
+
563
+ const result = freshGetApplicable({ $extension: freshManager }, 'edit-yaml', 'header', mockRoute);
564
+
565
+ // Should return the actual action
566
+ expect(result).toHaveLength(1);
567
+ expect(result[0].label).toBe('Test');
568
+
569
+ // Existing config should be untouched
570
+ const uiConfig = freshManager.getAllUIConfig();
571
+
572
+ expect(uiConfig['edit-yaml'].header).toHaveLength(1);
573
+ expect(uiConfig['edit-yaml'].header[0]).toBe(mockAction);
574
+ });
575
+
576
+ it('logs a warning for missing ExtensionPoints', () => {
577
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
578
+
579
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
580
+
581
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
582
+ expect.stringContaining('Table')
583
+ );
584
+
585
+ consoleWarnSpy.mockRestore();
586
+ });
587
+
588
+ it('dashboard-side getUIConfig calls remain safe with optional chaining', () => {
589
+ // getUIConfig('Table', ...) should not crash even though 'Table' is missing
590
+ // Returns empty array due to optional chaining in extension-manager
591
+ expect(freshManager.getUIConfig('Table', 'some-area')).toStrictEqual([]);
592
+
593
+ // Trigger ensureUIConfigCompat via getApplicableExtensionEnhancements
594
+ const result = freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
595
+
596
+ // getApplicableExtensionEnhancements exits early and returns empty array
597
+ expect(result).toStrictEqual([]);
598
+
599
+ // uiConfig is NOT mutated - 'Table' key still doesn't exist
600
+ const uiConfig = freshManager.getAllUIConfig();
601
+
602
+ expect(uiConfig['Table']).toBeUndefined();
603
+
604
+ // Direct getUIConfig calls still work safely (optional chaining)
605
+ expect(freshManager.getUIConfig('Table', 'some-area')).toStrictEqual([]);
606
+ });
607
+
608
+ it('only logs warning once for each missing ExtensionPoint', () => {
609
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
610
+
611
+ // First call logs warning and tracks 'Table' as missing
612
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
613
+ // Second call should not log warning again (already tracked)
614
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
615
+
616
+ // Warning should only be logged once
617
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
618
+
619
+ consoleWarnSpy.mockRestore();
620
+ });
436
621
  });
437
622
  });
@@ -124,7 +124,9 @@ export const createExtensionManager = (context) => {
124
124
  } catch (e) {
125
125
  delete plugins[id];
126
126
 
127
- return reject(new Error('Could not initialize plugin'));
127
+ console.error(`Could not initialize plugin ${ id }`, e); // eslint-disable-line no-console
128
+
129
+ return reject(new Error(`Could not initialize plugin ${ id } - ${ e?.message }`));
128
130
  }
129
131
 
130
132
  // Load all of the types etc from the plugin
@@ -426,7 +428,7 @@ export const createExtensionManager = (context) => {
426
428
  * Return the UI configuration for the given type and location
427
429
  */
428
430
  getUIConfig(type, uiArea) {
429
- return uiConfig[type][uiArea] || [];
431
+ return uiConfig[type]?.[uiArea] || [];
430
432
  },
431
433
 
432
434
  /**
@@ -137,6 +137,30 @@ function checkExtensionRouteBinding($route: any, locationConfig: any, context: a
137
137
  return res;
138
138
  }
139
139
 
140
+ // Track which ExtensionPoint keys are missing from the extension manager's uiConfig.
141
+ // This handles forwards-compatibility when extensions ship a newer shell that defines
142
+ // ExtensionPoint values the running dashboard doesn't know about (e.g. 'Table' on 2.13).
143
+ const _uiConfigPatched: { [point: string]: boolean } = {};
144
+
145
+ function ensureUIConfigCompat(extensionManager: any) {
146
+ const uiConfig = extensionManager.getAllUIConfig?.();
147
+
148
+ if (uiConfig) {
149
+ const missingPoints: { [point: string]: boolean } = {};
150
+
151
+ Object.values(ExtensionPoint).forEach((ep) => {
152
+ if (!uiConfig[ep] && !_uiConfigPatched[ep]) {
153
+ missingPoints[ep] = true;
154
+ _uiConfigPatched[ep] = true;
155
+ }
156
+ });
157
+
158
+ if (Object.keys(missingPoints).length) {
159
+ console.warn(`[plugin-helpers] These ExtensionPoints aren't available for usage in this Rancher version: ${ Object.keys(missingPoints).join(', ') }`); // eslint-disable-line no-console
160
+ }
161
+ }
162
+ }
163
+
140
164
  export function getApplicableExtensionEnhancements<T>(
141
165
  pluginCtx: ComponentOptionsMixin,
142
166
  actionType: ExtensionPoint,
@@ -148,6 +172,13 @@ export function getApplicableExtensionEnhancements<T>(
148
172
 
149
173
  // gate it so that we prevent errors on older versions of dashboard
150
174
  if (pluginCtx.$extension?.getUIConfig) {
175
+ ensureUIConfigCompat(pluginCtx.$extension);
176
+
177
+ // Exit early if actionType doesn't exist in the extension manager's uiConfig
178
+ if (_uiConfigPatched[actionType]) {
179
+ return [];
180
+ }
181
+
151
182
  const actions = pluginCtx.$extension.getUIConfig(actionType, uiArea);
152
183
 
153
184
  actions.forEach((action: any, i: number) => {
@@ -0,0 +1,83 @@
1
+ import { shallowMount } from '@vue/test-utils';
2
+ import node from '@shell/detail/node.vue';
3
+ import ConsumptionGauge from '@shell/components/ConsumptionGauge.vue';
4
+
5
+ describe('view: node detail', () => {
6
+ const mockStore = {
7
+ getters: {
8
+ 'cluster/schemaFor': () => undefined,
9
+ 'cluster/paginationEnabled': () => false,
10
+ 'type-map/headersFor': jest.fn(),
11
+ 'i18n/t': (key: string) => key,
12
+ currentCluster: { id: 'local' },
13
+ },
14
+ dispatch: jest.fn(),
15
+ };
16
+
17
+ const mocks = {
18
+ $store: mockStore,
19
+ $fetchState: { pending: false },
20
+ };
21
+
22
+ const defaultNodeValue = {
23
+ metadata: { name: 'test-node' },
24
+ status: {
25
+ nodeInfo: {}, images: [], conditions: []
26
+ },
27
+ spec: { taints: [] },
28
+ pods: [],
29
+ cpuCapacity: 4,
30
+ cpuUsage: 2,
31
+ ramReserved: 8000,
32
+ ramUsage: 4000,
33
+ podCapacity: 110,
34
+ podConsumed: 5,
35
+ isPidPressureOk: true,
36
+ isDiskPressureOk: true,
37
+ isMemoryPressureOk: true,
38
+ isKubeletOk: true,
39
+ internalIp: '10.0.0.1',
40
+ };
41
+
42
+ function createWrapper(nodeOverrides = {}) {
43
+ return shallowMount(node, {
44
+ props: { value: { ...defaultNodeValue, ...nodeOverrides } },
45
+ global: { mocks },
46
+ });
47
+ }
48
+
49
+ function findGaugeByResource(wrapper: ReturnType<typeof createWrapper>, resourceKey: string) {
50
+ const gauges = wrapper.findAllComponents(ConsumptionGauge);
51
+
52
+ return gauges.find(
53
+ (g) => g.attributes('resourcename')?.includes(resourceKey)
54
+ );
55
+ }
56
+
57
+ it('should pass the "running" translation key as usedlabel to the pods ConsumptionGauge', () => {
58
+ const wrapper = createWrapper();
59
+
60
+ const podsGauge = findGaugeByResource(wrapper, 'consumptionGauge.pods');
61
+
62
+ expect(podsGauge).toBeDefined();
63
+ expect(podsGauge!.attributes('usedlabel')).toContain('consumptionGauge.running');
64
+ });
65
+
66
+ it('should NOT pass a usedlabel to the CPU ConsumptionGauge', () => {
67
+ const wrapper = createWrapper();
68
+
69
+ const cpuGauge = findGaugeByResource(wrapper, 'consumptionGauge.cpu');
70
+
71
+ expect(cpuGauge).toBeDefined();
72
+ expect(cpuGauge!.attributes('usedlabel')).toBeUndefined();
73
+ });
74
+
75
+ it('should NOT pass a usedlabel to the Memory ConsumptionGauge', () => {
76
+ const wrapper = createWrapper();
77
+
78
+ const memoryGauge = findGaugeByResource(wrapper, 'consumptionGauge.memory');
79
+
80
+ expect(memoryGauge).toBeDefined();
81
+ expect(memoryGauge!.attributes('usedlabel')).toBeUndefined();
82
+ });
83
+ });
@@ -9,6 +9,7 @@ import DateComponent from '@shell/components/formatter/Date.vue';
9
9
  import { RcItemCard } from '@components/RcItemCard';
10
10
  import ActionMenu, { type ActionMenuSelection } from '@shell/components/ActionMenuShell.vue';
11
11
  import { Banner } from '@components/Banner';
12
+ import keySvg from '~shell/assets/images/key.svg';
12
13
 
13
14
  type SecretActionType = 'create-secret' | 'regen-secret' | 'remove-secret'
14
15
  interface ClientSecretData { createdAt: string, lastUsedAt: string, lastFiveCharacters: string }
@@ -228,7 +229,7 @@ export default defineComponent({
228
229
  clientSecrets.push({
229
230
  id: oidcSecretDataKey,
230
231
  header: { title: { text: oidcSecretDataKey } },
231
- image: { src: require('~shell/assets/images/key.svg') },
232
+ image: { src: keySvg },
232
233
  createdAt,
233
234
  lastFiveCharacters: oidcSecretData.lastFiveCharacters,
234
235
  lastUsedAt,
package/detail/node.vue CHANGED
@@ -234,6 +234,7 @@ export default {
234
234
  :resource-name="t('node.detail.glance.consumptionGauge.pods')"
235
235
  :capacity="value.podCapacity"
236
236
  :used="value.podConsumed"
237
+ :used-label="t('node.detail.glance.consumptionGauge.running')"
237
238
  />
238
239
  </div>
239
240
  <div class="spacer" />