@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.
- package/apis/intf/shell-api/slide-in.ts +46 -17
- package/apis/shell/__tests__/slide-in.test.ts +83 -2
- package/apis/shell/slide-in.ts +20 -0
- package/components/ExplorerProjectsNamespaces.vue +2 -2
- package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +9 -0
- package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +5 -1
- package/components/SlideInPanelManager.vue +103 -25
- package/components/__tests__/SlideInPanelManager.spec.ts +153 -10
- package/components/auth/AuthBanner.vue +1 -1
- package/core/plugins-loader.js +2 -0
- package/models/__tests__/namespace.test.ts +133 -8
- package/models/__tests__/provisioning.cattle.io.cluster.test.ts +57 -1
- package/models/namespace.js +34 -2
- package/models/provisioning.cattle.io.cluster.js +20 -13
- package/package.json +1 -1
- package/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts +136 -1
- package/pages/c/_cluster/explorer/workload-dashboard/composable.ts +69 -1
- package/pages/c/_cluster/fleet/index.vue +5 -9
- package/pkg/vue.config.js +4 -3
- package/plugins/codemirror.js +2 -0
- package/plugins/dashboard-store/resource-class.js +3 -6
- package/store/__tests__/action-menu.test.ts +622 -0
- package/store/__tests__/slideInPanel.test.ts +143 -43
- package/store/__tests__/wm.test.ts +503 -0
- package/store/aws.js +19 -4
- package/store/slideInPanel.ts +15 -3
- package/types/shell/index.d.ts +26 -2
- package/utils/__tests__/fleet-appco.test.ts +23 -0
- package/utils/fleet-appco.ts +6 -1
- 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
|
-
|
|
326
|
-
props: {
|
|
325
|
+
props: {
|
|
327
326
|
value,
|
|
328
327
|
statePanel,
|
|
329
328
|
workspace
|
|
330
329
|
},
|
|
331
|
-
width: '
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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:
|
|
96
|
-
'js-yaml':
|
|
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
|
package/plugins/codemirror.js
CHANGED
|
@@ -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: '
|
|
936
|
-
|
|
937
|
-
|
|
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
|