@rancher/shell 3.0.12-rc.6 → 3.0.12-rc.7

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 (30) hide show
  1. package/apis/intf/shell-api/slide-in.ts +46 -17
  2. package/apis/shell/__tests__/slide-in.test.ts +83 -2
  3. package/apis/shell/slide-in.ts +20 -0
  4. package/components/ExplorerProjectsNamespaces.vue +2 -2
  5. package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +9 -0
  6. package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +5 -1
  7. package/components/SlideInPanelManager.vue +103 -25
  8. package/components/__tests__/SlideInPanelManager.spec.ts +153 -10
  9. package/components/auth/AuthBanner.vue +1 -1
  10. package/core/plugins-loader.js +2 -0
  11. package/models/__tests__/namespace.test.ts +133 -8
  12. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +57 -1
  13. package/models/namespace.js +34 -2
  14. package/models/provisioning.cattle.io.cluster.js +20 -13
  15. package/package.json +1 -1
  16. package/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts +136 -1
  17. package/pages/c/_cluster/explorer/workload-dashboard/composable.ts +69 -1
  18. package/pages/c/_cluster/fleet/index.vue +5 -9
  19. package/pkg/vue.config.js +4 -3
  20. package/plugins/codemirror.js +2 -0
  21. package/plugins/dashboard-store/resource-class.js +3 -6
  22. package/store/__tests__/action-menu.test.ts +622 -0
  23. package/store/__tests__/slideInPanel.test.ts +143 -43
  24. package/store/__tests__/wm.test.ts +503 -0
  25. package/store/aws.js +19 -4
  26. package/store/slideInPanel.ts +15 -3
  27. package/types/shell/index.d.ts +26 -2
  28. package/utils/__tests__/fleet-appco.test.ts +23 -0
  29. package/utils/fleet-appco.ts +6 -1
  30. package/utils/sort.js +1 -1
@@ -5,7 +5,7 @@ import SlideInPanelManager from '@shell/components/SlideInPanelManager.vue';
5
5
 
6
6
  const MockComponent = {
7
7
  template: '<div data-testid="slide-in-panel-component">Mock Panel Content</div>',
8
- props: ['width', 'title', 'extraProp']
8
+ props: ['title', 'extraProp']
9
9
  };
10
10
 
11
11
  describe('slideInPanelManager.vue with Teleport', () => {
@@ -23,7 +23,7 @@ describe('slideInPanelManager.vue with Teleport', () => {
23
23
  'slideInPanel/isOpen': () => true,
24
24
  'slideInPanel/component': () => MockComponent,
25
25
  'slideInPanel/componentProps': () => ({
26
- width: '40%', title: 'Test Title', extraProp: 'extra'
26
+ width: 'default', title: 'Test Title', extraProp: 'extra'
27
27
  })
28
28
  };
29
29
 
@@ -61,15 +61,14 @@ describe('slideInPanelManager.vue with Teleport', () => {
61
61
 
62
62
  const styleAttr = slidePanel.getAttribute('style') || '';
63
63
 
64
- expect(styleAttr).toContain('width: 40%');
64
+ expect(styleAttr).toContain('width: 33%');
65
65
  expect(styleAttr).toContain('top: 55px');
66
66
  expect(styleAttr).toContain('height: calc(100vh - 55px)');
67
67
  expect(styleAttr).toContain('right: 0');
68
68
  });
69
69
 
70
- it('renders default panel title when no title is provided', async() => {
71
- // Update getter so that no title is provided
72
- getters['slideInPanel/componentProps'] = () => ({ width: '40%' });
70
+ it('hides header when no title is provided', async() => {
71
+ getters['slideInPanel/componentProps'] = () => ({});
73
72
  store = createStore({
74
73
  getters,
75
74
  mutations: { 'slideInPanel/close': jest.fn() }
@@ -77,9 +76,9 @@ describe('slideInPanelManager.vue with Teleport', () => {
77
76
  factory();
78
77
  await nextTick();
79
78
 
80
- const headerTitle = document.querySelector('#slides #slide-in-panel-manager .header .title') as HTMLElement;
79
+ const header = document.querySelector('#slides #slide-in-panel-manager .header') as HTMLElement;
81
80
 
82
- expect(headerTitle.textContent?.trim()).toBe('Details');
81
+ expect(header).toBeNull();
83
82
  });
84
83
 
85
84
  it('computes panelTop correctly when a banner exists', async() => {
@@ -122,8 +121,8 @@ describe('slideInPanelManager.vue with Teleport', () => {
122
121
  const slidePanel = document.querySelector('#slides .slide-in') as HTMLElement;
123
122
  const styleAttr = slidePanel.getAttribute('style') || '';
124
123
 
125
- // With currentProps width "40%", panelRight should be "-40%" when closed.
126
- expect(styleAttr).toContain('right: -40%');
124
+ // With default width preset (33%), panelRight should be "-33%" when closed.
125
+ expect(styleAttr).toContain('right: -33%');
127
126
  });
128
127
 
129
128
  it('calls store commit when clicking on the slide-in glass overlay', async() => {
@@ -163,4 +162,148 @@ describe('slideInPanelManager.vue with Teleport', () => {
163
162
 
164
163
  expect(closeMutation).toHaveBeenCalledWith({}, undefined);
165
164
  });
165
+
166
+ describe('width preset', () => {
167
+ it('resolves width "wide" to 73%', async() => {
168
+ getters['slideInPanel/componentProps'] = () => ({ width: 'wide', title: 'Test' });
169
+ store = createStore({
170
+ getters,
171
+ mutations: { 'slideInPanel/close': jest.fn() }
172
+ });
173
+ factory();
174
+ await nextTick();
175
+
176
+ const slidePanel = document.querySelector('#slides .slide-in') as HTMLElement;
177
+ const styleAttr = slidePanel.getAttribute('style') || '';
178
+
179
+ expect(styleAttr).toContain('width: 73%');
180
+ });
181
+
182
+ it('defaults to 33% when no width config is provided', async() => {
183
+ getters['slideInPanel/componentProps'] = () => ({ title: 'Test' });
184
+ store = createStore({
185
+ getters,
186
+ mutations: { 'slideInPanel/close': jest.fn() }
187
+ });
188
+ factory();
189
+ await nextTick();
190
+
191
+ const slidePanel = document.querySelector('#slides .slide-in') as HTMLElement;
192
+ const styleAttr = slidePanel.getAttribute('style') || '';
193
+
194
+ expect(styleAttr).toContain('width: 33%');
195
+ });
196
+ });
197
+
198
+ describe('height preset', () => {
199
+ it('resolves height "full" to 100vh with top 0', async() => {
200
+ getters['slideInPanel/componentProps'] = () => ({ height: 'full', title: 'Test' });
201
+ store = createStore({
202
+ getters,
203
+ mutations: { 'slideInPanel/close': jest.fn() }
204
+ });
205
+ factory();
206
+ await nextTick();
207
+
208
+ const slidePanel = document.querySelector('#slides .slide-in') as HTMLElement;
209
+ const styleAttr = slidePanel.getAttribute('style') || '';
210
+
211
+ expect(styleAttr).toContain('height: 100vh');
212
+ expect(styleAttr).toContain('top: 0');
213
+ });
214
+
215
+ it('applies elevated z-index when panelHeight is "full"', async() => {
216
+ getters['slideInPanel/componentProps'] = () => ({ height: 'full', title: 'Test' });
217
+ store = createStore({
218
+ getters,
219
+ mutations: { 'slideInPanel/close': jest.fn() }
220
+ });
221
+ factory();
222
+ await nextTick();
223
+
224
+ const slidePanel = document.querySelector('#slides .slide-in') as HTMLElement;
225
+ const styleAttr = slidePanel.getAttribute('style') || '';
226
+
227
+ expect(styleAttr).toContain('z-index: 102');
228
+
229
+ const slideGlass = document.querySelector('[data-testid="slide-in-glass"]') as HTMLElement;
230
+ const glassStyle = slideGlass.getAttribute('style') || '';
231
+
232
+ expect(glassStyle).toContain('z-index: 101');
233
+ });
234
+
235
+ it('does not apply elevated z-index for default panelHeight', async() => {
236
+ getters['slideInPanel/componentProps'] = () => ({ title: 'Test' });
237
+ store = createStore({
238
+ getters,
239
+ mutations: { 'slideInPanel/close': jest.fn() }
240
+ });
241
+ factory();
242
+ await nextTick();
243
+
244
+ const slidePanel = document.querySelector('#slides .slide-in') as HTMLElement;
245
+ const styleAttr = slidePanel.getAttribute('style') || '';
246
+
247
+ expect(styleAttr).not.toContain('z-index: 102');
248
+ });
249
+ });
250
+
251
+ describe('showHeader inferred from title', () => {
252
+ it('shows header when title is provided', async() => {
253
+ getters['slideInPanel/componentProps'] = () => ({ title: 'My Panel' });
254
+ store = createStore({
255
+ getters,
256
+ mutations: { 'slideInPanel/close': jest.fn() }
257
+ });
258
+ factory();
259
+ await nextTick();
260
+
261
+ const header = document.querySelector('#slides .slide-in .header') as HTMLElement;
262
+
263
+ expect(header).toBeTruthy();
264
+ expect(header.textContent?.trim()).toContain('My Panel');
265
+ });
266
+
267
+ it('hides header when no title is provided', async() => {
268
+ getters['slideInPanel/componentProps'] = () => ({});
269
+ store = createStore({
270
+ getters,
271
+ mutations: { 'slideInPanel/close': jest.fn() }
272
+ });
273
+ factory();
274
+ await nextTick();
275
+
276
+ const header = document.querySelector('#slides .slide-in .header') as HTMLElement;
277
+
278
+ expect(header).toBeNull();
279
+ });
280
+
281
+ it('respects deprecated showHeader=true even without title', async() => {
282
+ getters['slideInPanel/componentProps'] = () => ({ showHeader: true });
283
+ store = createStore({
284
+ getters,
285
+ mutations: { 'slideInPanel/close': jest.fn() }
286
+ });
287
+ factory();
288
+ await nextTick();
289
+
290
+ const header = document.querySelector('#slides .slide-in .header') as HTMLElement;
291
+
292
+ expect(header).toBeTruthy();
293
+ });
294
+
295
+ it('respects deprecated showHeader=false even with title', async() => {
296
+ getters['slideInPanel/componentProps'] = () => ({ showHeader: false, title: 'Should Not Show' });
297
+ store = createStore({
298
+ getters,
299
+ mutations: { 'slideInPanel/close': jest.fn() }
300
+ });
301
+ factory();
302
+ await nextTick();
303
+
304
+ const header = document.querySelector('#slides .slide-in .header') as HTMLElement;
305
+
306
+ expect(header).toBeNull();
307
+ });
308
+ });
166
309
  });
@@ -60,7 +60,7 @@ export default {
60
60
  <slot name="actions" />
61
61
  <button
62
62
  type="button"
63
- class="btn-sm role-primary"
63
+ class="btn btn-sm role-primary"
64
64
  @click="edit"
65
65
  >
66
66
  {{ t('action.edit') }}
@@ -1,4 +1,5 @@
1
1
  import * as vue3 from 'vue';
2
+ import * as vueRouter from 'vue-router';
2
3
  import $ from 'jquery';
3
4
  import JSZip from 'jszip';
4
5
  import jsyaml from 'js-yaml';
@@ -25,6 +26,7 @@ export default function({
25
26
 
26
27
  // Global libraries - allows us to externalise these to reduce package bundle size
27
28
  window.Vue = vue3;
29
+ window.__vueRouter = vueRouter;
28
30
  window.$ = $;
29
31
  window.__jszip = JSZip;
30
32
  window.__jsyaml = jsyaml;
@@ -1,6 +1,20 @@
1
1
  import Namespace from '@shell/models/namespace';
2
2
  import { SYSTEM_NAMESPACE } from '@shell/config/labels-annotations';
3
3
  import SYSTEM_NAMESPACES from '@shell/config/system-namespaces';
4
+ import { LOCAL_CLUSTER } from '@shell/config/types';
5
+ import { NAME as MANAGER } from '@shell/config/product/manager';
6
+ import { NAME as EXPLORER } from '@shell/config/product/explorer';
7
+ import sideNavService from '@shell/components/nav/TopLevelMenu.helper';
8
+
9
+ jest.mock('@shell/components/nav/TopLevelMenu.helper', () => ({
10
+ __esModule: true,
11
+ default: {
12
+ helper: {
13
+ clustersPinned: [],
14
+ clustersOthers: [],
15
+ },
16
+ },
17
+ }));
4
18
 
5
19
  describe('class Namespace', () => {
6
20
  describe('checking if isSystem', () => {
@@ -201,24 +215,64 @@ describe('class Namespace', () => {
201
215
  expect(namespace.listLocation.name).toBe(name);
202
216
  });
203
217
 
204
- it('should return the name and resource if Harvester', () => {
218
+ it('should route to local cluster explorer when in manager product', () => {
205
219
  const namespace = new Namespace({});
206
220
 
207
221
  jest.spyOn(namespace, '$rootGetters', 'get').mockReturnValue({
208
222
  isRancher: true,
209
- currentProduct: { inStore: 'harvester' }
223
+ productId: MANAGER,
224
+ clusterId: '_',
225
+ currentProduct: { inStore: '' },
210
226
  });
211
227
 
212
- const value = {
213
- name: 'harvester-c-cluster-projectsnamespaces',
214
- params: { resource: 'namespace' }
215
- };
228
+ expect(namespace.listLocation.params.cluster).toBe(LOCAL_CLUSTER);
229
+ expect(namespace.listLocation.params.product).toBe(EXPLORER);
230
+ });
231
+
232
+ it('should use current cluster and always use explorer product when not in manager', () => {
233
+ const namespace = new Namespace({});
216
234
 
217
- expect(namespace.listLocation).toStrictEqual(value);
235
+ jest.spyOn(namespace, '$rootGetters', 'get').mockReturnValue({
236
+ isRancher: true,
237
+ productId: EXPLORER,
238
+ clusterId: 'c-abc',
239
+ currentProduct: { inStore: '' },
240
+ });
241
+
242
+ expect(namespace.listLocation.params.cluster).toBe('c-abc');
243
+ expect(namespace.listLocation.params.product).toBe(EXPLORER);
218
244
  });
219
245
  });
220
246
 
221
- it.todo('should return _detailLocation with a name');
247
+ describe('handling _detailLocation', () => {
248
+ const mockDetailLocation = (namespace: any, overrides: Record<string, any>) => {
249
+ jest.spyOn(namespace, '$rootGetters', 'get').mockReturnValue(overrides);
250
+ jest.spyOn(namespace, '$getters', 'get').mockReturnValue({ schemaFor: () => ({ attributes: { namespaced: false } }) });
251
+ Object.defineProperty(namespace, 'isProdRegistrationV2TopLevelProductResoure', { get: () => false });
252
+ };
253
+
254
+ it('should route to local cluster explorer when in manager product', () => {
255
+ const namespace = new Namespace({});
256
+
257
+ mockDetailLocation(namespace, { productId: MANAGER, clusterId: '_' });
258
+
259
+ const loc = namespace._detailLocation;
260
+
261
+ expect(loc.params.cluster).toBe(LOCAL_CLUSTER);
262
+ expect(loc.params.product).toBe(EXPLORER);
263
+ });
264
+
265
+ it('should use current cluster and product when not in manager product', () => {
266
+ const namespace = new Namespace({});
267
+
268
+ mockDetailLocation(namespace, { productId: EXPLORER, clusterId: 'c-abc' });
269
+
270
+ const loc = namespace._detailLocation;
271
+
272
+ expect(loc.params.cluster).toBe('c-abc');
273
+ expect(loc.params.product).toBe(EXPLORER);
274
+ });
275
+ });
222
276
  it.todo('should return the resourceQuota');
223
277
  it.todo('should set the resourceQuota as reactive Vue property');
224
278
  it.todo('should reset project with cleanForNew');
@@ -263,6 +317,7 @@ describe('class Namespace', () => {
263
317
  const namespace = new Namespace({});
264
318
 
265
319
  jest.spyOn(namespace, 'project', 'get').mockReturnValue(null);
320
+ jest.spyOn(namespace, '$rootGetters', 'get').mockReturnValue({ productId: EXPLORER });
266
321
  Object.defineProperty(namespace, '_glance', { get: jest.fn(() => [{ name: 'namespace' }, { name: 'other' }]) });
267
322
 
268
323
  const result = namespace.glance;
@@ -270,6 +325,76 @@ describe('class Namespace', () => {
270
325
  expect(result).toHaveLength(1);
271
326
  expect(result[0].name).toBe('other');
272
327
  });
328
+
329
+ it('should remove type Link formatter when in manager product without local cluster access', () => {
330
+ const namespace = new Namespace({});
331
+
332
+ sideNavService.helper.clustersPinned.length = 0;
333
+ sideNavService.helper.clustersOthers.length = 0;
334
+
335
+ jest.spyOn(namespace, 'project', 'get').mockReturnValue(null);
336
+ jest.spyOn(namespace, '$rootGetters', 'get').mockReturnValue({ productId: MANAGER });
337
+ Object.defineProperty(namespace, '_glance', {
338
+ get: jest.fn(() => [
339
+ {
340
+ name: 'type', formatter: 'Link', formatterOpts: { to: {}, row: {} }
341
+ },
342
+ { name: 'other' },
343
+ ])
344
+ });
345
+
346
+ const result = namespace.glance;
347
+ const typeItem = result.find((item: any) => item.name === 'type');
348
+
349
+ expect(typeItem.formatter).toBeUndefined();
350
+ expect(typeItem.formatterOpts).toBeUndefined();
351
+ });
352
+
353
+ it('should keep type Link formatter when in manager product with local cluster access', () => {
354
+ const namespace = new Namespace({});
355
+
356
+ sideNavService.helper.clustersPinned.length = 0;
357
+ sideNavService.helper.clustersPinned.push({ id: LOCAL_CLUSTER } as any);
358
+ sideNavService.helper.clustersOthers.length = 0;
359
+
360
+ jest.spyOn(namespace, 'project', 'get').mockReturnValue(null);
361
+ jest.spyOn(namespace, '$rootGetters', 'get').mockReturnValue({ productId: MANAGER });
362
+ Object.defineProperty(namespace, '_glance', {
363
+ get: jest.fn(() => [
364
+ {
365
+ name: 'type', formatter: 'Link', formatterOpts: { to: {}, row: {} }
366
+ },
367
+ { name: 'other' },
368
+ ])
369
+ });
370
+
371
+ const result = namespace.glance;
372
+ const typeItem = result.find((item: any) => item.name === 'type');
373
+
374
+ expect(typeItem.formatter).toBe('Link');
375
+ expect(typeItem.formatterOpts).toBeDefined();
376
+ });
377
+
378
+ it('should keep type Link formatter when not in manager product', () => {
379
+ const namespace = new Namespace({});
380
+
381
+ jest.spyOn(namespace, 'project', 'get').mockReturnValue(null);
382
+ jest.spyOn(namespace, '$rootGetters', 'get').mockReturnValue({ productId: EXPLORER });
383
+ Object.defineProperty(namespace, '_glance', {
384
+ get: jest.fn(() => [
385
+ {
386
+ name: 'type', formatter: 'Link', formatterOpts: { to: {}, row: {} }
387
+ },
388
+ { name: 'other' },
389
+ ])
390
+ });
391
+
392
+ const result = namespace.glance;
393
+ const typeItem = result.find((item: any) => item.name === 'type');
394
+
395
+ expect(typeItem.formatter).toBe('Link');
396
+ expect(typeItem.formatterOpts).toBeDefined();
397
+ });
273
398
  });
274
399
 
275
400
  describe('projectGlance', () => {
@@ -3,11 +3,23 @@ import MgmtCluster from '@shell/models/management.cattle.io.cluster';
3
3
  import { IMPORTED_DAY_2_OPS } from '@shell/config/features';
4
4
  import { OPERATION_ANNOTATIONS } from '@shell/config/labels-annotations';
5
5
  import { SETTING } from '@shell/config/settings';
6
- import { MANAGEMENT, OPERATION } from '@shell/config/types';
6
+ import { MANAGEMENT, OPERATION, LOCAL_CLUSTER, NAMESPACE } from '@shell/config/types';
7
7
  import { createOperationCR } from '@shell/utils/operation-cr';
8
+ import { NAME as EXPLORER } from '@shell/config/product/explorer';
9
+ import sideNavService from '@shell/components/nav/TopLevelMenu.helper';
8
10
 
9
11
  jest.mock('@shell/utils/operation-cr', () => ({ createOperationCR: jest.fn() }));
10
12
 
13
+ jest.mock('@shell/components/nav/TopLevelMenu.helper', () => ({
14
+ __esModule: true,
15
+ default: {
16
+ helper: {
17
+ clustersPinned: [],
18
+ clustersOthers: []
19
+ }
20
+ }
21
+ }));
22
+
11
23
  jest.mock('@shell/utils/provider', () => ({
12
24
  isHostedProvider: jest.fn().mockImplementation((context, provider) => {
13
25
  return ['GKE', 'EKS', 'AKS'].includes(provider);
@@ -597,4 +609,48 @@ describe('class ProvCluster', () => {
597
609
  expect((createOperationCR as jest.Mock).mock.calls[0][4]).toBe('imported-cluster');
598
610
  });
599
611
  });
612
+
613
+ describe('namespaceLocation', () => {
614
+ const setLocalClusterAccess = ({ pinned, others }: { pinned: boolean, others: boolean }) => {
615
+ sideNavService.helper.clustersPinned.length = 0;
616
+ sideNavService.helper.clustersOthers.length = 0;
617
+
618
+ if (pinned) {
619
+ sideNavService.helper.clustersPinned.push({ id: LOCAL_CLUSTER } as any);
620
+ }
621
+ if (others) {
622
+ sideNavService.helper.clustersOthers.push({ id: LOCAL_CLUSTER } as any);
623
+ }
624
+ };
625
+
626
+ it('should route to the local cluster explorer when local is in the pinned side-nav clusters', () => {
627
+ setLocalClusterAccess({ pinned: true, others: false });
628
+ const cluster = new ProvCluster({ metadata: { namespace: 'fleet-default' } });
629
+
630
+ expect(cluster.namespaceLocation).toStrictEqual({
631
+ name: 'c-cluster-product-resource-id',
632
+ params: {
633
+ cluster: LOCAL_CLUSTER,
634
+ product: EXPLORER,
635
+ resource: NAMESPACE,
636
+ id: 'fleet-default',
637
+ },
638
+ });
639
+ });
640
+
641
+ it('should route to the local cluster explorer when local is in the unpinned side-nav clusters', () => {
642
+ setLocalClusterAccess({ pinned: false, others: true });
643
+ const cluster = new ProvCluster({ metadata: { namespace: 'fleet-default' } });
644
+
645
+ expect(cluster.namespaceLocation?.params.cluster).toBe(LOCAL_CLUSTER);
646
+ expect(cluster.namespaceLocation?.params.product).toBe(EXPLORER);
647
+ });
648
+
649
+ it('should return null when the user has no access to the local cluster', () => {
650
+ setLocalClusterAccess({ pinned: false, others: false });
651
+ const cluster = new ProvCluster({ metadata: { namespace: 'fleet-default' } });
652
+
653
+ expect(cluster.namespaceLocation).toBeNull();
654
+ });
655
+ });
600
656
  });
@@ -2,12 +2,15 @@ import SYSTEM_NAMESPACES from '@shell/config/system-namespaces';
2
2
  import {
3
3
  PROJECT, SYSTEM_NAMESPACE, ISTIO as ISTIO_LABELS, FLEET, RESOURCE_QUOTA
4
4
  } from '@shell/config/labels-annotations';
5
- import { ISTIO, MANAGEMENT } from '@shell/config/types';
5
+ import { ISTIO, MANAGEMENT, LOCAL_CLUSTER } from '@shell/config/types';
6
6
 
7
7
  import { get, set } from '@shell/utils/object';
8
8
  import { insertAt, isArray } from '@shell/utils/array';
9
9
  import SteveModel from '@shell/plugins/steve/steve-class';
10
10
  import { HARVESTER_NAME as HARVESTER } from '@shell/config/features';
11
+ import { NAME as MANAGER } from '@shell/config/product/manager';
12
+ import { NAME as EXPLORER } from '@shell/config/product/explorer';
13
+ import sideNavService from '@shell/components/nav/TopLevelMenu.helper';
11
14
  import { hasPSALabels, getPSATooltipsDescription, getPSALabels } from '@shell/utils/pod-security-admission';
12
15
  import { PSAIconsDisplay, PSALabelsNamespaceVersion } from '@shell/config/pod-security-admission';
13
16
 
@@ -188,7 +191,14 @@ export default class Namespace extends SteveModel {
188
191
  }
189
192
 
190
193
  get listLocation() {
191
- const listLocation = { name: this.$rootGetters['isRancher'] ? 'c-cluster-product-projectsnamespaces' : 'c-cluster-product-resource' };
194
+ const productId = this.$rootGetters['productId'];
195
+ const isManagerProduct = productId === MANAGER;
196
+ const cluster = isManagerProduct ? LOCAL_CLUSTER : this.$rootGetters['clusterId'];
197
+
198
+ const listLocation = {
199
+ name: this.$rootGetters['isRancher'] ? 'c-cluster-product-projectsnamespaces' : 'c-cluster-product-resource',
200
+ params: { cluster, product: EXPLORER },
201
+ };
192
202
 
193
203
  // Harvester uses these resource directly... but has different routes. listLocation covers routes leading back to route
194
204
  if (this.$rootGetters['currentProduct'].inStore === HARVESTER) {
@@ -202,6 +212,12 @@ export default class Namespace extends SteveModel {
202
212
  get _detailLocation() {
203
213
  const _detailLocation = super._detailLocation;
204
214
 
215
+ // Namespace detail pages belong to the local cluster's explorer, not Cluster Management
216
+ if (this.$rootGetters['productId'] === MANAGER) {
217
+ _detailLocation.params.cluster = LOCAL_CLUSTER;
218
+ _detailLocation.params.product = EXPLORER;
219
+ }
220
+
205
221
  return _detailLocation;
206
222
  }
207
223
 
@@ -285,6 +301,22 @@ export default class Namespace extends SteveModel {
285
301
  glance.splice(namespaceIndex, 1, this.projectGlance);
286
302
  }
287
303
 
304
+ // In Cluster Management, the type link points to the local cluster's explorer.
305
+ // If the user cannot access the local cluster, show type as plain text.
306
+ const productId = this.$rootGetters['productId'];
307
+
308
+ if (productId === MANAGER) {
309
+ const hasLocalCluster = sideNavService.helper.clustersPinned.some((c) => c.id === LOCAL_CLUSTER) ||
310
+ sideNavService.helper.clustersOthers.some((c) => c.id === LOCAL_CLUSTER);
311
+ const typeIndex = glance.findIndex((item) => item.name === 'type');
312
+
313
+ if (!hasLocalCluster && typeIndex > -1) {
314
+ glance[typeIndex] = {
315
+ ...glance[typeIndex], formatter: undefined, formatterOpts: undefined
316
+ };
317
+ }
318
+ }
319
+
288
320
  // projectGlance could be undefined
289
321
  return glance.filter(Boolean);
290
322
  }
@@ -3,6 +3,8 @@ import {
3
3
  CONFIG_MAP, AUTOSCALER_CONFIG_MAP_ID,
4
4
  EVENT, OPERATION
5
5
  } from '@shell/config/types';
6
+ import { NAME as EXPLORER } from '@shell/config/product/explorer';
7
+ import sideNavService from '@shell/components/nav/TopLevelMenu.helper';
6
8
  import SteveModel from '@shell/plugins/steve/steve-class';
7
9
  import { findBy } from '@shell/utils/array';
8
10
  import { get, set } from '@shell/utils/object';
@@ -788,21 +790,26 @@ export default class ProvCluster extends SteveModel {
788
790
  }
789
791
 
790
792
  get namespaceLocation() {
791
- const localCluster = this.$rootGetters['management/byId'](MANAGEMENT.CLUSTER, LOCAL_CLUSTER);
792
-
793
- if (localCluster) {
794
- return {
795
- name: 'c-cluster-product-resource-id',
796
- params: {
797
- cluster: localCluster.id,
798
- product: this.$rootGetters['productId'],
799
- resource: NAMESPACE,
800
- id: this.namespace
801
- }
802
- };
793
+ // Check side nav for local cluster access — `management/byId` only reflects what's
794
+ // currently cached, which on a downstream detail page is just the downstream cluster
795
+ // (so admins would falsely look like they have no local access). The side nav reflects
796
+ // permission, not cache state, so it's accurate for both admins and downstream-only users.
797
+ const hasLocalCluster = sideNavService.helper.clustersPinned.some((c) => c.id === LOCAL_CLUSTER) ||
798
+ sideNavService.helper.clustersOthers.some((c) => c.id === LOCAL_CLUSTER);
799
+
800
+ if (!hasLocalCluster) {
801
+ return null;
803
802
  }
804
803
 
805
- return null;
804
+ return {
805
+ name: 'c-cluster-product-resource-id',
806
+ params: {
807
+ cluster: LOCAL_CLUSTER,
808
+ product: EXPLORER,
809
+ resource: NAMESPACE,
810
+ id: this.namespace
811
+ }
812
+ };
806
813
  }
807
814
 
808
815
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rancher/shell",
3
- "version": "3.0.12-rc.6",
3
+ "version": "3.0.12-rc.7",
4
4
  "description": "Rancher Dashboard Shell",
5
5
  "repository": "https://github.com/rancher/dashboard",
6
6
  "license": "Apache-2.0",