diff --git a/.eslintrc.js b/.eslintrc.js index 0671773c3c2..4b3b98c4dca 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,7 +59,8 @@ module.exports = { '@typescript-eslint/no-var-requires': 'off', 'vue/one-component-per-file': 'off', 'vue/no-deprecated-slot-attribute': 'off', - 'vue/v-on-event-hyphenation': 'off' + 'vue/v-on-event-hyphenation': 'off', + 'jest/no-hooks': 'off', }, overrides: [ { diff --git a/cypress/e2e/po/components/namespace-filter.po.ts b/cypress/e2e/po/components/namespace-filter.po.ts index cd9f5dfb67b..f56b8570d4a 100644 --- a/cypress/e2e/po/components/namespace-filter.po.ts +++ b/cypress/e2e/po/components/namespace-filter.po.ts @@ -1,4 +1,5 @@ import ComponentPo from '@/cypress/e2e/po/components/component.po'; + export class NamespaceFilterPo extends ComponentPo { constructor() { super('[data-testid="namespaces-filter"]'); @@ -52,6 +53,10 @@ export class NamespaceFilterPo extends ComponentPo { return this.namespaceDropdown().find('[data-testid="namespaces-values"]'); } + allSelected() { + return this.self().find('[data-testid="namespaces-values-none"]').should('exist'); + } + moreOptionsSelected() { return this.namespaceDropdown().find('.ns-more'); } diff --git a/cypress/e2e/po/pages/chart-repositories.po.ts b/cypress/e2e/po/pages/chart-repositories.po.ts index ba5c1fdb010..fac6bf3963e 100644 --- a/cypress/e2e/po/pages/chart-repositories.po.ts +++ b/cypress/e2e/po/pages/chart-repositories.po.ts @@ -28,8 +28,7 @@ export default class ChartRepositoriesPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Apps'); sideNav.navToSideMenuEntryByLabel('Repositories'); } else { diff --git a/cypress/e2e/po/pages/explorer/charts/charts.po.ts b/cypress/e2e/po/pages/explorer/charts/charts.po.ts index d09520aad30..615d10ef1b1 100644 --- a/cypress/e2e/po/pages/explorer/charts/charts.po.ts +++ b/cypress/e2e/po/pages/explorer/charts/charts.po.ts @@ -23,8 +23,7 @@ export class ChartsPage extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Apps'); } diff --git a/cypress/e2e/po/pages/explorer/cluster-dashboard.po.ts b/cypress/e2e/po/pages/explorer/cluster-dashboard.po.ts index 693a942152e..6eb0c38a2e5 100644 --- a/cypress/e2e/po/pages/explorer/cluster-dashboard.po.ts +++ b/cypress/e2e/po/pages/explorer/cluster-dashboard.po.ts @@ -4,7 +4,8 @@ import CustomBadgeDialogPo from '@/cypress/e2e/po/components/custom-badge-dialog import EventsListPo from '@/cypress/e2e/po/lists/events-list.po'; import TabbedPo from '@/cypress/e2e/po/components/tabbed.po'; import CertificatesPo from '@/cypress/e2e/po/components/certificates.po'; -import { HeaderPo } from '~/cypress/e2e/po/components/header.po'; +import { HeaderPo } from '@/cypress/e2e/po/components/header.po'; +import { NamespaceFilterPo } from '@/cypress/e2e/po/components/namespace-filter.po'; export default class ClusterDashboardPagePo extends PagePo { private static createPath(clusterId: string) { @@ -15,6 +16,10 @@ export default class ClusterDashboardPagePo extends PagePo { return super.goTo(ClusterDashboardPagePo.createPath(clusterId)); } + urlPath(clusterId = 'local') { + return ClusterDashboardPagePo.createPath(clusterId); + } + constructor(clusterId: string) { super(ClusterDashboardPagePo.createPath(clusterId)); } @@ -22,8 +27,7 @@ export default class ClusterDashboardPagePo extends PagePo { static navTo(clusterId = 'local') { const burgerMenu = new BurgerMenuPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); } customizeAppearanceButton() { @@ -79,4 +83,45 @@ export default class ClusterDashboardPagePo extends PagePo { controllerManagerStatus() { return cy.get('[data-testid="k8s-service-controller-manager"]'); } + + /** + * Confirm that the ns filter is set correctly before navigating to a page that will use it + * 1. nav to cluster dashboard + * 2. check ns filter values + */ + static goToAndConfirmNsValues(cluster: string, { + nsProject, + all + }: { + nsProject?: { + values: string[] + }, + all?: { + is: boolean, + } + }) { + const instance = new ClusterDashboardPagePo(cluster); + const nsfilter = new NamespaceFilterPo(); + + instance.goTo(); + instance.waitForPage(); + nsfilter.checkVisible(); + + if (nsProject) { + for (let i = 0; i < nsProject.values.length; i++) { + nsfilter.selectedValues().contains(nsProject.values[i]); + } + } else if (all) { + nsfilter.allSelected(); + } else { + throw new Error('Bad Config'); + } + } + + static goToAndWait(cluster: string) { + const instance = new ClusterDashboardPagePo(cluster); + + instance.goTo(); + instance.clusterActionsHeader().checkVisible(); + } } diff --git a/cypress/e2e/po/pages/explorer/config-map.po.ts b/cypress/e2e/po/pages/explorer/config-map.po.ts index 930f55e86cc..631797b0126 100644 --- a/cypress/e2e/po/pages/explorer/config-map.po.ts +++ b/cypress/e2e/po/pages/explorer/config-map.po.ts @@ -16,8 +16,7 @@ export class ConfigMapPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Storage'); sideNav.navToSideMenuEntryByLabel('ConfigMaps'); } diff --git a/cypress/e2e/po/pages/explorer/custom-resource-definitions.po.ts b/cypress/e2e/po/pages/explorer/custom-resource-definitions.po.ts index ab689fc0afe..4de4b4a5f82 100644 --- a/cypress/e2e/po/pages/explorer/custom-resource-definitions.po.ts +++ b/cypress/e2e/po/pages/explorer/custom-resource-definitions.po.ts @@ -22,8 +22,7 @@ export class CustomResourceDefinitionsPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('More Resources'); sideNav.navToSideMenuGroupByLabel('API'); sideNav.navToSideMenuEntryByLabel('CustomResourceDefinitions'); diff --git a/cypress/e2e/po/pages/explorer/horizontal-pod-autoscalers.po.ts b/cypress/e2e/po/pages/explorer/horizontal-pod-autoscalers.po.ts index 6d762443b67..1291e2e8250 100644 --- a/cypress/e2e/po/pages/explorer/horizontal-pod-autoscalers.po.ts +++ b/cypress/e2e/po/pages/explorer/horizontal-pod-autoscalers.po.ts @@ -20,8 +20,7 @@ export class HorizontalPodAutoscalersPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Service Discovery'); sideNav.navToSideMenuEntryByLabel('HorizontalPodAutoscalers'); } diff --git a/cypress/e2e/po/pages/explorer/ingress.po.ts b/cypress/e2e/po/pages/explorer/ingress.po.ts index ebccd71e6aa..807f5cf990f 100644 --- a/cypress/e2e/po/pages/explorer/ingress.po.ts +++ b/cypress/e2e/po/pages/explorer/ingress.po.ts @@ -21,8 +21,7 @@ export class IngressPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Service Discovery'); sideNav.navToSideMenuEntryByLabel('Ingresses'); } diff --git a/cypress/e2e/po/pages/explorer/network-policy.po.ts b/cypress/e2e/po/pages/explorer/network-policy.po.ts index 01d89a9be4f..35db7da0842 100644 --- a/cypress/e2e/po/pages/explorer/network-policy.po.ts +++ b/cypress/e2e/po/pages/explorer/network-policy.po.ts @@ -16,8 +16,7 @@ export class NetworkPolicyPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Policy'); sideNav.navToSideMenuEntryByLabel('Network Policies'); } diff --git a/cypress/e2e/po/pages/explorer/persistent-volume-claims.po.ts b/cypress/e2e/po/pages/explorer/persistent-volume-claims.po.ts index 7e26e458325..3bea96f7e15 100644 --- a/cypress/e2e/po/pages/explorer/persistent-volume-claims.po.ts +++ b/cypress/e2e/po/pages/explorer/persistent-volume-claims.po.ts @@ -20,8 +20,7 @@ export class PersistentVolumeClaimsPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Storage'); sideNav.navToSideMenuEntryByLabel('PersistentVolumeClaims'); } diff --git a/cypress/e2e/po/pages/explorer/persistent-volumes.po.ts b/cypress/e2e/po/pages/explorer/persistent-volumes.po.ts index ec10860bba3..1c43e264e5f 100644 --- a/cypress/e2e/po/pages/explorer/persistent-volumes.po.ts +++ b/cypress/e2e/po/pages/explorer/persistent-volumes.po.ts @@ -21,8 +21,7 @@ export class PersistentVolumesPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Storage'); sideNav.navToSideMenuEntryByLabel('PersistentVolumes'); } diff --git a/cypress/e2e/po/pages/explorer/service-accounts.po.ts b/cypress/e2e/po/pages/explorer/service-accounts.po.ts index 2c174bda427..51dbe2f05d3 100644 --- a/cypress/e2e/po/pages/explorer/service-accounts.po.ts +++ b/cypress/e2e/po/pages/explorer/service-accounts.po.ts @@ -1,5 +1,7 @@ import PagePo from '@/cypress/e2e/po/pages/page.po'; import BaseResourceList from '@/cypress/e2e/po/lists/base-resource-list.po'; +import BurgerMenuPo from '@/cypress/e2e/po/side-bars/burger-side-menu.po'; +import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po'; export class ServiceAccountsPagePo extends PagePo { private static createPath(clusterId: string) { @@ -14,6 +16,16 @@ export class ServiceAccountsPagePo extends PagePo { return super.goTo(ServiceAccountsPagePo.createPath(clusterId)); } + static navTo(clusterId = 'local') { + const burgerMenu = new BurgerMenuPo(); + const sideNav = new ProductNavPo(); + + burgerMenu.goToCluster(clusterId); + sideNav.navToSideMenuGroupByLabel('More Resources'); + sideNav.navToSideMenuGroupByLabel('Core'); + sideNav.navToSideMenuEntryByLabel('ServiceAccount'); + } + constructor(clusterId = 'local') { super(ServiceAccountsPagePo.createPath(clusterId)); } diff --git a/cypress/e2e/po/pages/explorer/services.po.ts b/cypress/e2e/po/pages/explorer/services.po.ts index 0aca6ad8a21..e9fbc44ae32 100644 --- a/cypress/e2e/po/pages/explorer/services.po.ts +++ b/cypress/e2e/po/pages/explorer/services.po.ts @@ -21,10 +21,9 @@ export class ServicesPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Service Discovery'); - sideNav.navToSideMenuEntryByLabel('Ingresses'); + sideNav.navToSideMenuEntryByLabel('Service'); } constructor(clusterId = 'local') { diff --git a/cypress/e2e/po/pages/explorer/storage-classes.po.ts b/cypress/e2e/po/pages/explorer/storage-classes.po.ts index 4859841ac64..6ca1756341c 100644 --- a/cypress/e2e/po/pages/explorer/storage-classes.po.ts +++ b/cypress/e2e/po/pages/explorer/storage-classes.po.ts @@ -21,8 +21,7 @@ export class StorageClassesPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Storage'); sideNav.navToSideMenuEntryByLabel('StorageClasses'); } diff --git a/cypress/e2e/po/pages/explorer/workloads-pods.po.ts b/cypress/e2e/po/pages/explorer/workloads-pods.po.ts index d1185681bad..bf5f250eeb0 100644 --- a/cypress/e2e/po/pages/explorer/workloads-pods.po.ts +++ b/cypress/e2e/po/pages/explorer/workloads-pods.po.ts @@ -22,8 +22,7 @@ export class WorkloadsPodsListPagePo extends PagePo { const burgerMenu = new BurgerMenuPo(); const sideNav = new ProductNavPo(); - BurgerMenuPo.toggle(); - burgerMenu.clusterNotPinnedList().contains(clusterId).click(); + burgerMenu.goToCluster(clusterId); sideNav.navToSideMenuGroupByLabel('Workloads'); sideNav.navToSideMenuEntryByLabel('Pods'); } diff --git a/cypress/e2e/po/side-bars/burger-side-menu.po.ts b/cypress/e2e/po/side-bars/burger-side-menu.po.ts index c9937b99391..fa0ae030d36 100644 --- a/cypress/e2e/po/side-bars/burger-side-menu.po.ts +++ b/cypress/e2e/po/side-bars/burger-side-menu.po.ts @@ -134,7 +134,13 @@ export default class BurgerMenuPo extends ComponentPo { return this.self().find('.body .cluster.selector.option'); } - goToCluster(clusterId = 'local') { + goToCluster(clusterId = 'local', toggleOpen = true) { + if (toggleOpen) { + BurgerMenuPo.toggle(); + } + + this.self().find('.cluster-name').contains(clusterId).should('exist'); + return this.self().find('.cluster-name').contains(clusterId).click(); } diff --git a/cypress/e2e/tests/navigation/side-nav/main-side-menu.spec.ts b/cypress/e2e/tests/navigation/side-nav/main-side-menu.spec.ts index 9c27cd6fbfb..560a43f17f8 100644 --- a/cypress/e2e/tests/navigation/side-nav/main-side-menu.spec.ts +++ b/cypress/e2e/tests/navigation/side-nav/main-side-menu.spec.ts @@ -29,6 +29,9 @@ describe('Side Menu: main', () => { pagePoFake.navToClusterMenuEntry(fakeProvClusterId); sideNav.navToSideMenuEntryByLabel('Projects/Namespaces'); + BurgerMenuPo.burgerMenuGetNavClusterbyLabel('local').should('exist'); + BurgerMenuPo.burgerMenuGetNavClusterbyLabel(fakeProvClusterId).should('exist'); + // press key combo cy.get('body').focus().type('{alt}', { release: false }); diff --git a/cypress/e2e/tests/navigation/side-nav/product-side-nav.spec.ts b/cypress/e2e/tests/navigation/side-nav/product-side-nav.spec.ts index 937ea0c0f42..9afbe19ad05 100644 --- a/cypress/e2e/tests/navigation/side-nav/product-side-nav.spec.ts +++ b/cypress/e2e/tests/navigation/side-nav/product-side-nav.spec.ts @@ -21,10 +21,9 @@ describe('Side navigation: Cluster ', { tags: ['@navigation', '@adminUser'] }, ( cy.login(); HomePagePo.goTo(); - BurgerMenuPo.toggle(); const burgerMenuPo = new BurgerMenuPo(); - burgerMenuPo.goToCluster('local').click(); + burgerMenuPo.goToCluster('local'); }); it('Can access to first navigation link on click', () => { diff --git a/cypress/e2e/tests/pages/explorer/dashboard/cluster-dashboard.spec.ts b/cypress/e2e/tests/pages/explorer/dashboard/cluster-dashboard.spec.ts index c1f9ef6a7e1..739bb22bffa 100644 --- a/cypress/e2e/tests/pages/explorer/dashboard/cluster-dashboard.spec.ts +++ b/cypress/e2e/tests/pages/explorer/dashboard/cluster-dashboard.spec.ts @@ -9,7 +9,6 @@ import { NodesPagePo } from '@/cypress/e2e/po/pages/explorer/nodes.po'; import { EventsPagePo } from '@/cypress/e2e/po/pages/explorer/events.po'; import * as path from 'path'; import { eventsNoDataset } from '@/cypress/e2e/blueprints/explorer/cluster/events'; -import HomePagePo from '@/cypress/e2e/po/pages/home.po'; const configMapYaml = `apiVersion: v1 kind: ConfigMap @@ -258,12 +257,19 @@ describe('Cluster Dashboard', { testIsolation: 'off', tags: ['@explorer', '@admi }); it('can view events table empty if no events', { tags: ['@vai', '@adminUser'] }, () => { - const events = new EventsPagePo('local'); - - HomePagePo.goTo(); + cy.visit(clusterDashboard.urlPath(), { + onBeforeLoad(win) { + cy.stub(win.console, 'error').as('consoleError'); + cy.stub(win.console, 'warn').as('consoleWarn'); + }, + }); eventsNoDataset(); - ClusterDashboardPagePo.navTo(); + clusterDashboard.goTo(); + + cy.get('@consoleError').should('not.be.called'); // See error lot + cy.get('@consoleWarn').should('not.be.called'); // See warning log (there will be some....) + cy.wait('@eventsNoData'); clusterDashboard.waitForPage(undefined, 'cluster-events'); @@ -279,6 +285,8 @@ describe('Cluster Dashboard', { testIsolation: 'off', tags: ['@explorer', '@admi clusterDashboard.fullEventsLink().click(); cy.wait('@eventsNoData'); + const events = new EventsPagePo('local'); + events.waitForPage(); events.eventslist().resourceTable().sortableTable().checkRowCount(true, 1); diff --git a/cypress/e2e/tests/pages/explorer/dashboard/events.spec.ts b/cypress/e2e/tests/pages/explorer/dashboard/events.spec.ts index cb3698c3568..913405b7712 100644 --- a/cypress/e2e/tests/pages/explorer/dashboard/events.spec.ts +++ b/cypress/e2e/tests/pages/explorer/dashboard/events.spec.ts @@ -4,8 +4,9 @@ import { generateEventsDataSmall } from '@/cypress/e2e/blueprints/explorer/clust import LoadingPo from '@/cypress/e2e/po/components/loading.po'; import SortableTablePo from '@/cypress/e2e/po/components/sortable-table.po'; -const clusterDashboard = new ClusterDashboardPagePo('local'); -const events = new EventsPagePo('local'); +const cluster = 'local'; +const clusterDashboard = new ClusterDashboardPagePo(cluster); +const events = new EventsPagePo(cluster); describe('Events', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { before(() => { @@ -19,7 +20,7 @@ describe('Events', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, let nsName2: string; before('set up', () => { - cy.updateNamespaceFilter('local', 'none', '{\"local\":[]}'); + cy.updateNamespaceFilter(cluster, 'none', '{\"local\":[]}'); cy.createE2EResourceName('ns1').then((ns1) => { nsName1 = ns1; @@ -54,7 +55,8 @@ describe('Events', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, }); it('pagination is visible and user is able to navigate through events data', () => { - ClusterDashboardPagePo.goTo('local'); + ClusterDashboardPagePo.goToAndConfirmNsValues(cluster, { all: { is: true } }); + clusterDashboard.waitForPage(undefined, 'cluster-events'); EventsPagePo.navTo(); events.waitForPage(); @@ -199,7 +201,7 @@ describe('Events', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, }); after('clean up', () => { - cy.updateNamespaceFilter('local', 'none', '{"local":["all://user"]}'); + cy.updateNamespaceFilter(cluster, 'none', '{"local":["all://user"]}'); // delete namespace (this will also delete all pods in it) cy.deleteRancherResource('v1', 'namespaces', nsName1); diff --git a/cypress/e2e/tests/pages/explorer/more-resources/api/custom-resource-definitions.spec.ts b/cypress/e2e/tests/pages/explorer/more-resources/api/custom-resource-definitions.spec.ts index c3da910c863..0c9c16e9189 100644 --- a/cypress/e2e/tests/pages/explorer/more-resources/api/custom-resource-definitions.spec.ts +++ b/cypress/e2e/tests/pages/explorer/more-resources/api/custom-resource-definitions.spec.ts @@ -2,8 +2,10 @@ import { CustomResourceDefinitionsPagePo } from '@/cypress/e2e/po/pages/explorer import { generateCrdsDataSmall } from '@/cypress/e2e/blueprints/explorer/more-resources/api/custom-resource-definition-get'; import * as jsyaml from 'js-yaml'; import HomePagePo from '@/cypress/e2e/po/pages/home.po'; +import ClusterDashboardPagePo from '@/cypress/e2e/po/pages/explorer/cluster-dashboard.po'; -const crdsPage = new CustomResourceDefinitionsPagePo('local'); +const cluster = 'local'; +const crdsPage = new CustomResourceDefinitionsPagePo(cluster); const crdName = `e2etests.${ +new Date() }.example.com`; const crdGroup = `${ +new Date() }.example.com`; @@ -14,11 +16,14 @@ describe('CustomResourceDefinitions', { testIsolation: 'off', tags: ['@explorer' describe('List', { tags: ['@vai', '@adminUser'] }, () => { before(() => { - cy.tableRowsPerPageAndNamespaceFilter(10, 'local', 'none', '{\"local\":[]}'); + ClusterDashboardPagePo.goToAndWait(cluster); // Ensure we're at a solid state before messing with preferences (given login/load might change them) + cy.tableRowsPerPageAndNamespaceFilter(10, cluster, 'none', '{\"local\":[]}'); }); it('can create a crd and see it in list view', () => { - crdsPage.goTo(); + ClusterDashboardPagePo.goToAndConfirmNsValues(cluster, { all: { is: true } } ); + + CustomResourceDefinitionsPagePo.navTo(); crdsPage.waitForPage(); crdsPage.create(); diff --git a/cypress/e2e/tests/pages/explorer/more-resources/core/service-accounts.spec.ts b/cypress/e2e/tests/pages/explorer/more-resources/core/service-accounts.spec.ts index 95bc134c8a3..e0eb6689eac 100644 --- a/cypress/e2e/tests/pages/explorer/more-resources/core/service-accounts.spec.ts +++ b/cypress/e2e/tests/pages/explorer/more-resources/core/service-accounts.spec.ts @@ -1,6 +1,8 @@ import { ServiceAccountsPagePo } from '@/cypress/e2e/po/pages/explorer/service-accounts.po'; import { generateServiceAccDataSmall, serviceAccNoData } from '@/cypress/e2e/blueprints/explorer/core/service-accounts-get'; +import ClusterDashboardPagePo from '@/cypress/e2e/po/pages/explorer/cluster-dashboard.po'; +const cluster = 'local'; const serviceAccountsPagePo = new ServiceAccountsPagePo(); describe('Service Accounts', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { @@ -10,12 +12,14 @@ describe('Service Accounts', { testIsolation: 'off', tags: ['@explorer', '@admin describe('List', { tags: ['@vai', '@adminUser'] }, () => { before('set up', () => { - cy.updateNamespaceFilter('local', 'none', '{\"local\":[]}'); + cy.updateNamespaceFilter(cluster, 'none', '{\"local\":[]}'); }); it('validate services table in empty state', () => { + ClusterDashboardPagePo.goToAndConfirmNsValues(cluster, { all: { is: true } } ); + serviceAccNoData(); - serviceAccountsPagePo.goTo(); + ServiceAccountsPagePo.navTo(); serviceAccountsPagePo.waitForPage(); cy.wait('@serviceAccNoData'); @@ -80,7 +84,7 @@ describe('Service Accounts', { testIsolation: 'off', tags: ['@explorer', '@admin }); after('clean up', () => { - cy.updateNamespaceFilter('local', 'none', '{"local":["all://user"]}'); + cy.updateNamespaceFilter(cluster, 'none', '{"local":["all://user"]}'); }); }); }); diff --git a/cypress/e2e/tests/pages/explorer/service-discovery/ingress.spec.ts b/cypress/e2e/tests/pages/explorer/service-discovery/ingress.spec.ts index b14cb274387..8ad0ffe38ae 100644 --- a/cypress/e2e/tests/pages/explorer/service-discovery/ingress.spec.ts +++ b/cypress/e2e/tests/pages/explorer/service-discovery/ingress.spec.ts @@ -1,6 +1,8 @@ import { IngressPagePo } from '@/cypress/e2e/po/pages/explorer/ingress.po'; import { generateIngressesDataSmall, ingressesNoData } from '@/cypress/e2e/blueprints/explorer/workloads/service-discovery/ingresses-get'; +import ClusterDashboardPagePo from '@/cypress/e2e/po/pages/explorer/cluster-dashboard.po'; +const cluster = 'local'; const ingressPagePo = new IngressPagePo(); describe('Ingresses', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { @@ -32,12 +34,14 @@ describe('Ingresses', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] describe('List', { tags: ['@vai', '@adminUser'] }, () => { before('set up', () => { - cy.updateNamespaceFilter('local', 'none', '{\"local\":[]}'); + cy.updateNamespaceFilter(cluster, 'none', '{\"local\":[]}'); }); it('validate services table in empty state', () => { + ClusterDashboardPagePo.goToAndConfirmNsValues(cluster, { all: { is: true } }); + ingressesNoData(); - ingressPagePo.goTo(); + IngressPagePo.navTo(); ingressPagePo.waitForPage(); cy.wait('@ingressesNoData'); @@ -101,7 +105,7 @@ describe('Ingresses', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }); after('clean up', () => { - cy.updateNamespaceFilter('local', 'none', '{"local":["all://user"]}'); + cy.updateNamespaceFilter(cluster, 'none', '{"local":["all://user"]}'); }); }); }); diff --git a/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts b/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts index 54ed377f6b9..43aa56c97e6 100644 --- a/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts +++ b/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts @@ -1,6 +1,8 @@ import { ServicesPagePo } from '@/cypress/e2e/po/pages/explorer/services.po'; import { generateServicesDataSmall, servicesNoData } from '@/cypress/e2e/blueprints/explorer/workloads/service-discovery/services-get'; +import ClusterDashboardPagePo from '@/cypress/e2e/po/pages/explorer/cluster-dashboard.po'; +const cluster = 'local'; const servicesPagePo = new ServicesPagePo(); describe('Services', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { @@ -10,12 +12,15 @@ describe('Services', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] } describe('List', { tags: ['@vai', '@adminUser'] }, () => { before('set up', () => { - cy.updateNamespaceFilter('local', 'none', '{\"local\":[]}'); + ClusterDashboardPagePo.goToAndWait(cluster); // Ensure we're at a solid state before messing with preferences (given login/load might change them) + cy.updateNamespaceFilter(cluster, 'none', '{\"local\":[]}'); }); it('validate services table in empty state', () => { + ClusterDashboardPagePo.goToAndConfirmNsValues(cluster, { all: { is: true } } ); + servicesNoData(); - servicesPagePo.goTo(); + ServicesPagePo.navTo(); servicesPagePo.waitForPage(); cy.wait('@servicesNoData'); @@ -86,7 +91,7 @@ describe('Services', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] } }); after('clean up', () => { - cy.updateNamespaceFilter('local', 'none', '{"local":["all://user"]}'); + cy.updateNamespaceFilter(cluster, 'none', '{"local":["all://user"]}'); }); }); }); diff --git a/cypress/e2e/tests/pages/explorer2/storage/persistent-volume-claims.spec.ts b/cypress/e2e/tests/pages/explorer2/storage/persistent-volume-claims.spec.ts index ebd5bde6af6..58aa0985600 100644 --- a/cypress/e2e/tests/pages/explorer2/storage/persistent-volume-claims.spec.ts +++ b/cypress/e2e/tests/pages/explorer2/storage/persistent-volume-claims.spec.ts @@ -1,6 +1,8 @@ import { generatePersistentVolumeClaimsDataSmall, persistentVolumeClaimsNoData } from '@/cypress/e2e/blueprints/explorer/storage/persistent-volume-claims-get'; import { PersistentVolumeClaimsPagePo } from '@/cypress/e2e/po/pages/explorer/persistent-volume-claims.po'; +import ClusterDashboardPagePo from '@/cypress/e2e/po/pages/explorer/cluster-dashboard.po'; +const cluster = 'local'; const persistentVolumeClaimsPage = new PersistentVolumeClaimsPagePo(); describe('PersistentVolumeClaims', { testIsolation: 'off', tags: ['@explorer2', '@adminUser'] }, () => { @@ -14,10 +16,12 @@ describe('PersistentVolumeClaims', { testIsolation: 'off', tags: ['@explorer2', }); it('validate persistent volume claims table in empty state', () => { + ClusterDashboardPagePo.goToAndConfirmNsValues(cluster, { all: { is: true } } ); + const tag = 'persistentvolumeclaimsNoData'; persistentVolumeClaimsNoData(tag); - persistentVolumeClaimsPage.goTo(); + PersistentVolumeClaimsPagePo.navTo(); persistentVolumeClaimsPage.waitForPage(); cy.wait(`@${ tag }`); diff --git a/cypress/e2e/tests/pages/explorer2/workloads/pods.spec.ts b/cypress/e2e/tests/pages/explorer2/workloads/pods.spec.ts index 9570cb67ed8..645b760a040 100644 --- a/cypress/e2e/tests/pages/explorer2/workloads/pods.spec.ts +++ b/cypress/e2e/tests/pages/explorer2/workloads/pods.spec.ts @@ -6,9 +6,12 @@ import PodPo from '@/cypress/e2e/po/components/workloads/pod.po'; import HomePagePo from '@/cypress/e2e/po/pages/home.po'; import { generatePodsDataSmall } from '@/cypress/e2e/blueprints/explorer/workloads/pods/pods-get'; import SortableTablePo from '@/cypress/e2e/po/components/sortable-table.po'; +import ClusterDashboardPagePo from '@/cypress/e2e/po/pages/explorer/cluster-dashboard.po'; + +const cluster = 'local'; describe('Pods', { testIsolation: 'off', tags: ['@explorer2', '@adminUser'] }, () => { - const workloadsPodPage = new WorkloadsPodsListPagePo('local'); + const workloadsPodPage = new WorkloadsPodsListPagePo(cluster); before(() => { cy.login(); @@ -55,13 +58,15 @@ describe('Pods', { testIsolation: 'off', tags: ['@explorer2', '@adminUser'] }, ( uniquePod = resp.body.metadata.name; }); - cy.tableRowsPerPageAndNamespaceFilter(10, 'local', 'none', `{\"local\":[\"ns://${ nsName1 }\",\"ns://${ nsName2 }\"]}`); + cy.tableRowsPerPageAndNamespaceFilter(10, cluster, 'none', `{\"local\":[\"ns://${ nsName1 }\",\"ns://${ nsName2 }\"]}`); }); }); }); it('pagination is visible and user is able to navigate through pods data', () => { - WorkloadsPodsListPagePo.goTo('local'); + ClusterDashboardPagePo.goToAndConfirmNsValues(cluster, { nsProject: { values: [nsName1, nsName2] } }); + + WorkloadsPodsListPagePo.navTo(); workloadsPodPage.waitForPage(); // check pods count @@ -178,7 +183,7 @@ describe('Pods', { testIsolation: 'off', tags: ['@explorer2', '@adminUser'] }, ( }); it('pagination is hidden', () => { - cy.tableRowsPerPageAndNamespaceFilter(10, 'local', 'none', '{"local":[]}'); + cy.tableRowsPerPageAndNamespaceFilter(10, cluster, 'none', '{"local":[]}'); // generate small set of pods data generatePodsDataSmall(); @@ -195,7 +200,7 @@ describe('Pods', { testIsolation: 'off', tags: ['@explorer2', '@adminUser'] }, ( after('clean up', () => { // Ensure the default rows per page value is set after running the tests - cy.tableRowsPerPageAndNamespaceFilter(100, 'local', 'none', '{"local":["all://user"]}'); + cy.tableRowsPerPageAndNamespaceFilter(100, cluster, 'none', '{"local":["all://user"]}'); // delete namespace (this will also delete all pods in it) cy.deleteRancherResource('v1', 'namespaces', nsName1); @@ -351,14 +356,14 @@ describe('Pods', { testIsolation: 'off', tags: ['@explorer2', '@adminUser'] }, ( // }); // it('dialog should open/close as expected', () => { - // const podCreatePage = new WorkloadsPodsCreatePagePo('local'); + // const podCreatePage = new WorkloadsPodsCreatePagePo(cluster); // podCreatePage.goTo(); // podCreatePage.createWithUI(podName, 'nginx', 'default'); // // Should be on the list view - // const podsListPage = new WorkloadsPodsListPagePo('local'); + // const podsListPage = new WorkloadsPodsListPagePo(cluster); // // Filter the list to just show the newly created pod // podsListPage.list().resourceTable().sortableTable().filter(podName); diff --git a/cypress/e2e/tests/pages/extensions/extensions.spec.ts b/cypress/e2e/tests/pages/extensions/extensions.spec.ts index 21e089876fa..6b8c4e01a24 100644 --- a/cypress/e2e/tests/pages/extensions/extensions.spec.ts +++ b/cypress/e2e/tests/pages/extensions/extensions.spec.ts @@ -7,6 +7,7 @@ import UiPluginsPagePo from '@/cypress/e2e/po/pages/explorer/uiplugins.po'; import { NamespaceFilterPo } from '@/cypress/e2e/po/components/namespace-filter.po'; const namespaceFilter = new NamespaceFilterPo(); +const cluster = 'local'; const DISABLED_CACHE_EXTENSION_NAME = 'large-extension'; // const DISABLED_CACHE_EXTENSION_MENU_LABEL = 'Large-extension'; @@ -150,7 +151,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { BurgerMenuPo.checkIfMenuItemLinkIsHighlighted('Extensions'); // catching regression https://github.com/rancher/dashboard/issues/10576 - BurgerMenuPo.checkIfClusterMenuLinkIsHighlighted('local', false); + BurgerMenuPo.checkIfClusterMenuLinkIsHighlighted(cluster, false); // go to "add rancher repositories" extensionsPo.extensionMenuToggle(); @@ -161,7 +162,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { extensionsPo.addReposModal().should('not.exist'); // go to repos list page - const appRepoList = new RepositoriesPagePo('local', 'apps'); + const appRepoList = new RepositoriesPagePo(cluster, 'apps'); appRepoList.goTo(); appRepoList.waitForPage(); @@ -185,7 +186,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { } }); - const appRepoList = new RepositoriesPagePo('local', 'apps'); + const appRepoList = new RepositoriesPagePo(cluster, 'apps'); // Ensure that the banner should be shown (by confirming that a required repo isn't there) appRepoList.goTo(); @@ -344,7 +345,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { }); // ui-plugin-operator updated cache disabled threshold to 30mb as per https://github.com/rancher/rancher/pull/47565 - it('An extension larger than 30mb, which will trigger chacheState disabled, should install and work fine', () => { + it('An extension larger than 30mb, which will trigger cacheState disabled, should install and work fine', () => { const extensionsPo = new ExtensionsPagePo(); extensionsPo.goTo(); @@ -378,7 +379,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { // cy.get('h1').should('have.text', DISABLED_CACHE_EXTENSION_TITLE); // check if cache state is disabled - const uiPluginsPo = new UiPluginsPagePo('local'); + const uiPluginsPo = new UiPluginsPagePo(cluster); uiPluginsPo.goTo(); uiPluginsPo.waitForPage(); @@ -460,7 +461,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { extensionsPo.extensionDetailsTitle().should('contain', EXTENSION_NAME); }); - it('Should uninstall unathenticated extensions', () => { + it('Should uninstall unauthenticated extensions', () => { // Because we logged out in the previous test this one will also have to use an uncached login cy.login(undefined, undefined, false); const extensionsPo = new ExtensionsPagePo(); diff --git a/shell/components/AsyncButton.vue b/shell/components/AsyncButton.vue index 3abbf570017..f847dc07df8 100644 --- a/shell/components/AsyncButton.vue +++ b/shell/components/AsyncButton.vue @@ -288,6 +288,7 @@ export default defineComponent({ v-if="displayIcon" v-clean-tooltip="tooltip" :class="{icon: true, 'icon-lg': true, [displayIcon]: true}" + class="ml-5 mr-0" /> +import { defineComponent } from 'vue'; +import ResourceFetch from '@shell/mixins/resource-fetch'; +import ResourceTable from '@shell/components/ResourceTable.vue'; +import { StorePaginationResult } from '@shell/types/store/pagination.types'; + +export type FetchSecondaryResourcesOpts = { canPaginate: boolean } +export type FetchSecondaryResources = (opts: FetchSecondaryResourcesOpts) => Promise + +export type FetchPageSecondaryResourcesOpts = { canPaginate: boolean, force: boolean, page: any[], pagResult: StorePaginationResult } +export type FetchPageSecondaryResources = (opts: FetchPageSecondaryResourcesOpts) => Promise + +/** + * This is meant to enable ResourceList like capabilities outside of List pages / components + * + * Specifically + * - Resource Fetch features, including server-side pagination + * - Some plumbing + * + * This avoids polluting the owning component with mixins + * + */ +export default defineComponent({ + name: 'PaginatedResourceTable', + + components: { ResourceTable }, + + mixins: [ResourceFetch], + + props: { + schema: { + type: Object, + required: true, + }, + + headers: { + type: Array, + default: null, + }, + + paginationHeaders: { + type: Array, + default: null, + }, + + groupable: { + type: Boolean, + default: null, // Null: auto based on namespaced and type custom groupings + }, + + namespaced: { + type: Boolean, + default: null, // Automatic from schema + }, + + /** + * Information may be required from resources other than the primary one shown per row + * + * This will fetch them ALL and will be run in a non-server-side pagination world + */ + fetchSecondaryResources: { + type: Function, + default: null, + }, + + /** + * Information may be required from resources other than the primary one shown per row + * + * This will fetch only those relevant to the current page using server-side pagination based filters + * + * called from shell/mixins/resource-fetch-api-pagination.js + */ + fetchPageSecondaryResources: { + type: Function, + default: null, + } + }, + + data() { + return { resource: this.schema.id }; + }, + + async fetch() { + const promises = [ + this.$fetchType(this.resource, [], this.inStore), + ]; + + if (this.fetchSecondaryResources) { + promises.push(this.fetchSecondaryResources({ canPaginate: this.canPaginate })); + } + + await Promise.all(promises); + }, + + computed: { + safeHeaders() { + const customHeaders = this.canPaginate ? this.paginationHeaders : this.headers; + + return customHeaders || this.$store.getters['type-map/headersFor'](this.schema, this.canPaginate); + } + } +}); + + + + diff --git a/shell/components/ResourceList/index.vue b/shell/components/ResourceList/index.vue index 28e399da679..7d66ac23764 100644 --- a/shell/components/ResourceList/index.vue +++ b/shell/components/ResourceList/index.vue @@ -96,7 +96,6 @@ export default { const showMasthead = getters[`type-map/optionsFor`](resource).showListMasthead; return { - inStore, schema, hasListComponent, showMasthead: showMasthead === undefined ? true : showMasthead, diff --git a/shell/components/ResourceTable.vue b/shell/components/ResourceTable.vue index dbdb0b4fa3a..86b4add4526 100644 --- a/shell/components/ResourceTable.vue +++ b/shell/components/ResourceTable.vue @@ -429,12 +429,17 @@ export default { }, computedGroupBy() { + // If we're not showing grouping options we shouldn't have a group by property + if (!this.showGrouping) { + return null; + } + if ( this.groupBy ) { // This probably comes from the type-map config for the resource (see ResourceList) return this.groupBy; } - if ( this.group === 'namespace' && this.showGrouping ) { + if ( this.group === 'namespace' ) { // This switches to group rows by a key which is the label for the group (??) return 'groupByLabel'; } diff --git a/shell/components/SortableTable/index.vue b/shell/components/SortableTable/index.vue index 5bf3f6dfccd..118e2ef2398 100644 --- a/shell/components/SortableTable/index.vue +++ b/shell/components/SortableTable/index.vue @@ -361,7 +361,13 @@ export default { externalPaginationResult: { type: Object, default: null + }, + + manualRefreshButtonSize: { + type: String, + default: '' } + }, data() { @@ -1140,8 +1146,8 @@ export default { @@ -1568,10 +1574,6 @@ export default { opacity: 0.5; pointer-events: none; } - - .manual-refresh { - height: 40px; - } .advanced-filter-group { position: relative; margin-left: 10px; @@ -1672,7 +1674,7 @@ export default { margin-right: 10px; font-size: 11px; } - .cross { + .cross { font-size: 12px; font-weight: bold; cursor: pointer; diff --git a/shell/components/form/ResourceLabeledSelect.vue b/shell/components/form/ResourceLabeledSelect.vue index a4cbc64fabe..dd9e0e16e42 100644 --- a/shell/components/form/ResourceLabeledSelect.vue +++ b/shell/components/form/ResourceLabeledSelect.vue @@ -52,14 +52,14 @@ export enum RESOURCE_LABEL_SELECT_MODE { } /** - * Convience wrapper around the LabelSelect component to support pagination + * Convenience wrapper around the LabelSelect component to support pagination * * Handles * * 1) Conditionally enabling the pagination feature given system settings * 2) Helper function to fetch the pagination result * - * A number of ways can be provided to override the convienences (see props) + * A number of ways can be provided to override the conveniences (see props) */ export default defineComponent({ name: 'ResourceLabeledSelect', diff --git a/shell/components/nav/TopLevelMenu.helper.ts b/shell/components/nav/TopLevelMenu.helper.ts new file mode 100644 index 00000000000..1c886ef77f4 --- /dev/null +++ b/shell/components/nav/TopLevelMenu.helper.ts @@ -0,0 +1,546 @@ +import { CAPI, MANAGEMENT } from '@shell/config/types'; +import { PaginationParam, PaginationParamFilter, PaginationSort } from '@shell/types/store/pagination.types'; +import { VuexStore } from '@shell/types/store/vuex'; +import { filterHiddenLocalCluster, filterOnlyKubernetesClusters, paginationFilterClusters } from '@shell/utils/cluster'; +import PaginationWrapper from '@shell/utils/pagination-wrapper'; +import { allHash } from '@shell/utils/promise'; +import { sortBy } from '@shell/utils/sort'; +import { LocationAsRelativeRaw } from 'vue-router'; + +interface TopLevelMenuCluster { + id: string, + label: string, + ready: boolean + providerNavLogo: string, + badge: string, + isLocal: boolean, + pinned: boolean, + description: string, + pin: () => void, + unpin: () => void, + clusterRoute: LocationAsRelativeRaw, +} + +interface UpdateArgs { + searchTerm: string, + pinnedIds: string[], + unPinnedMax?: number, +} + +type MgmtCluster = { + [key: string]: any +} + +type ProvCluster = { + [key: string]: any +} + +/** + * Order + * 1. local cluster - https://github.com/rancher/dashboard/issues/10975 + * 2. working clusters + * 3. name + */ +const DEFAULT_SORT: Array = [ + { + asc: false, + field: 'spec.internal', + }, + // { + // asc: true, + // field: 'status.conditions[0].status' // Pending API changes https://github.com/rancher/rancher/issues/48092 + // }, + { + asc: true, + field: 'spec.displayName', + }, +]; + +export interface TopLevelMenuHelper { + /** + * Filter mgmt clusters by + * 1. If harvester or not (filterOnlyKubernetesClusters) + * 2. If local or not (filterHiddenLocalCluster) + * 3. Is pinned + * + * Sort By + * 1. is local cluster (appears at top) + * 2. ready + * 3. name + */ + clustersPinned: Array; + + /** + * Filter mgmt clusters by + * 1. If harvester or not (filterOnlyKubernetesClusters) + * 2. If local or not (filterHiddenLocalCluster) + * 3. + * a) if search term, filter on it + * b) if no search term, filter on pinned + * + * Sort By + * 1. is local cluster (appears at top) + * 2. ready + * 3. name + */ + clustersOthers: Array; + + update: (args: UpdateArgs) => Promise +} + +export abstract class BaseTopLevelMenuHelper { + protected $store: VuexStore; + protected hasProvCluster: boolean; + + /** + * Filter mgmt clusters by + * 1. If harvester or not (filterOnlyKubernetesClusters) + * 2. If local or not (filterHiddenLocalCluster) + * 3. Is pinned + * + * Why aren't we filtering these by search term? Because we don't show pinned when filtering on search term + * + * Sort By + * 1. is local cluster (appears at top) + * 2. ready + * 3. name + */ + public clustersPinned: Array = []; + + /** + * Filter mgmt clusters by + * 1. If harvester or not (filterOnlyKubernetesClusters) + * 2. If local or not (filterHiddenLocalCluster) + * 3. + * a) if search term, filter on it + * b) if no search term, filter on pinned + * + * Sort By + * 1. is local cluster (appears at top) + * 2. ready + * 3. name + */ + public clustersOthers: Array = []; + + constructor({ $store }: { + $store: VuexStore, +}) { + this.$store = $store; + + this.hasProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER); + } + + protected convertToCluster(mgmtCluster: MgmtCluster, provCluster: ProvCluster): TopLevelMenuCluster { + return { + id: mgmtCluster.id, + label: mgmtCluster.nameDisplay, + ready: mgmtCluster.isReady, // && !provCluster?.hasError, + providerNavLogo: mgmtCluster.providerMenuLogo, + badge: mgmtCluster.badge, + isLocal: mgmtCluster.isLocal, + pinned: mgmtCluster.pinned, + description: provCluster?.description || mgmtCluster.description, + pin: () => mgmtCluster.pin(), + unpin: () => mgmtCluster.unpin(), + clusterRoute: { name: 'c-cluster-explorer', params: { cluster: mgmtCluster.id } } + }; + } +} + +/** + * Helper designed to supply paginated results for the top level menu cluster resources + */ +export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper implements TopLevelMenuHelper { + private args?: UpdateArgs; + + private clustersPinnedWrapper: PaginationWrapper; + private clustersOthersWrapper: PaginationWrapper; + private provClusterWrapper: PaginationWrapper; + + private commonClusterFilters: PaginationParam[]; + + constructor({ $store }: { + $store: VuexStore, + }) { + super({ $store }); + + this.commonClusterFilters = paginationFilterClusters({ getters: this.$store.getters }); + + this.clustersPinnedWrapper = new PaginationWrapper({ + $store, + onUpdate: () => { + // trigger on websocket update (only need 1 trigger for this cluster type) + // https://github.com/rancher/rancher/issues/40773 / https://github.com/rancher/dashboard/issues/12734 + if (this.args) { + this.update(this.args); + } + }, + enabledFor: { + store: 'management', + resource: { + id: MANAGEMENT.CLUSTER, + context: 'side-bar', + } + } + }); + this.clustersOthersWrapper = new PaginationWrapper({ + $store, + onUpdate: (res) => { + // trigger on websocket update (only need 1 trigger for this cluster type) + // https://github.com/rancher/rancher/issues/40773 / https://github.com/rancher/dashboard/issues/12734 + if (this.args) { + this.update(this.args); + } + }, + enabledFor: { + store: 'management', + resource: { + id: MANAGEMENT.CLUSTER, + context: 'side-bar', + } + } + }); + this.provClusterWrapper = new PaginationWrapper({ + $store, + onUpdate: (res) => { + // trigger on websocket update (only need 1 trigger for this cluster type) + // https://github.com/rancher/rancher/issues/40773 / https://github.com/rancher/dashboard/issues/12734 + if (this.args) { + this.update(this.args); + } + }, + enabledFor: { + store: 'management', + resource: { + id: CAPI.RANCHER_CLUSTER, + context: 'side-bar', + } + } + }); + } + + // ---------- requests ---------- + async update(args: UpdateArgs) { + if (!this.hasProvCluster) { + // We're filtering out mgmt clusters without prov clusters, so if the user can't see any prov clusters at all + // exit early + return; + } + + this.args = args; + const promises = { + pinned: this.updatePinned(args), + notPinned: this.updateOthers(args) + }; + + const res: { + pinned: MgmtCluster[], + notPinned: MgmtCluster[] + } = await allHash(promises) as any; + const provClusters = await this.updateProvCluster(res.notPinned, res.pinned); + const provClustersByMgmtId = provClusters.reduce((res: { [mgmtId: string]: ProvCluster}, provCluster: ProvCluster) => { + if (provCluster.mgmtClusterId) { + res[provCluster.mgmtClusterId] = provCluster; + } + + return res; + }, {} as { [mgmtId: string]: ProvCluster}); + + const _clustersNotPinned = res.notPinned + .filter((mgmtCluster) => !!provClustersByMgmtId[mgmtCluster.id]) + .map((mgmtCluster) => this.convertToCluster(mgmtCluster, provClustersByMgmtId[mgmtCluster.id])); + const _clustersPinned = res.pinned + .filter((mgmtCluster) => !!provClustersByMgmtId[mgmtCluster.id]) + .map((mgmtCluster) => this.convertToCluster(mgmtCluster, provClustersByMgmtId[mgmtCluster.id])); + + this.clustersPinned.length = 0; + this.clustersOthers.length = 0; + + this.clustersPinned.push(..._clustersPinned); + this.clustersOthers.push(..._clustersNotPinned); + } + + private constructParams({ + pinnedIds, + searchTerm, + includeLocal, + includeSearchTerm, + includePinned, + excludePinned, + }: { + pinnedIds?: string[], + searchTerm?: string, + includeLocal?: boolean, + includeSearchTerm?: boolean, + includePinned?: boolean, + excludePinned?: boolean, + }): PaginationParam[] { + const filters: PaginationParam[] = [...this.commonClusterFilters]; + + if (pinnedIds) { + if (includePinned) { + // cluster id is 1 OR 2 OR 3 OR 4... + filters.push(PaginationParamFilter.createMultipleFields( + pinnedIds.map((id) => ({ + field: 'id', value: id, equals: true, exact: true + })) + )); + } + + if (excludePinned) { + // cluster id is NOT 1 AND NOT 2 AND NOT 3 AND NOT 4... + filters.push(...pinnedIds.map((id) => PaginationParamFilter.createSingleField({ + field: 'id', equals: false, value: id + }))); + } + } + + if (searchTerm && includeSearchTerm) { + filters.push(PaginationParamFilter.createSingleField({ + field: 'spec.displayName', exact: false, value: searchTerm + })); + } + + if (includeLocal) { + filters.push(PaginationParamFilter.createSingleField({ field: 'id', value: 'local' })); + } + + return filters; + } + + /** + * See `clustersPinned` description for details + */ + private async updatePinned(args: UpdateArgs): Promise { + if (args.pinnedIds?.length < 1) { + // Return early, otherwise we're fetching all clusters... + return Promise.resolve([]); + } + + return this.clustersPinnedWrapper.request({ + pagination: { + filters: this.constructParams({ + pinnedIds: args.pinnedIds, + includePinned: true, + }), + page: 1, + sort: DEFAULT_SORT, + projectsOrNamespaces: [] + }, + classify: true, + }).then((r) => r.data); + } + + /** + * See `clustersOthers` description for details + */ + private async updateOthers(args: UpdateArgs): Promise { + return this.clustersOthersWrapper.request({ + pagination: { + filters: this.constructParams({ + searchTerm: args.searchTerm, + includeSearchTerm: !!args.searchTerm, + pinnedIds: args.pinnedIds, + excludePinned: !args.searchTerm, + }), + page: 1, + pageSize: args.unPinnedMax, + sort: DEFAULT_SORT, + projectsOrNamespaces: [] + }, + classify: true, + }).then((r) => r.data); + } + + /** + * Find all provisioning clusters associated with the displayed mgmt clusters + */ + private async updateProvCluster(notPinned: MgmtCluster[], pinned: MgmtCluster[]): Promise { + return this.provClusterWrapper.request({ + pagination: { + + filters: [ + PaginationParamFilter.createMultipleFields( + [...notPinned, ...pinned] + .map((mgmtCluster) => ({ + field: 'status.clusterName', value: mgmtCluster.id, equals: true, exact: true + })) + ) + ], + + page: 1, + sort: [], + projectsOrNamespaces: [] + }, + classify: true, + }).then((r) => r.data); + } +} + +/** + * Helper designed to supply non-pagainted results for the top level menu cluster resources + */ +export class TopLevelMenuHelperLegacy extends BaseTopLevelMenuHelper implements TopLevelMenuHelper { + constructor({ $store }: { + $store: VuexStore, + }) { + super({ $store }); + + if (this.hasProvCluster) { + $store.dispatch('management/findAll', { type: CAPI.RANCHER_CLUSTER }); + } + } + + async update(args: UpdateArgs) { + const clusters = this.updateClusters(); + const _clustersNotPinned = this.clustersFiltered(clusters, args); + const _clustersPinned = this.pinFiltered(clusters, args); + + this.clustersPinned.length = 0; + this.clustersOthers.length = 0; + + this.clustersPinned.push(..._clustersPinned); + this.clustersOthers.push(..._clustersNotPinned); + } + + /** + * Filter mgmt clusters by + * 1. Harvester type 1 (filterOnlyKubernetesClusters) + * 2. Harvester type 2 (filterHiddenLocalCluster) + * 3. There's a matching prov cluster + * + * Convert remaining clusters to special format + */ + private updateClusters(): TopLevelMenuCluster[] { + if (!this.hasProvCluster) { + // We're filtering out mgmt clusters without prov clusters, so if the user can't see any prov clusters at all + // exit early + return []; + } + + const all = this.$store.getters['management/all'](MANAGEMENT.CLUSTER); + const mgmtClusters = filterHiddenLocalCluster(filterOnlyKubernetesClusters(all, this.$store), this.$store); + const provClusters = this.$store.getters['management/all'](CAPI.RANCHER_CLUSTER); + const provClustersByMgmtId = provClusters.reduce((res: any, provCluster: ProvCluster) => { + if (provCluster.mgmt?.id) { + res[provCluster.mgmt.id] = provCluster; + } + + return res; + }, {}); + + return (mgmtClusters || []).reduce((res: any, mgmtCluster: MgmtCluster) => { + // Filter to only show mgmt clusters that exist for the available provisioning clusters + // Addresses issue where a mgmt cluster can take some time to get cleaned up after the corresponding + // provisioning cluster has been deleted + if (!provClustersByMgmtId[mgmtCluster.id]) { + return res; + } + + res.push(this.convertToCluster(mgmtCluster, provClustersByMgmtId[mgmtCluster.id])); + + return res; + }, []); + } + + /** + * Filter clusters by + * 1. Not pinned + * 2. Includes search term + * + * Sort remaining clusters + * + * Reduce number of clusters if too many too show + * + * Important! This is used to show unpinned clusters OR results of search + */ + private clustersFiltered(clusters: TopLevelMenuCluster[], args: UpdateArgs): TopLevelMenuCluster[] { + const clusterFilter = args.searchTerm; + const maxClustersToShow = args.unPinnedMax || 10; + + const search = (clusterFilter || '').toLowerCase(); + let localCluster: MgmtCluster | null = null; + + const filtered = clusters.filter((c) => { + // If we're searching we don't care if pinned or not + if (search) { + if (!c.label?.toLowerCase().includes(search)) { + return false; + } + } else if (c.pinned) { + // Not searching, not pinned, don't care + return false; + } + + if (!localCluster && c.id === 'local') { + // Local cluster is a special case, we're inserting it at top so don't include in the middle + localCluster = c; + + return false; + } + + return true; + }); + + const sorted = sortBy(filtered, ['ready:desc', 'label']); + + // put local cluster on top of list always - https://github.com/rancher/dashboard/issues/10975 + if (localCluster) { + sorted.unshift(localCluster); + } + + if (search) { + // this.showPinClusters = false; + // this.searchActive = !sorted.length > 0; + + return sorted; + } + // this.showPinClusters = true; + // this.searchActive = false; + + if (sorted.length >= maxClustersToShow) { + return sorted.slice(0, maxClustersToShow); + } + + return sorted; + } + + /** + * Filter clusters by + * 1. Not pinned + * 2. Includes search term + * + * Sort remaining clusters + * + * Reduce number of clusters if too many too show + * + * Important! This is hidden if there's a filter (user searching) + */ + private pinFiltered(clusters: TopLevelMenuCluster[], args: UpdateArgs): TopLevelMenuCluster[] { + let localCluster = null; + const filtered = clusters.filter((c) => { + if (!c.pinned) { + // We only care about pinned clusters + return false; + } + + if (c.id === 'local') { + // Special case, we're going to add this at the start so filter out + localCluster = c; + + return false; + } + + return true; + }); + + const sorted = sortBy(filtered, ['ready:desc', 'label']); + + // put local cluster on top of list always - https://github.com/rancher/dashboard/issues/10975 + if (localCluster) { + sorted.unshift(localCluster); + } + + return sorted; + } +} diff --git a/shell/components/nav/TopLevelMenu.vue b/shell/components/nav/TopLevelMenu.vue index 43062d2c48f..829ba829d3e 100644 --- a/shell/components/nav/TopLevelMenu.vue +++ b/shell/components/nav/TopLevelMenu.vue @@ -4,18 +4,20 @@ import ClusterIconMenu from '@shell/components/ClusterIconMenu'; import IconOrSvg from '../IconOrSvg'; import { BLANK_CLUSTER } from '@shell/store/store-types.js'; import { mapGetters } from 'vuex'; -import { CAPI, MANAGEMENT } from '@shell/config/types'; -import { MENU_MAX_CLUSTERS } from '@shell/store/prefs'; +import { CAPI, COUNT, MANAGEMENT } from '@shell/config/types'; +import { MENU_MAX_CLUSTERS, PINNED_CLUSTERS } from '@shell/store/prefs'; import { sortBy } from '@shell/utils/sort'; import { ucFirst } from '@shell/utils/string'; import { KEY } from '@shell/utils/platform'; import { getVersionInfo } from '@shell/utils/version'; import { SETTING } from '@shell/config/settings'; -import { filterOnlyKubernetesClusters, filterHiddenLocalCluster } from '@shell/utils/cluster'; import { getProductFromRoute } from '@shell/utils/router'; import { isRancherPrime } from '@shell/config/version'; import Pinned from '@shell/components/nav/Pinned'; import { getGlobalBannerFontSizes } from '@shell/utils/banners'; +import { TopLevelMenuHelperPagination, TopLevelMenuHelperLegacy } from '@shell/components/nav/TopLevelMenu.helper'; +import { debounce } from 'lodash'; +import { sameContents } from '@shell/utils/array'; export default { components: { @@ -29,6 +31,17 @@ export default { const { displayVersion, fullVersion } = getVersionInfo(this.$store); const hasProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER); + const canPagination = this.$store.getters[`management/paginationEnabled`]({ + id: MANAGEMENT.CLUSTER, + context: 'side-bar', + }) && this.$store.getters[`management/paginationEnabled`]({ + id: CAPI.RANCHER_CLUSTER, + context: 'side-bar', + }); + const helper = canPagination ? new TopLevelMenuHelperPagination({ $store: this.$store }) : new TopLevelMenuHelperLegacy({ $store: this.$store }); + const provClusters = !canPagination && hasProvCluster ? this.$store.getters[`management/all`](CAPI.RANCHER_CLUSTER) : []; + const mgmtClusters = !canPagination ? this.$store.getters[`management/all`](MANAGEMENT.CLUSTER) : []; + return { shown: false, displayVersion, @@ -37,27 +50,26 @@ export default { hasProvCluster, maxClustersToShow: MENU_MAX_CLUSTERS, emptyCluster: BLANK_CLUSTER, - showPinClusters: false, - searchActive: false, routeCombo: false, - }; - }, - fetch() { - if (this.hasProvCluster) { - this.$store.dispatch('management/findAll', { type: CAPI.RANCHER_CLUSTER }); - } + canPagination, + helper, + debouncedHelperUpdateSlow: debounce((...args) => this.helper.update(...args), 750), + debouncedHelperUpdateQuick: debounce((...args) => this.helper.update(...args), 200), + provClusters, + mgmtClusters, + }; }, computed: { ...mapGetters(['clusterId']), ...mapGetters(['clusterReady', 'isRancher', 'currentCluster', 'currentProduct', 'isRancherInHarvester']), ...mapGetters({ features: 'features/get' }), - value: { - get() { - return this.$store.getters['productId']; - }, + + pinnedIds() { + return this.$store.getters['prefs/get'](PINNED_CLUSTERS); }, + sideMenuStyle() { const globalBannerSettings = getGlobalBannerFontSizes(this.$store); @@ -68,161 +80,47 @@ export default { }, showClusterSearch() { - return this.clusters.length > this.maxClustersToShow; + return this.allClustersCount > this.maxClustersToShow; }, - /** - * Filter mgmt clusters by - * 1. Harvester type 1 (filterOnlyKubernetesClusters) - * 2. Harvester type 2 (filterHiddenLocalCluster) - * 3. There's a matching prov cluster - * - * Convert remaining clusters to special format - */ - clusters() { - if (!this.hasProvCluster) { - // We're filtering out mgmt clusters without prov clusters, so if the user can't see any prov clusters at all - // exit early - return []; - } + allClustersCount() { + const counts = this.$store.getters[`management/all`](COUNT)?.[0]?.counts || {}; + const count = counts[MANAGEMENT.CLUSTER] || {}; - const all = this.$store.getters['management/all'](MANAGEMENT.CLUSTER); - const mgmtClusters = filterHiddenLocalCluster(filterOnlyKubernetesClusters(all, this.$store), this.$store); - const provClusters = this.$store.getters['management/all'](CAPI.RANCHER_CLUSTER); - const provClustersByMgmtId = provClusters.reduce((res, provCluster) => { - if (provCluster.mgmt?.id) { - res[provCluster.mgmt.id] = provCluster; - } - - return res; - }, {}); + return count?.summary.count; + }, - return (mgmtClusters || []).reduce((res, mgmtCluster) => { - // Filter to only show mgmt clusters that exist for the available provisioning clusters - // Addresses issue where a mgmt cluster can take some time to get cleaned up after the corresponding - // provisioning cluster has been deleted - if (!provClustersByMgmtId[mgmtCluster.id]) { - return res; - } + // New + search() { + return (this.clusterFilter || '').toLowerCase(); + }, - const pCluster = provClustersByMgmtId[mgmtCluster.id]; - - res.push({ - id: mgmtCluster.id, - label: mgmtCluster.nameDisplay, - ready: mgmtCluster.isReady && !pCluster?.hasError, - osLogo: mgmtCluster.providerOsLogo, - providerNavLogo: mgmtCluster.providerMenuLogo, - badge: mgmtCluster.badge, - isLocal: mgmtCluster.isLocal, - isHarvester: mgmtCluster.isHarvester, - pinned: mgmtCluster.pinned, - description: pCluster?.description || mgmtCluster.description, - pin: () => mgmtCluster.pin(), - unpin: () => mgmtCluster.unpin(), - clusterRoute: { name: 'c-cluster-explorer', params: { cluster: mgmtCluster.id } } - }); + // New + showPinClusters() { + return !this.clusterFilter; + }, - return res; - }, []); + // New + searchActive() { + return !!this.search; }, /** - * Filter clusters by - * 1. Not pinned - * 2. Includes search term + * Only Clusters that are pinned * - * Sort remaining clusters - * - * Reduce number of clusters if too many too show - * - * Important! This is used to show unpinned clusters OR results of search + * (see description of helper.clustersPinned for more details) */ - clustersFiltered() { - const search = (this.clusterFilter || '').toLowerCase(); - let localCluster = null; - - const filtered = this.clusters.filter((c) => { - // If we're searching we don't care if pinned or not - if (search) { - if (!c.label?.toLowerCase().includes(search)) { - return false; - } - } else if (c.pinned) { - // Not searching, not pinned, don't care - return false; - } - - if (!localCluster && c.id === 'local') { - // Local cluster is a special case, we're inserting it at top so don't include in the middle - localCluster = c; - - return false; - } - - return true; - }); - - const sorted = sortBy(filtered, ['ready:desc', 'label']); - - // put local cluster on top of list always - https://github.com/rancher/dashboard/issues/10975 - if (localCluster) { - sorted.unshift(localCluster); - } - - if (search) { - this.showPinClusters = false; - this.searchActive = !sorted.length > 0; - - return sorted; - } - this.showPinClusters = true; - this.searchActive = false; - - if (sorted.length >= this.maxClustersToShow) { - return sorted.slice(0, this.maxClustersToShow); - } - - return sorted; + pinFiltered() { + return this.hasProvCluster ? this.helper.clustersPinned : []; }, /** - * Filter clusters by - * 1. Not pinned - * 2. Includes search term - * - * Sort remaining clusters - * - * Reduce number of clusters if too many too show + * Used to shown unpinned clusters OR results of text search * - * Important! This is hidden if there's a filter (user searching) + * (see description of helper.clustersOthers for more details) */ - pinFiltered() { - let localCluster = null; - const filtered = this.clusters.filter((c) => { - if (!c.pinned) { - // We only care about pinned clusters - return false; - } - - if (c.id === 'local') { - // Special case, we're going to add this at the start so filter out - localCluster = c; - - return false; - } - - return true; - }); - - const sorted = sortBy(filtered, ['ready:desc', 'label']); - - // put local cluster on top of list always - https://github.com/rancher/dashboard/issues/10975 - if (localCluster) { - sorted.unshift(localCluster); - } - - return sorted; + clustersFiltered() { + return this.hasProvCluster ? this.helper.clustersOthers : []; }, pinnedClustersHeight() { @@ -232,7 +130,7 @@ export default { return `min-height: ${ height }px`; }, clusterFilterCount() { - return this.clusterFilter ? this.clustersFiltered.length : this.clusters.length; + return this.clusterFilter ? this.clustersFiltered.length : this.allClustersCount; }, multiClusterApps() { @@ -360,10 +258,45 @@ export default { } }, + // See https://github.com/rancher/dashboard/issues/12831 for outstanding performance related work watch: { $route() { this.shown = false; - } + }, + + pinnedIds: { + immediate: true, + handler(neu, old) { + if (sameContents(neu, old)) { + return; + } + + this.updateClusters(neu, 'quick'); + } + }, + + search() { + this.updateClusters(this.pinnedIds, 'slow'); + }, + + provClusters: { + handler() { + // Shouldn't get here if SSP + this.updateClusters(this.pinnedIds, 'slow'); + }, + deep: true, + immediate: true, + }, + + mgmtClusters: { + handler() { + // Shouldn't get here if SSP + this.updateClusters(this.pinnedIds, 'slow'); + }, + deep: true, + immediate: true, + }, + }, mounted() { @@ -490,9 +423,27 @@ export default { popperClass }; }, + + updateClusters(pinnedIds, speed = 'slow' | 'quick') { + const args = { + pinnedIds, + searchTerm: this.search, + unPinnedMax: this.maxClustersToShow + }; + + switch (speed) { + case 'slow': + this.debouncedHelperUpdateSlow(args); + break; + case 'quick': + this.debouncedHelperUpdateQuick(args); + break; + } + } } }; + -