@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
@@ -1,11 +1,15 @@
1
1
  import { useWorkloadDashboard } from '@shell/pages/c/_cluster/explorer/workload-dashboard/composable';
2
2
  import { WORKLOAD_RESOURCE_TYPES } from '@shell/pages/c/_cluster/explorer/workload-dashboard/types';
3
+ import { WORKLOAD_TYPES } from '@shell/config/types';
3
4
  import { defineComponent, h } from 'vue';
4
5
  import { shallowMount, flushPromises } from '@vue/test-utils';
5
6
 
7
+ const WORKLOAD_DASHBOARD_ROUTE = 'c-cluster-explorer-workload-dashboard';
8
+
6
9
  const mockGetters: Record<string, any> = {};
7
10
  const mockDispatch = jest.fn();
8
11
  const mockRouterPush = jest.fn();
12
+ const mockCurrentRoute = { value: { name: WORKLOAD_DASHBOARD_ROUTE } };
9
13
 
10
14
  jest.mock('vuex', () => ({
11
15
  useStore: () => ({
@@ -18,7 +22,7 @@ jest.mock('vuex', () => ({
18
22
  }),
19
23
  }));
20
24
 
21
- jest.mock('vue-router', () => ({ useRouter: () => ({ push: mockRouterPush }) }));
25
+ jest.mock('vue-router', () => ({ useRouter: () => ({ push: mockRouterPush, currentRoute: mockCurrentRoute }) }));
22
26
 
23
27
  jest.mock('@shell/composables/useI18n', () => ({ useI18n: () => ({ t: (key: string, args?: Record<string, any>) => `%${ key }%${ args ? JSON.stringify(args) : '' }` }) }));
24
28
 
@@ -105,6 +109,7 @@ describe('composable: useWorkloadDashboard', () => {
105
109
  beforeEach(() => {
106
110
  jest.clearAllMocks();
107
111
  setupGetters();
112
+ mockCurrentRoute.value.name = WORKLOAD_DASHBOARD_ROUTE;
108
113
  });
109
114
 
110
115
  describe('namespaceSubtitle', () => {
@@ -558,4 +563,134 @@ describe('composable: useWorkloadDashboard', () => {
558
563
  wrapper.unmount();
559
564
  });
560
565
  });
566
+
567
+ describe('response validation', () => {
568
+ // The composable caches malformed clusters in a module-level singleton, so each
569
+ // invalid-data test uses a unique cluster id to stay independent of the others.
570
+ function deploymentRouteFor(cluster: string) {
571
+ return {
572
+ name: 'c-cluster-product-resource',
573
+ params: {
574
+ cluster,
575
+ product: 'explorer',
576
+ resource: WORKLOAD_TYPES.DEPLOYMENT,
577
+ },
578
+ };
579
+ }
580
+
581
+ const malformedSummaryResponse = {
582
+ summary: [{
583
+ property: 'metadata.state.name',
584
+ counts: { running: { namespace: { default: 5 } } },
585
+ }],
586
+ data: [],
587
+ };
588
+
589
+ it('should redirect to the deployment page when a count entry is missing total', async() => {
590
+ const { wrapper } = mountComposable({ clusterId: 'c-missing-total' }, malformedSummaryResponse);
591
+
592
+ await flushPromises();
593
+
594
+ expect(mockRouterPush).toHaveBeenCalledWith(deploymentRouteFor('c-missing-total'));
595
+ wrapper.unmount();
596
+ });
597
+
598
+ it('should redirect when the summary is empty but data is present', async() => {
599
+ const dataWithoutSummaryResponse = { summary: [], data: [{ id: 'pod-1' }] };
600
+
601
+ const { wrapper } = mountComposable({ clusterId: 'c-empty-summary-with-data' }, dataWithoutSummaryResponse);
602
+
603
+ await flushPromises();
604
+
605
+ expect(mockRouterPush).toHaveBeenCalledWith(deploymentRouteFor('c-empty-summary-with-data'));
606
+ wrapper.unmount();
607
+ });
608
+
609
+ it('should redirect when there is no summary but data is present', async() => {
610
+ const dataWithoutSummaryResponse = { data: [{ id: 'pod-1' }] };
611
+
612
+ const { wrapper } = mountComposable({ clusterId: 'c-no-summary-with-data' }, dataWithoutSummaryResponse);
613
+
614
+ await flushPromises();
615
+
616
+ expect(mockRouterPush).toHaveBeenCalledWith(deploymentRouteFor('c-no-summary-with-data'));
617
+ wrapper.unmount();
618
+ });
619
+
620
+ it('should not redirect when every count entry has a total', async() => {
621
+ const { wrapper } = mountComposable();
622
+
623
+ await flushPromises();
624
+
625
+ expect(mockRouterPush).not.toHaveBeenCalled();
626
+ wrapper.unmount();
627
+ });
628
+
629
+ it('should not redirect when the summary is empty and there is no data', async() => {
630
+ const emptyResponse = { summary: [], data: [] };
631
+
632
+ const { wrapper } = mountComposable({}, emptyResponse);
633
+
634
+ await flushPromises();
635
+
636
+ expect(mockRouterPush).not.toHaveBeenCalled();
637
+ wrapper.unmount();
638
+ });
639
+
640
+ it('should not redirect when there is no summary and no data', async() => {
641
+ const noSummaryResponse = { data: [] };
642
+
643
+ const { wrapper } = mountComposable({}, noSummaryResponse);
644
+
645
+ await flushPromises();
646
+
647
+ expect(mockRouterPush).not.toHaveBeenCalled();
648
+ wrapper.unmount();
649
+ });
650
+
651
+ it('should not redirect for entries returned with an error', async() => {
652
+ const errorResponse = { summary: null, error: 'No access' };
653
+
654
+ const { wrapper } = mountComposable({}, errorResponse);
655
+
656
+ await flushPromises();
657
+
658
+ expect(mockRouterPush).not.toHaveBeenCalled();
659
+ wrapper.unmount();
660
+ });
661
+
662
+ it('should not redirect when the user has already navigated away from the dashboard', async() => {
663
+ // The fetch resolves after the user left the workload dashboard.
664
+ mockCurrentRoute.value.name = 'c-cluster-product-resource';
665
+
666
+ const { wrapper } = mountComposable({ clusterId: 'c-navigated-away' }, malformedSummaryResponse);
667
+
668
+ await flushPromises();
669
+
670
+ expect(mockRouterPush).not.toHaveBeenCalled();
671
+ wrapper.unmount();
672
+ });
673
+
674
+ it('should skip fetching and redirect immediately for a cluster already known to be invalid', async() => {
675
+ const cluster = 'c-cached-invalid';
676
+
677
+ // First mount detects the malformed response and caches the cluster.
678
+ const first = mountComposable({ clusterId: cluster }, malformedSummaryResponse);
679
+
680
+ await flushPromises();
681
+ first.wrapper.unmount();
682
+
683
+ jest.clearAllMocks();
684
+
685
+ // Second mount returns a valid response, but the cache should short-circuit the
686
+ // fetch entirely and redirect without ever requesting the summaries again.
687
+ const second = mountComposable({ clusterId: cluster }, summaryResponse);
688
+
689
+ await flushPromises();
690
+
691
+ expect(mockDispatch).not.toHaveBeenCalledWith('cluster/request', expect.anything());
692
+ expect(mockRouterPush).toHaveBeenCalledWith(deploymentRouteFor(cluster));
693
+ second.wrapper.unmount();
694
+ });
695
+ });
561
696
  });
@@ -3,7 +3,7 @@ import {
3
3
  } from 'vue';
4
4
  import { useStore } from 'vuex';
5
5
  import { useRouter, type RouteLocationRaw } from 'vue-router';
6
- import { NAMESPACE } from '@shell/config/types';
6
+ import { NAMESPACE, WORKLOAD_TYPES } from '@shell/config/types';
7
7
  import type { StateColor } from '@shell/utils/style';
8
8
  import { useI18n } from '@shell/composables/useI18n';
9
9
  import { useStateColor } from '@shell/composables/useStateColor';
@@ -26,6 +26,15 @@ import {
26
26
  type WorkloadDashboardByNamespaceCard,
27
27
  } from './types';
28
28
 
29
+ const WORKLOAD_DASHBOARD_ROUTE = 'c-cluster-explorer-workload-dashboard';
30
+
31
+ /**
32
+ * Singleton (module-level) cache of cluster ids whose workload summary responses
33
+ * have been found to be malformed. Once a cluster is flagged we skip re-fetching
34
+ * and redirect straight away. Held in memory only, so it is cleared on reload.
35
+ */
36
+ const invalidDataClusters = new Set<string>();
37
+
29
38
  export function useWorkloadDashboard() {
30
39
  const store = useStore();
31
40
  const router = useRouter();
@@ -344,12 +353,57 @@ export function useWorkloadDashboard() {
344
353
 
345
354
  // ── Fetching & polling ──
346
355
 
356
+ /**
357
+ * Validates the shape of a successfully fetched response.
358
+ * - An empty/absent summary is only valid when there is also no `data`
359
+ * (a populated `data` with no summary means we got the wrong response).
360
+ * - When a summary is present, every state entry under `counts` must
361
+ * include a `total`; a count key without `total` is invalid.
362
+ */
363
+ function isValidResponse(res: { summary?: WorkloadDashboardSummaryEntry['summary']; data?: unknown }): boolean {
364
+ const summary = res?.summary;
365
+
366
+ if (!Array.isArray(summary) || summary.length === 0) {
367
+ // With no summary, the response is only valid when there is also no data.
368
+ return Array.isArray(res?.data) ? res.data.length === 0 : !res?.data;
369
+ }
370
+
371
+ return summary.every((item) => Object.values(item.counts || {})
372
+ .every((detail) => typeof detail?.total === 'number'));
373
+ }
374
+
375
+ function redirectToDeployment(): void {
376
+ // The redirect can fire from an async fetch or poll that resolves after the
377
+ // user has already navigated away — only redirect if still on the dashboard.
378
+ if (router.currentRoute.value.name !== WORKLOAD_DASHBOARD_ROUTE) {
379
+ return;
380
+ }
381
+
382
+ // Logged to help triage "where's my workload summary page?" style questions:
383
+ // the summary response was malformed, so we fall back to the deployments list.
384
+ console.info(`Workload dashboard: unexpected summary response for cluster "${ clusterId.value }", redirecting to the deployments list.`); // eslint-disable-line no-console
385
+
386
+ stopPollTimer();
387
+ router.push(resourceRoute(WORKLOAD_TYPES.DEPLOYMENT));
388
+ }
389
+
347
390
  async function fetchSummaries(): Promise<void> {
391
+ // If this cluster's data was already found to be malformed, skip the requests
392
+ // and redirect straight away (cleared on reload via the in-memory singleton).
393
+ if (invalidDataClusters.has(clusterId.value)) {
394
+ redirectToDeployment();
395
+
396
+ return;
397
+ }
398
+
348
399
  try {
349
400
  const accessibleTypes = WORKLOAD_RESOURCE_TYPES.filter(
350
401
  (type) => store.getters['cluster/canList'](type)
351
402
  );
352
403
 
404
+ // Set when a successfully fetched response has an unexpected shape.
405
+ let hasInvalidResponse = false;
406
+
353
407
  const workloadPromises = accessibleTypes.map(async(type): Promise<WorkloadDashboardSummaryEntry> => {
354
408
  try {
355
409
  let url = `${ store.getters['cluster/urlFor'](type) }`;
@@ -361,6 +415,10 @@ export function useWorkloadDashboard() {
361
415
 
362
416
  const res = await store.dispatch('cluster/request', { url });
363
417
 
418
+ if (!hasInvalidResponse && !isValidResponse(res)) {
419
+ hasInvalidResponse = true;
420
+ }
421
+
364
422
  return {
365
423
  type, summary: res.summary || [], error: null
366
424
  };
@@ -375,6 +433,16 @@ export function useWorkloadDashboard() {
375
433
 
376
434
  const results = await Promise.all(workloadPromises);
377
435
 
436
+ // Only successful responses are validated (errors are caught above). If any
437
+ // had an unexpected shape, remember it so we skip future requests, then
438
+ // stop polling and redirect to the deployment list page.
439
+ if (hasInvalidResponse) {
440
+ invalidDataClusters.add(clusterId.value);
441
+ redirectToDeployment();
442
+
443
+ return;
444
+ }
445
+
378
446
  await resolveStateColors(results);
379
447
  summaries.value = results;
380
448
  fetchError.value = null;
@@ -322,19 +322,15 @@ export default {
322
322
  this.selectedCard = selected;
323
323
 
324
324
  this.$shell.slideIn.open(ResourceDetails, {
325
- showHeader: false,
326
- props: {
325
+ props: {
327
326
  value,
328
327
  statePanel,
329
328
  workspace
330
329
  },
331
- width: '73%',
332
- // We want this to be full viewport height top to bottom
333
- height: '100vh',
334
- top: '0',
335
- 'z-index': 101, // We want this to be above the main side menu
336
- closeOnRouteChange: ['name', 'params', 'query'], // We want to ignore hash changes, tables in extensions can trigger the drawer to close while opening
337
- triggerFocusTrap: false,
330
+ width: 'wide',
331
+ height: 'full',
332
+ closeOnRouteChange: ['name', 'params', 'query'],
333
+ disableFocusTrap: true,
338
334
  });
339
335
  },
340
336
 
package/pkg/vue.config.js CHANGED
@@ -91,9 +91,10 @@ module.exports = function(dir) {
91
91
  // These modules will be externalised and not included with the build of a package library
92
92
  // This helps reduce the package size, but these dependencies must be provided by the hosting application
93
93
  config.externals = {
94
- jquery: '$',
95
- jszip: '__jszip',
96
- 'js-yaml': '__jsyaml'
94
+ jquery: '$',
95
+ jszip: '__jszip',
96
+ 'js-yaml': '__jsyaml',
97
+ 'vue-router': '__vueRouter'
97
98
  };
98
99
 
99
100
  // Prevent warning in log with the md files in the content folder
@@ -27,6 +27,8 @@ import 'codemirror/addon/hint/show-hint.css';
27
27
  import 'codemirror/addon/hint/show-hint.js';
28
28
  import 'codemirror/addon/hint/anyword-hint.js';
29
29
 
30
+ import 'codemirror/addon/comment/comment.js';
31
+
30
32
  import { strPad } from '@shell/utils/string';
31
33
 
32
34
  function isLineComment(cm, lineNo) {
@@ -932,12 +932,9 @@ export default class Resource {
932
932
  componentProps: {
933
933
  resource: this,
934
934
  onClose,
935
- width: '73%',
936
- // We want this to be full viewport height top to bottom
937
- height: '100vh',
938
- top: '0',
939
- 'z-index': 101, // We want this to be above the main side menu
940
- closeOnRouteChange: ['name', 'params', 'query'], // We want to ignore hash changes, tables in extensions can trigger the drawer to close while opening
935
+ width: 'wide',
936
+ height: 'full',
937
+ closeOnRouteChange: ['name', 'params', 'query'],
941
938
  triggerFocusTrap: true,
942
939
  returnFocusSelector,
943
940
  defaultTab