diff --git a/.github/workflows/build-extension-catalog.yml b/.github/workflows/build-extension-catalog.yml index 2f4b7c52d0..48cfc89d9a 100644 --- a/.github/workflows/build-extension-catalog.yml +++ b/.github/workflows/build-extension-catalog.yml @@ -60,7 +60,7 @@ jobs: git config user.email 'github-actions[bot]@users.noreply.github.com' - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ inputs.registry_target }} username: ${{ inputs.registry_user }} diff --git a/cypress/e2e/po/components/ace.po.ts b/cypress/e2e/po/components/ace.po.ts new file mode 100644 index 0000000000..0b2d2a6fe6 --- /dev/null +++ b/cypress/e2e/po/components/ace.po.ts @@ -0,0 +1,31 @@ +import ComponentPo from '@/cypress/e2e/po/components/component.po'; +import RadioGroupInputPo from '@/cypress/e2e/po/components/radio-group-input.po'; +import LabeledInputPo from '@/cypress/e2e/po/components/labeled-input.po'; + +export default class ACE extends ComponentPo { + constructor(selector = '.dashboard-root') { + super(selector); + } + + enable() { + const radioButton = new RadioGroupInputPo('[data-testid="ace-enabled-radio-input"]'); + + return radioButton.set(1); + } + + fqdn() { + return LabeledInputPo.byLabel(this.self(), 'FQDN'); + } + + caCerts() { + return LabeledInputPo.byLabel(this.self(), 'CA Certificates'); + } + + enterFdqn(val: string) { + return new LabeledInputPo('[data-testid="ace-fqdn-input"]').set(val); + } + + enterCaCerts(val: string) { + return new LabeledInputPo('[data-testid="ace-cacerts-input"]').set(val); + } +} diff --git a/cypress/e2e/po/components/component.po.ts b/cypress/e2e/po/components/component.po.ts index 1762ea2c5e..df064096c6 100644 --- a/cypress/e2e/po/components/component.po.ts +++ b/cypress/e2e/po/components/component.po.ts @@ -82,4 +82,8 @@ export default class ComponentPo { checkNotExists(options?: GetOptions): Cypress.Chainable { return this.self(options).should('not.exist'); } + + shouldHaveValue(value: string, options?: GetOptions): Cypress.Chainable { + return this.self(options).should('have.value', value); + } } diff --git a/cypress/e2e/po/components/growl-manager.po.ts b/cypress/e2e/po/components/growl-manager.po.ts new file mode 100644 index 0000000000..b7aecd4c18 --- /dev/null +++ b/cypress/e2e/po/components/growl-manager.po.ts @@ -0,0 +1,23 @@ +import ComponentPo from '@/cypress/e2e/po/components/component.po'; + +export class GrowlManagerPo extends ComponentPo { + constructor() { + super('.growl-container'); + } + + growlList() { + return this.self().find('.growl-list'); + } + + growlMessage() { + return this.self().find('.growl-message'); + } + + dismissWarning() { + return this.self().find('.icon-close').click(); + } + + dismissAllWarnings() { + return this.self().find('button.btn').contains('Clear All Notifications').click(); + } +} diff --git a/cypress/e2e/po/components/key-value.po.ts b/cypress/e2e/po/components/key-value.po.ts new file mode 100644 index 0000000000..73f0353c1f --- /dev/null +++ b/cypress/e2e/po/components/key-value.po.ts @@ -0,0 +1,13 @@ +import ComponentPo from '@/cypress/e2e/po/components/component.po'; + +export default class KeyValuePo extends ComponentPo { + addButton(label: string) { + return this.self().find('[data-testid="add_row_item_button"]').contains(label); + } + + setKeyValueAtIndex(label: string, key: string, value: string, index: number, selector: string) { + this.addButton(label).click(); + this.self().find(`${ selector } [data-testid="input-kv-item-key-${ index }"]`).type(key); + this.self().find(`${ selector } [data-testid="kv-item-value-${ index }"]`).type(value); + } +} diff --git a/cypress/e2e/po/components/labeled-input.po.ts b/cypress/e2e/po/components/labeled-input.po.ts index 8f9c5a2f30..6af54f972d 100644 --- a/cypress/e2e/po/components/labeled-input.po.ts +++ b/cypress/e2e/po/components/labeled-input.po.ts @@ -50,6 +50,14 @@ export default class LabeledInputPo extends ComponentPo { }); } + expectToBeDisabled(): Cypress.Chainable { + return this.self().should('have.attr', 'disabled', 'disabled'); + } + + expectToBeEnabled(): Cypress.Chainable { + return this.self().should('not.have.attr', 'disabled'); + } + /** * Return the input HTML element from given container * @returns HTML Element diff --git a/cypress/e2e/po/components/tabbed.po.ts b/cypress/e2e/po/components/tabbed.po.ts index 631e24a58b..1e74ffe514 100644 --- a/cypress/e2e/po/components/tabbed.po.ts +++ b/cypress/e2e/po/components/tabbed.po.ts @@ -17,6 +17,10 @@ export default class TabbedPo extends ComponentPo { return this.self().get('[data-testid="tabbed-block"] > li'); } + assertTabIsActive(selector: string) { + return this.self().find(`${ selector }`).should('have.class', 'active'); + } + /** * Get tab labels * @param tabLabelsSelector diff --git a/cypress/e2e/po/components/policy/network-policy.po.ts b/cypress/e2e/po/edit/policy/network-policy.po.ts similarity index 67% rename from cypress/e2e/po/components/policy/network-policy.po.ts rename to cypress/e2e/po/edit/policy/network-policy.po.ts index fe1738d4b3..f62ba7c4c5 100644 --- a/cypress/e2e/po/components/policy/network-policy.po.ts +++ b/cypress/e2e/po/edit/policy/network-policy.po.ts @@ -1,13 +1,25 @@ -import CreateEditViewPo from '@/cypress/e2e/po/components/create-edit-view.po'; +import PagePo from '@/cypress/e2e/po/pages/page.po'; import AsyncButtonPo from '@/cypress/e2e/po/components/async-button.po'; import LabeledInputPo from '@/cypress/e2e/po/components/labeled-input.po'; import CheckboxInputPo from '@/cypress/e2e/po/components/checkbox-input.po'; import LabeledSelectPo from '@/cypress/e2e/po/components/labeled-select.po'; import BannersPo from '@/cypress/e2e/po/components/banners.po'; import CodeMirrorPo from '@/cypress/e2e/po/components/code-mirror.po'; -export default class NetworkPolicyPo extends CreateEditViewPo { - constructor(selector = '.dashboard-root') { - super(selector); +import ArrayListPo from '@/cypress/e2e/po/components/array-list.po'; + +export default class CreateEditNetworkPolicyPagePo extends PagePo { + private static createPath(clusterId: string, namespace?: string, id?: string ) { + const root = `/c/${ clusterId }/explorer/networking.k8s.io.networkpolicy`; + + return id ? `${ root }/${ namespace }/${ id }` : `${ root }/create`; + } + + static goTo(path: string): Cypress.Chainable { + throw new Error('invalid'); + } + + constructor(clusterId = 'local', namespace?: string, id?: string) { + super(CreateEditNetworkPolicyPagePo.createPath(clusterId, namespace, id)); } nameInput() { @@ -30,6 +42,14 @@ export default class NetworkPolicyPo extends CreateEditViewPo { return cy.get('[data-testid="array-list-button"]').contains('Add allowed traffic source'); } + addAllowedPortButton() { + return cy.get('[data-testid="array-list-button"]').contains('Add allowed port'); + } + + ingressRuleItem(index: number) { + return new ArrayListPo('section #rule-ingress0', this.self()).arrayListItem(index); + } + policyRuleTargetSelect(index: number) { return new LabeledSelectPo(`[data-testid="policy-rule-target-${ index }"] [data-testid="policy-rule-target-type-labeled-select"]`, this.self()); } diff --git a/cypress/e2e/po/edit/resource-detail.po.ts b/cypress/e2e/po/edit/resource-detail.po.ts index 28c7e91387..fb560e5870 100644 --- a/cypress/e2e/po/edit/resource-detail.po.ts +++ b/cypress/e2e/po/edit/resource-detail.po.ts @@ -4,14 +4,26 @@ import CruResourcePo from '@/cypress/e2e/po/components/cru-resource.po'; import ResourceYamlPo from '@/cypress/e2e/po/components/resource-yaml.po'; export default class ResourceDetailPo extends ComponentPo { + /** + * components for handling CRUD operations for resources, including cancel/save buttons + * @returns + */ cruResource() { return new CruResourcePo(this.self()); } + /** + * components for managing the resource creation and edit forms + * @returns + */ createEditView() { return new CreateEditViewPo(this.self()); } + /** + * components for YAML editor + * @returns + */ resourceYaml() { return new ResourceYamlPo(this.self()); } diff --git a/cypress/e2e/po/edit/services.po.ts b/cypress/e2e/po/edit/services.po.ts index a316910251..6301b26cbd 100644 --- a/cypress/e2e/po/edit/services.po.ts +++ b/cypress/e2e/po/edit/services.po.ts @@ -1,18 +1,76 @@ import PagePo from '@/cypress/e2e/po/pages/page.po'; +import NameNsDescription from '@/cypress/e2e/po/components/name-ns-description.po'; +import ResourceDetailPo from '@/cypress/e2e/po/edit/resource-detail.po'; +import LabeledSelectPo from '@/cypress/e2e/po/components/labeled-select.po'; +import TabbedPo from '@/cypress/e2e/po/components/tabbed.po'; +import LabeledInputPo from '@/cypress/e2e/po/components/labeled-input.po'; +import ArrayListPo from '@/cypress/e2e/po/components/array-list.po'; +import KeyValuePo from '@/cypress/e2e/po/components/key-value.po'; -export default class WorkloadsCreateEditPo extends PagePo { - private static createPath(clusterId: string, id?: string ) { - const root = `/c/${ clusterId }/explorer/storage.k8s.io.storageclass/create`; +export default class ServicesCreateEditPo extends PagePo { + private static createPath(clusterId: string, namespace?: string, id?: string ) { + const root = `/c/${ clusterId }/explorer/service`; - return id ? `${ root }/${ id }` : `${ root }/create`; + return id ? `${ root }/${ namespace }/${ id }` : `${ root }/create`; } static goTo(path: string): Cypress.Chainable { throw new Error('invalid'); } - constructor(clusterId = '_', id?: string) { - super(WorkloadsCreateEditPo.createPath(clusterId, id)); + constructor(clusterId = 'local', namespace?: string, id?: string) { + super(ServicesCreateEditPo.createPath(clusterId, namespace, id)); + } + + resourceDetail() { + return new ResourceDetailPo(this.self()); + } + + title() { + return this.self().get('.title .primaryheader h1'); + } + + nameNsDescription() { + return new NameNsDescription(this.self()); + } + + selectNamespace(label: string) { + const selectNs = new LabeledSelectPo(`[data-testid="name-ns-description-namespace"]`, this.self()); + + selectNs.toggle(); + selectNs.clickLabel(label); + } + + selectServiceOption(index: number) { + return this.resourceDetail().cruResource().selectSubTypeByIndex(index).click(); + } + + tabs() { + return new TabbedPo('[data-testid="tabbed"]'); + } + + externalNameTab() { + return this.tabs().clickTabWithSelector('[data-testid="define-external-name"]'); + } + + externalNameInput() { + return new LabeledInputPo('#define-external-name .labeled-input input'); + } + + ipAddressesTab() { + return this.tabs().clickTabWithSelector('[data-testid="ips"]'); + } + + ipAddressList() { + return new ArrayListPo('section#ips'); + } + + lablesAnnotationsTab() { + return this.tabs().clickTabWithSelector('[data-testid="btn-labels-and-annotations"]'); + } + + lablesAnnotationsKeyValue() { + return new KeyValuePo('section#labels-and-annotations'); } errorBanner() { diff --git a/cypress/e2e/po/extensions/imported/cluster-edit.po.ts b/cypress/e2e/po/extensions/imported/cluster-edit.po.ts new file mode 100644 index 0000000000..c5b580d1b9 --- /dev/null +++ b/cypress/e2e/po/extensions/imported/cluster-edit.po.ts @@ -0,0 +1,37 @@ +import PagePo from '@/cypress/e2e/po/pages/page.po'; +import LabeledInputPo from '@/cypress/e2e/po/components/labeled-input.po'; +import ACE from '@/cypress/e2e/po/components/ace.po'; +import ResourceDetailPo from '@/cypress/e2e/po/edit/resource-detail.po'; + +/** + * Edit page for imported cluster + */ +export default class ClusterManagerEditImportedPagePo extends PagePo { + private static createPath(clusterId: string, clusterName: string) { + return `/c/${ clusterId }/manager/provisioning.cattle.io.cluster/fleet-default/${ clusterName }`; + } + + static goTo(clusterId: string, clusterName: string): Cypress.Chainable { + return super.goTo(ClusterManagerEditImportedPagePo.createPath(clusterId, clusterName)); + } + + constructor(clusterId = '_', clusterName: string) { + super(ClusterManagerEditImportedPagePo.createPath(clusterId, clusterName)); + } + + name(): LabeledInputPo { + return LabeledInputPo.byLabel(this.self(), 'Name'); + } + + ace(): ACE { + return new ACE(); + } + + resourceDetail() { + return new ResourceDetailPo(this.self()); + } + + save() { + return this.resourceDetail().createEditView().save(); + } +} diff --git a/cypress/e2e/po/lists/chart-repositories.po.ts b/cypress/e2e/po/lists/chart-repositories.po.ts index 9e5f1fb328..2ebf4a605a 100644 --- a/cypress/e2e/po/lists/chart-repositories.po.ts +++ b/cypress/e2e/po/lists/chart-repositories.po.ts @@ -8,6 +8,10 @@ export default class ChartRepositoriesListPo extends BaseResourceList { return this.resourceTable().sortableTable().rowActionMenuOpen(repoName); } + closeActionMenu() { + cy.get('body').click(0, 0); // Click outside of the action menu + } + openBulkActionDropdown() { return this.resourceTable().sortableTable().bulkActionDropDownOpen(); } diff --git a/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts b/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts index 9ee0cab104..758a5fdbb5 100644 --- a/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts +++ b/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts @@ -7,4 +7,8 @@ export default class FleetClusterList extends BaseResourceList { details(name: string, index: number) { return this.resourceTable().sortableTable().rowWithName(name).column(index); } + + subRows() { + return this.resourceTable().sortableTable().subRows(); + } } diff --git a/cypress/e2e/po/other-products/cis-benchmark.po.ts b/cypress/e2e/po/other-products/cis-benchmark.po.ts new file mode 100644 index 0000000000..870e75b7d1 --- /dev/null +++ b/cypress/e2e/po/other-products/cis-benchmark.po.ts @@ -0,0 +1,72 @@ +import PagePo from '@/cypress/e2e/po/pages/page.po'; +import CodeMirrorPo from '@/cypress/e2e/po/components/code-mirror.po'; +import ResourceListMastheadPo from '@/cypress/e2e/po/components/ResourceList/resource-list-masthead.po'; +import BaseResourceList from '@/cypress/e2e/po/lists/base-resource-list.po'; +import NameNsDescription from '@/cypress/e2e/po/components/name-ns-description.po'; +import CruResourcePo from '@/cypress/e2e/po/components/cru-resource.po'; + +export class CisBenchmarkListPo extends PagePo { + private static createPath(clusterId: string) { + return `/c/${ clusterId }/cis/cis.cattle.io.clusterscan`; + } + + static goTo(clusterId: string): Cypress.Chainable { + return super.goTo(CisBenchmarkListPo.createPath(clusterId)); + } + + constructor(clusterId = 'local') { + super(CisBenchmarkListPo.createPath(clusterId)); + } + + masthead() { + return new ResourceListMastheadPo(this.self()); + } + + createScan() { + return this.masthead().create(); + } + + listElementWithName(name:string) { + const baseResourceList = new BaseResourceList(this.self()); + + return baseResourceList.resourceTable().sortableTable().rowElementWithName(name); + } + + firstRow() { + const baseResourceList = new BaseResourceList(this.self()); + + return baseResourceList.resourceTable().sortableTable().row(0); + } +} + +export class CisBenchmarkPo extends PagePo { + static url: string; + + private static createPath( clusterId: string, name: string ) { + const urlStr = `/c/${ clusterId }/cis/cis.cattle.io.clusterscan/${ name }`; + + return urlStr; + } + + static goTo(): Cypress.Chainable { + return super.goTo(this.url); + } + + constructor(clusterId = 'local', name = 'create') { + super(CisBenchmarkPo.createPath(clusterId, name)); + + CisBenchmarkPo.url = CisBenchmarkPo.createPath(clusterId, name); + } + + cruResource() { + return new CruResourcePo(this.self()); + } + + nameNsDescription() { + return new NameNsDescription(this.self()); + } + + yamlEditor(): CodeMirrorPo { + return CodeMirrorPo.bySelector(this.self(), '[data-testid="yaml-editor-code-mirror"]'); + } +} diff --git a/cypress/e2e/po/pages/explorer/network-policy.po.ts b/cypress/e2e/po/pages/explorer/network-policy.po.ts index 35db7da084..0b0b3cc1f1 100644 --- a/cypress/e2e/po/pages/explorer/network-policy.po.ts +++ b/cypress/e2e/po/pages/explorer/network-policy.po.ts @@ -2,6 +2,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'; +import CreateEditNetworkPolicyPagePo from '@/cypress/e2e/po/edit/policy/network-policy.po'; export class NetworkPolicyPagePo extends PagePo { private static createPath(clusterId: string) { @@ -21,7 +22,7 @@ export class NetworkPolicyPagePo extends PagePo { sideNav.navToSideMenuEntryByLabel('Network Policies'); } - constructor(clusterId = 'local') { + constructor(private clusterId = 'local') { super(NetworkPolicyPagePo.createPath(clusterId)); } @@ -40,4 +41,8 @@ export class NetworkPolicyPagePo extends PagePo { searchForNetworkPolicy(name: string) { return this.list().resourceTable().sortableTable().filter(name); } + + createEditNetworkPolicyForm(namespace?: string, id?: string): CreateEditNetworkPolicyPagePo { + return new CreateEditNetworkPolicyPagePo(this.clusterId, namespace, id); + } } diff --git a/cypress/e2e/po/pages/explorer/services.po.ts b/cypress/e2e/po/pages/explorer/services.po.ts index e9fbc44ae3..d5346480f1 100644 --- a/cypress/e2e/po/pages/explorer/services.po.ts +++ b/cypress/e2e/po/pages/explorer/services.po.ts @@ -26,7 +26,7 @@ export class ServicesPagePo extends PagePo { sideNav.navToSideMenuEntryByLabel('Service'); } - constructor(clusterId = 'local') { + constructor(private clusterId = 'local') { super(ServicesPagePo.createPath(clusterId)); } @@ -38,7 +38,7 @@ export class ServicesPagePo extends PagePo { return this.list().masthead().create(); } - createServicesForm(id? : string): ServicesCreateEditPo { - return new ServicesCreateEditPo(id); + createServicesForm(namespace?: string, id?: string): ServicesCreateEditPo { + return new ServicesCreateEditPo(this.clusterId, namespace, id); } } diff --git a/cypress/e2e/po/pages/extensions.po.ts b/cypress/e2e/po/pages/extensions.po.ts index ec2706bbf9..9944a27b64 100644 --- a/cypress/e2e/po/pages/extensions.po.ts +++ b/cypress/e2e/po/pages/extensions.po.ts @@ -34,7 +34,7 @@ export default class ExtensionsPagePo extends PagePo { return this.title().should('contain', 'Extensions'); } - loading(options: any) { + loading() { return this.self().get('.data-loading'); } diff --git a/cypress/e2e/po/pages/global-settings/home-links.po.ts b/cypress/e2e/po/pages/global-settings/home-links.po.ts index c186ec02db..d48c4a142f 100644 --- a/cypress/e2e/po/pages/global-settings/home-links.po.ts +++ b/cypress/e2e/po/pages/global-settings/home-links.po.ts @@ -32,7 +32,7 @@ export class HomeLinksPagePo extends RootClusterPage { } addLinkButton() { - return cy.getId('add_link_button'); + return cy.getId('add_row_item_button'); } removeLinkButton() { diff --git a/cypress/e2e/po/pages/global-settings/performance.po.ts b/cypress/e2e/po/pages/global-settings/performance.po.ts index e7a8eba545..88be2566a6 100644 --- a/cypress/e2e/po/pages/global-settings/performance.po.ts +++ b/cypress/e2e/po/pages/global-settings/performance.po.ts @@ -60,6 +60,10 @@ export class PerformancePagePo extends RootClusterPage { return CheckboxInputPo.byLabel(this.self(), 'Enable Garbage Collection'); } + garbageCollectionResourceCount() { + return LabeledInputPo.byLabel(this.self(), 'Resource Count'); + } + namespaceFilteringCheckbox(): CheckboxInputPo { return CheckboxInputPo.byLabel(this.self(), 'Enable Required Namespace / Project Filtering'); } diff --git a/cypress/e2e/tests/pages/charts/cis-benchmark.spec.ts b/cypress/e2e/tests/pages/charts/cis-benchmark.spec.ts new file mode 100644 index 0000000000..c5389ecf5c --- /dev/null +++ b/cypress/e2e/tests/pages/charts/cis-benchmark.spec.ts @@ -0,0 +1,93 @@ +import { ChartPage } from '@/cypress/e2e/po/pages/explorer/charts/chart.po'; +import HomePagePo from '@/cypress/e2e/po/pages/home.po'; +import { InstallChartPage } from '@/cypress/e2e/po/pages/explorer/charts/install-charts.po'; +import { MEDIUM_TIMEOUT_OPT, LONG_TIMEOUT_OPT } from '@/cypress/support/utils/timeouts'; +import Kubectl from '@/cypress/e2e/po/components/kubectl.po'; +import { CisBenchmarkPo, CisBenchmarkListPo } from '@/cypress/e2e/po/other-products/cis-benchmark.po'; +import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po'; + +describe('Charts', { testIsolation: 'off', tags: ['@charts', '@adminUser'] }, () => { + before(() => { + cy.login(); + HomePagePo.goTo(); + }); + + describe('CIS Benchmark install', () => { + const installChartPage = new InstallChartPage(); + const chartPage = new ChartPage(); + + describe('YAML view', () => { + beforeEach(() => { + ChartPage.navTo(null, 'CIS Benchmark'); + chartPage.waitForChartHeader('CIS Benchmark', MEDIUM_TIMEOUT_OPT); + chartPage.goToInstall(); + installChartPage.nextPage().editYaml(); + }); + + describe('UI Elements', () => { + it('Footer controls should sticky to bottom', () => { + cy.get('#wizard-footer-controls').should('be.visible'); + + cy.get('#wizard-footer-controls').then(($el) => { + const elementRect = $el[0].getBoundingClientRect(); + const viewportHeight = Cypress.config('viewportHeight'); + const pageHeight = Cypress.$(cy.state('window')).height(); + + expect(elementRect.bottom).to.eq(pageHeight); + expect(elementRect.bottom).to.eq(viewportHeight); + }); + }); + }); + }); + + describe('CIS Chart setup', () => { + it('Complete install and a Scan is created', () => { + cy.updateNamespaceFilter('local', 'none', '{"local":[]}'); + const kubectl = new Kubectl(); + const cisBenchmark = new CisBenchmarkPo(); + const cisBenchmarkList = new CisBenchmarkListPo(); + const sideNav = new ProductNavPo(); + + ChartPage.navTo(null, 'CIS Benchmark'); + chartPage.waitForChartHeader('CIS Benchmark', MEDIUM_TIMEOUT_OPT); + chartPage.goToInstall(); + + installChartPage.nextPage(); + + cy.intercept('POST', 'v1/catalog.cattle.io.clusterrepos/rancher-charts?action=install').as('chartInstall'); + installChartPage.installChart(); + cy.wait('@chartInstall').its('response.statusCode').should('eq', 201); + cy.contains('Disconnected'); + + kubectl.closeTerminal(); + + sideNav.navToSideMenuGroupByLabel('CIS Benchmark'); + cisBenchmarkList.waitForPage(); + cisBenchmarkList.createScan(); + cisBenchmark.waitForPage(); + cisBenchmark.cruResource().saveAndWaitForRequests('POST', 'v1/cis.cattle.io.clusterscans') + .then(({ response }) => { + expect(response?.statusCode).to.eq(201); + expect(response?.body).to.have.property('type', 'cis.cattle.io.clusterscan'); + expect(response?.body.metadata).to.have.property('name'); + expect(response?.body.metadata).to.have.property('generateName', 'scan-'); + }); + cisBenchmarkList.waitForPage(); + cisBenchmarkList.checkVisible(); + const column = cisBenchmarkList.firstRow().column(1); + + column.get('.bg-success', LONG_TIMEOUT_OPT).should('exist'); + }); + + after('clean up', () => { + const chartNamespace = 'cis-operator-system'; + const chartApp = 'rancher-cis-benchmark'; + const chartCrd = 'rancher-cis-benchmark-crd'; + + cy.createRancherResource('v1', `catalog.cattle.io.apps/${ chartNamespace }/${ chartApp }?action=uninstall`, '{}'); + cy.createRancherResource('v1', `catalog.cattle.io.apps/${ chartNamespace }/${ chartCrd }?action=uninstall`, '{}'); + cy.updateNamespaceFilter('local', 'none', '{"local":["all://user"]}'); + }); + }); + }); +}); diff --git a/cypress/e2e/tests/pages/explorer/apps/charts.spec.ts b/cypress/e2e/tests/pages/explorer/apps/charts.spec.ts index 20e89ef0ef..d42b0c1742 100644 --- a/cypress/e2e/tests/pages/explorer/apps/charts.spec.ts +++ b/cypress/e2e/tests/pages/explorer/apps/charts.spec.ts @@ -142,4 +142,29 @@ describe('Apps/Charts', { tags: ['@explorer', '@adminUser'] }, () => { cy.get('@fetchChartDataAfterBack.all').should('have.length', 0); }); + + it('A disabled repo should NOT be listed on the repos dropdown', () => { + const disabledRepoId = 'disabled-repo'; + + cy.intercept('GET', '/v1/catalog.cattle.io.clusterrepos?exclude=metadata.managedFields', (req) => { + req.reply({ + statusCode: 200, + body: { + data: [ + { id: disabledRepoId, spec: { enabled: false } }, // disabled + { id: 'enabled-repo-1', spec: { enabled: true } }, // enabled + { id: 'enabled-repo-2', spec: {} } // enabled + ] + } + }); + }).as('getRepos'); + + cy.wait('@getRepos'); + + chartsPage.chartsFilterReposSelect().toggle(); + chartsPage.chartsFilterReposSelect().isOpened(); + chartsPage.chartsFilterReposSelect().getOptions().should('have.length', 3); // should include three options: All, enabled-repo-1 and enabled-repo-2 + chartsPage.chartsFilterReposSelect().getOptions().contains(disabledRepoId) + .should('not.exist'); + }); }); diff --git a/cypress/e2e/tests/pages/explorer/policy/network-policy.spec.ts b/cypress/e2e/tests/pages/explorer/policy/network-policy.spec.ts index 1488f7596d..d588df84f1 100644 --- a/cypress/e2e/tests/pages/explorer/policy/network-policy.spec.ts +++ b/cypress/e2e/tests/pages/explorer/policy/network-policy.spec.ts @@ -1,80 +1,126 @@ import { NetworkPolicyPagePo } from '@/cypress/e2e/po/pages/explorer/network-policy.po'; -import NetworkPolicyPo from '@/cypress/e2e/po/components/policy/network-policy.po'; import PromptRemove from '@/cypress/e2e/po/prompts/promptRemove.po'; const networkPolicyPage = new NetworkPolicyPagePo('local'); -const networkPolicyName = 'custom-network-policy'; +const customNetworkPolicyName = 'custom-network-policy'; const networkPolicyDescription = 'Custom Network Policy Description'; +const namespace = 'default'; +let networkPolicyName = ''; +let removeNetworkPolicy = false; +const networkPolicyToDelete = []; describe('NetworkPolicies', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { before(() => { cy.login(); + cy.createE2EResourceName('networkpolicy').then((name) => { + networkPolicyName = name; + }); }); it('creates a network policy and displays it in the list', () => { // Visit the main menu and select the 'local' cluster // Navigate to Policy => Network Policies NetworkPolicyPagePo.navTo(); + networkPolicyPage.waitForPage(); // Go to Create Page networkPolicyPage.clickCreate(); - const networkPolicyPo = new NetworkPolicyPo(); // Enter name & description - networkPolicyPo.nameInput().set(networkPolicyName); - networkPolicyPo.descriptionInput().set(networkPolicyDescription); + networkPolicyPage.createEditNetworkPolicyForm().nameInput().set(customNetworkPolicyName); + networkPolicyPage.createEditNetworkPolicyForm().descriptionInput().set(networkPolicyDescription); // Enable ingress checkbox - networkPolicyPo.enableIngressCheckbox().set(); + networkPolicyPage.createEditNetworkPolicyForm().enableIngressCheckbox().set(); // Add a new rule without a key to match all the namespaces - networkPolicyPo.newNetworkPolicyRuleAddBtn().click(); - networkPolicyPo.addAllowedTrafficSourceButton().click(); - networkPolicyPo.policyRuleTargetSelect(0).toggle(); - networkPolicyPo.policyRuleTargetSelect(0).clickOptionWithLabel('Namespace Selector'); + networkPolicyPage.createEditNetworkPolicyForm().newNetworkPolicyRuleAddBtn().click(); + networkPolicyPage.createEditNetworkPolicyForm().addAllowedTrafficSourceButton().click(); + networkPolicyPage.createEditNetworkPolicyForm().policyRuleTargetSelect(0).toggle(); + networkPolicyPage.createEditNetworkPolicyForm().policyRuleTargetSelect(0).clickOptionWithLabel('Namespace Selector'); cy.getRancherResource('v1', 'namespaces').then((resp: Cypress.Response) => { cy.wrap(resp.body.count).as('namespaceCount'); }); cy.get('@namespaceCount').then((count) => { - networkPolicyPo.matchingNamespacesMessage(0).should('contain.text', `Matches ${ count } of ${ count }`); + networkPolicyPage.createEditNetworkPolicyForm().matchingNamespacesMessage(0).should('contain.text', `Matches ${ count } of ${ count }`); // Add a second rule a key to match none of the namespaces - networkPolicyPo.addAllowedTrafficSourceButton().click(); - networkPolicyPo.policyRuleTargetSelect(1).toggle(); - networkPolicyPo.policyRuleTargetSelect(1).clickOptionWithLabel('Namespace Selector'); - networkPolicyPo.policyRuleKeyInput(1).focus().type('something-with-no-matching-namespaces'); - networkPolicyPo.matchingNamespacesMessage(1).should('contain.text', `Matches 0 of ${ count }`); + networkPolicyPage.createEditNetworkPolicyForm().addAllowedTrafficSourceButton().click(); + networkPolicyPage.createEditNetworkPolicyForm().policyRuleTargetSelect(1).toggle(); + networkPolicyPage.createEditNetworkPolicyForm().policyRuleTargetSelect(1).clickOptionWithLabel('Namespace Selector'); + networkPolicyPage.createEditNetworkPolicyForm().policyRuleKeyInput(1).focus().type('something-with-no-matching-namespaces'); + networkPolicyPage.createEditNetworkPolicyForm().matchingNamespacesMessage(1).should('contain.text', `Matches 0 of ${ count }`); // Click on Create - networkPolicyPo.saveCreateForm().click(); + networkPolicyPage.createEditNetworkPolicyForm().saveCreateForm().click(); // Check if the NetworkPolicy is created successfully networkPolicyPage.waitForPage(); - networkPolicyPage.searchForNetworkPolicy(networkPolicyName); - networkPolicyPage.waitForPage(`q=${ networkPolicyName }`); - networkPolicyPage.listElementWithName(networkPolicyName).should('exist').and('be.visible'); + networkPolicyPage.searchForNetworkPolicy(customNetworkPolicyName); + networkPolicyPage.waitForPage(`q=${ customNetworkPolicyName }`); + networkPolicyPage.listElementWithName(customNetworkPolicyName).should('exist').and('be.visible'); // Navigate back to the edit page and check if the matching message is still correct - networkPolicyPage.list().actionMenu(networkPolicyName).getMenuItem('Edit Config').click(); - networkPolicyPo.matchingNamespacesMessage(0).should('contain.text', `Matches ${ count } of ${ count }`); - networkPolicyPo.matchingNamespacesMessage(1).should('contain.text', `Matches 0 of ${ count }`); + networkPolicyPage.list().actionMenu(customNetworkPolicyName).getMenuItem('Edit Config').click(); + networkPolicyPage.createEditNetworkPolicyForm().matchingNamespacesMessage(0).should('contain.text', `Matches ${ count } of ${ count }`); + networkPolicyPage.createEditNetworkPolicyForm().matchingNamespacesMessage(1).should('contain.text', `Matches 0 of ${ count }`); }); }); it('can open "Edit as YAML"', () => { NetworkPolicyPagePo.navTo(); + networkPolicyPage.waitForPage(); networkPolicyPage.clickCreate(); - const networkPolicyPo = new NetworkPolicyPo(); - networkPolicyPo.editAsYaml().click(); - networkPolicyPo.yamlEditor().checkExists(); + networkPolicyPage.createEditNetworkPolicyForm().editAsYaml().click(); + networkPolicyPage.createEditNetworkPolicyForm().yamlEditor().checkExists(); + }); + + // testing https://github.com/rancher/dashboard/issues/11856 + it('port value is sent correctly in request payload', () => { + cy.intercept('POST', 'v1/networking.k8s.io.networkpolicies').as('createNetworkPolicy'); + + NetworkPolicyPagePo.navTo(); + networkPolicyPage.waitForPage(); + networkPolicyPage.clickCreate(); + networkPolicyPage.createEditNetworkPolicyForm().waitForPage(null, 'ingress'); + + networkPolicyPage.createEditNetworkPolicyForm().nameInput().set(networkPolicyName); + networkPolicyPage.createEditNetworkPolicyForm().descriptionInput().set(networkPolicyDescription); + networkPolicyPage.createEditNetworkPolicyForm().enableIngressCheckbox().set(); + networkPolicyPage.createEditNetworkPolicyForm().newNetworkPolicyRuleAddBtn().click(); + networkPolicyPage.createEditNetworkPolicyForm().addAllowedPortButton().click(); + networkPolicyPage.createEditNetworkPolicyForm().ingressRuleItem(0).find('.col:nth-of-type(1) input').type('8080'); + networkPolicyPage.createEditNetworkPolicyForm().saveCreateForm().click(); + + // check request payload + cy.wait('@createNetworkPolicy').then(({ request, response }) => { + expect(response?.statusCode).to.eq(201); + removeNetworkPolicy = true; + networkPolicyToDelete.push(`${ namespace }/${ networkPolicyName }`); + expect(request?.body.spec.ingress).to.deep.include({ ports: [{ port: 8080 }] }); + }); + networkPolicyPage.waitForPage(); + networkPolicyPage.searchForNetworkPolicy(networkPolicyName); + networkPolicyPage.waitForPage(`q=${ networkPolicyName }`); + networkPolicyPage.list().actionMenu(networkPolicyName).getMenuItem('Edit Config').click(); + networkPolicyPage.createEditNetworkPolicyForm(namespace, networkPolicyName).waitForPage(`mode=edit#rule-ingress0`); + // check elements value property + networkPolicyPage.createEditNetworkPolicyForm().ingressRuleItem(0).find('.col:nth-of-type(1) input').should('have.value', '8080'); }); it('can delete a network policy', () => { NetworkPolicyPagePo.navTo(); networkPolicyPage.waitForPage(); - networkPolicyPage.list().actionMenu(networkPolicyName).getMenuItem('Delete').click(); + networkPolicyPage.list().actionMenu(customNetworkPolicyName).getMenuItem('Delete').click(); const promptRemove = new PromptRemove(); cy.intercept('DELETE', 'v1/networking.k8s.io.networkpolicies/**').as('deleteNetworkPolicy'); promptRemove.remove(); cy.wait('@deleteNetworkPolicy'); networkPolicyPage.waitForPage(); - cy.contains(networkPolicyName).should('not.exist'); + cy.contains(customNetworkPolicyName).should('not.exist'); + }); + + after(() => { + if (removeNetworkPolicy) { + // delete gitrepo + networkPolicyToDelete.forEach((r) => cy.deleteRancherResource('v1', 'networking.k8s.io.networkpolicies', r, false)); + } }); }); 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 43aa56c97e..8f6e7c4bec 100644 --- a/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts +++ b/cypress/e2e/tests/pages/explorer/service-discovery/services.spec.ts @@ -1,13 +1,147 @@ 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'; +import PromptRemove from '@/cypress/e2e/po/prompts/promptRemove.po'; +import { GrowlManagerPo } from '@/cypress/e2e/po/components/growl-manager.po'; -const cluster = 'local'; const servicesPagePo = new ServicesPagePo(); +const growlPo = new GrowlManagerPo(); +const cluster = 'local'; +let serviceExternalName = ''; +const namespace = 'default'; +let removeServices = false; +const servicesToDelete = []; describe('Services', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { before(() => { cy.login(); + cy.createE2EResourceName('serviceexternalname').then((name) => { + serviceExternalName = name; + }); + }); + + describe('CRUD', () => { + it('can create an ExternalName Service', () => { + cy.intercept('POST', '/v1/services').as('createService'); + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.clickCreate(); + servicesPagePo.createServicesForm().waitForPage(); + + servicesPagePo.createServicesForm().selectServiceOption(1); + servicesPagePo.createServicesForm().waitForPage(null, 'define-external-name'); + servicesPagePo.createServicesForm().resourceDetail().title().should('contain', 'Create ExternalName'); + servicesPagePo.createServicesForm().nameNsDescription().name().set(serviceExternalName); + servicesPagePo.createServicesForm().nameNsDescription().description().set(`${ serviceExternalName }-desc`); + servicesPagePo.createServicesForm().selectNamespace(namespace); + servicesPagePo.createServicesForm().tabs().allTabs().should('have.length', 3); + + const tabs = ['External Name', 'IP Addresses', 'Labels & Annotations']; + + servicesPagePo.createServicesForm().tabs().tabNames().each((el, i) => { + expect(el).to.eq(tabs[i]); + }); + + servicesPagePo.createServicesForm().tabs().assertTabIsActive('[data-testid="define-external-name"]'); + servicesPagePo.createServicesForm().externalNameInput().set('my.database.example.com'); + servicesPagePo.createServicesForm().ipAddressesTab(); + servicesPagePo.createServicesForm().waitForPage(null, 'ips'); + servicesPagePo.createServicesForm().ipAddressList().setValueAtIndex('1.1.1.1', 0); + servicesPagePo.createServicesForm().ipAddressList().setValueAtIndex('2.2.2.2', 1); + servicesPagePo.createServicesForm().lablesAnnotationsTab(); + servicesPagePo.createServicesForm().waitForPage(null, 'labels-and-annotations'); + servicesPagePo.createServicesForm().lablesAnnotationsKeyValue().setKeyValueAtIndex('Add Label', 'label-key1', 'label-value1', 0, '.labels-and-annotations-container div.row:nth-of-type(2)'); + + // Adding Annotations doesn't work via test automation + // See https://github.com/rancher/dashboard/issues/13191 + // servicesPagePo.createServicesForm().lablesAnnotationsKeyValue().setKeyValueAtIndex('Add Annotation', 'ann-key1', 'ann-value1', 0, '.labels-and-annotations-container div.row:nth-of-type(3)'); + servicesPagePo.createServicesForm().resourceDetail().createEditView().create(); + cy.wait('@createService').then(({ response }) => { + expect(response?.statusCode).to.eq(201); + removeServices = true; + servicesToDelete.push(`${ namespace }/${ serviceExternalName }`); + }); + servicesPagePo.waitForPage(); + servicesPagePo.list().resourceTable().sortableTable().rowWithName(serviceExternalName) + .checkVisible(); + growlPo.dismissWarning(); + }); + + it('can edit an ExternalName Service', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(serviceExternalName).getMenuItem('Edit Config').click(); + servicesPagePo.createServicesForm(namespace, serviceExternalName).waitForPage('mode=edit', 'define-external-name'); + servicesPagePo.createServicesForm().nameNsDescription().description().set(`${ serviceExternalName }-desc`); + servicesPagePo.createServicesForm().resourceDetail().cruResource().saveAndWaitForRequests('PUT', `/v1/services/${ namespace }/${ serviceExternalName }`) + .then(({ response }) => { + expect(response?.statusCode).to.eq(200); + expect(response?.body.metadata).to.have.property('name', serviceExternalName); + expect(response?.body.metadata.annotations).to.have.property('field.cattle.io/description', `${ serviceExternalName }-desc`); + }); + servicesPagePo.waitForPage(); + growlPo.dismissWarning(); + }); + + it('can clone an ExternalName Service', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(serviceExternalName).getMenuItem('Clone').click(); + servicesPagePo.createServicesForm(namespace, serviceExternalName).waitForPage('mode=clone', 'define-external-name'); + servicesPagePo.createServicesForm().nameNsDescription().name().set(`clone-${ serviceExternalName }`); + servicesPagePo.createServicesForm().resourceDetail().cruResource().saveAndWaitForRequests('POST', '/v1/services') + .then(({ response }) => { + expect(response?.statusCode).to.eq(201); + expect(response?.body.metadata).to.have.property('name', `clone-${ serviceExternalName }`); + removeServices = true; + servicesToDelete.push(`${ namespace }/clone-${ serviceExternalName }`); + }); + servicesPagePo.waitForPage(); + servicesPagePo.list().resourceTable().sortableTable().rowWithName(`clone-${ serviceExternalName }`) + .checkVisible(); + growlPo.dismissWarning(); + }); + + it('can Edit Yaml', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(`clone-${ serviceExternalName }`).getMenuItem('Edit YAML').click(); + servicesPagePo.createServicesForm(namespace, `clone-${ serviceExternalName }`).waitForPage('mode=edit&as=yaml'); + servicesPagePo.createServicesForm().title().contains(`Service: clone-${ serviceExternalName }`).should('be.visible'); + }); + + it('can delete an ExternalName Service', () => { + ServicesPagePo.navTo(); + servicesPagePo.waitForPage(); + servicesPagePo.list().actionMenu(`clone-${ serviceExternalName }`).getMenuItem('Delete').click(); + servicesPagePo.list().resourceTable().sortableTable().rowNames('.col-link-detail') + .then((rows: any) => { + const promptRemove = new PromptRemove(); + + cy.intercept('DELETE', `/v1/services/${ namespace }/clone-${ serviceExternalName }`).as('deleteService'); + + promptRemove.remove(); + cy.wait('@deleteService'); + servicesPagePo.waitForPage(); + servicesPagePo.list().resourceTable().sortableTable().checkRowCount(false, rows.length - 1); + servicesPagePo.list().resourceTable().sortableTable().rowNames('.col-link-detail') + .should('not.contain', `clone-${ serviceExternalName }`); + }); + }); + + // testing https://github.com/rancher/dashboard/issues/11889 + it('validation errors should not be shown when form is just opened', () => { + servicesPagePo.goTo(); + servicesPagePo.clickCreate(); + servicesPagePo.createServicesForm().errorBanner().should('not.exist'); + }); + + after(() => { + if (removeServices) { + // delete gitrepo + servicesToDelete.forEach((r) => cy.deleteRancherResource('v1', 'services', r, false)); + } + }); }); describe('List', { tags: ['@vai', '@adminUser'] }, () => { @@ -84,12 +218,6 @@ describe('Services', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] } servicesPagePo.list().resourceTable().sortableTable().checkRowCount(false, 3); }); - it('validation errors should not be shown when form is just opened', () => { - servicesPagePo.goTo(); - servicesPagePo.clickCreate(); - servicesPagePo.createServicesForm().errorBanner().should('not.exist'); - }); - after('clean up', () => { cy.updateNamespaceFilter(cluster, 'none', '{"local":["all://user"]}'); }); diff --git a/cypress/e2e/tests/pages/extensions/extensions.spec.ts b/cypress/e2e/tests/pages/extensions/extensions.spec.ts index f208c05fc6..da074f6138 100644 --- a/cypress/e2e/tests/pages/extensions/extensions.spec.ts +++ b/cypress/e2e/tests/pages/extensions/extensions.spec.ts @@ -195,6 +195,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { // Ensure that the banner should be shown (by confirming that a required repo isn't there) appRepoList.goTo(); appRepoList.waitForPage(); + appRepoList.sortableTable().checkLoadingIndicatorNotVisible(); appRepoList.sortableTable().noRowsShouldNotExist(); appRepoList.sortableTable().rowNames().then((names: any) => { if (names.includes(UI_PLUGINS_PARTNERS_REPO_NAME)) { @@ -409,6 +410,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { extensionsPo.extensionTabAvailableClick(); extensionsPo.waitForPage(null, 'available'); + extensionsPo.loading().should('not.exist'); // Install unauthenticated extension extensionsPo.extensionCardInstallClick(UNAUTHENTICATED_EXTENSION_NAME); @@ -418,6 +420,8 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { // let's check the extension reload banner and reload the page extensionsPo.extensionReloadBanner().should('be.visible'); extensionsPo.extensionReloadClick(); + extensionsPo.waitForPage(null, 'installed'); + extensionsPo.loading().should('not.exist'); // make sure both extensions have been imported extensionsPo.extensionScriptImport(UNAUTHENTICATED_EXTENSION_NAME).should('exist'); @@ -436,7 +440,8 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => { // make sure both extensions have been imported after logging in again cy.login(undefined, undefined, false); extensionsPo.goTo(); - extensionsPo.waitForPage(); + extensionsPo.waitForPage(null, 'installed'); + extensionsPo.loading().should('not.exist'); extensionsPo.waitForTitle(); extensionsPo.extensionScriptImport(UNAUTHENTICATED_EXTENSION_NAME).should('exist'); extensionsPo.extensionScriptImport(EXTENSION_NAME).should('exist'); diff --git a/cypress/e2e/tests/pages/fleet/advanced/workspaces.spec.ts b/cypress/e2e/tests/pages/fleet/advanced/workspaces.spec.ts index d74342d972..3d3ca200dc 100644 --- a/cypress/e2e/tests/pages/fleet/advanced/workspaces.spec.ts +++ b/cypress/e2e/tests/pages/fleet/advanced/workspaces.spec.ts @@ -18,7 +18,7 @@ describe('Workspaces', { testIsolation: 'off', tags: ['@fleet', '@adminUser'] }, let initialCount: number; it('check table headers are available in list and details view', () => { - FleetWorkspaceListPagePo.navTo(); + fleetWorkspacesPage.goTo(); fleetWorkspacesPage.waitForPage(); fleetWorkspacesPage.sortableTable().noRowsShouldNotExist(); fleetWorkspacesPage.sortableTable().filter(defaultWorkspace); diff --git a/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts b/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts index 12eca5f170..b9e4001144 100644 --- a/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts +++ b/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts @@ -70,7 +70,8 @@ describe('Fleet Clusters', { tags: ['@fleet', '@adminUser'] }, () => { rke2ClusterAmazon: { clusterName: name, namespace, - } + }, + metadata: { labels: { foo: 'bar' } } }).then(() => { removeCluster = true; }); @@ -123,6 +124,8 @@ describe('Fleet Clusters', { tags: ['@fleet', '@adminUser'] }, () => { fleetClusterListPage.clusterList().details(clusterName, 4).should('have.text', '1'); // check resources: testing https://github.com/rancher/dashboard/issues/11154 fleetClusterListPage.clusterList().details(clusterName, 5).contains( ' 1 ', MEDIUM_TIMEOUT_OPT); + // check cluster labels + fleetClusterListPage.clusterList().subRows().should('contain.text', 'foo=bar'); const fleetClusterDetailsPage = new FleetClusterDetailsPo(namespace, clusterName); diff --git a/cypress/e2e/tests/pages/global-settings/peformance.spec.ts b/cypress/e2e/tests/pages/global-settings/performance.spec.ts similarity index 97% rename from cypress/e2e/tests/pages/global-settings/peformance.spec.ts rename to cypress/e2e/tests/pages/global-settings/performance.spec.ts index 421fada386..e57aca64d4 100644 --- a/cypress/e2e/tests/pages/global-settings/peformance.spec.ts +++ b/cypress/e2e/tests/pages/global-settings/performance.spec.ts @@ -144,12 +144,18 @@ describe('Performance', { testIsolation: 'off', tags: ['@globalSettings', '@admi performancePage.garbageCollectionCheckbox().isUnchecked(); performancePage.garbageCollectionCheckbox().set(); performancePage.garbageCollectionCheckbox().isChecked(); + // testing https://github.com/rancher/dashboard/issues/11856 + performancePage.garbageCollectionResourceCount().set('600'); performancePage.applyAndWait('garbageCollection-true').then(({ request, response }) => { expect(response?.statusCode).to.eq(200); expect(request.body).to.have.property('value').contains('\"garbageCollection\":{\"enabled\":true'); expect(response?.body).to.have.property('value').contains('\"garbageCollection\":{\"enabled\":true'); + expect(response?.body).to.have.property('value').contains('\"countThreshold\":600'); }); + // check elements value property + performancePage.garbageCollectionResourceCount().shouldHaveValue('600'); + // Disable garbage collection performancePage.garbageCollectionCheckbox().isChecked(); performancePage.garbageCollectionCheckbox().set(); diff --git a/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts b/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts index 1a678bb191..491617ea6a 100644 --- a/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts +++ b/cypress/e2e/tests/pages/manager/cluster-manager.spec.ts @@ -10,7 +10,7 @@ import ClusterManagerDetailImportedGenericPagePo from '@/cypress/e2e/po/detail/p import ClusterManagerCreateRke2CustomPagePo from '@/cypress/e2e/po/edit/provisioning.cattle.io.cluster/create/cluster-create-rke2-custom.po'; import ClusterManagerEditRke2CustomPagePo from '@/cypress/e2e/po/edit/provisioning.cattle.io.cluster/edit/cluster-edit-rke2-custom.po'; import ClusterManagerImportGenericPagePo from '@/cypress/e2e/po/edit/provisioning.cattle.io.cluster/import/cluster-import.generic.po'; -import ClusterManagerEditGenericPagePo from '@/cypress/e2e/po/edit/provisioning.cattle.io.cluster/edit/cluster-edit-generic.po'; +import ClusterManagerEditImportedPagePo from '@/cypress/e2e/po/extensions/imported/cluster-edit.po'; import ClusterManagerNamespacePagePo from '@/cypress/e2e/po/pages/cluster-manager/namespace.po'; import PromptRemove from '@/cypress/e2e/po/prompts/promptRemove.po'; import * as path from 'path'; @@ -563,9 +563,11 @@ describe('Cluster Manager', { testIsolation: 'off', tags: ['@manager', '@adminUs describe('Imported', { tags: ['@jenkins', '@importedCluster'] }, () => { const importClusterPage = new ClusterManagerImportGenericPagePo(); + const fqdn = 'fqdn'; + const cacert = 'cacert'; describe('Generic', () => { - const editImportedClusterPage = new ClusterManagerEditGenericPagePo(undefined, importGenericName); + const editImportedClusterPage = new ClusterManagerEditImportedPagePo(undefined, importGenericName); it('can create new cluster', () => { const detailClusterPage = new ClusterManagerDetailImportedGenericPagePo(undefined, importGenericName); @@ -614,11 +616,30 @@ describe('Cluster Manager', { testIsolation: 'off', tags: ['@manager', '@adminUs clusterList.list().providerSubType(importGenericName).should('contain.text', 'K3s'); }); - it('can navigate to cluster edit page', () => { + it('can edit imported cluster and see changes afterwards', () => { + cy.intercept('GET', '/v1-rke2-release/releases').as('getRke2Releases'); clusterList.goTo(); + clusterList.list().actionMenu(importGenericName).getMenuItem('Edit Config').click(); + editImportedClusterPage.waitForPage('mode=edit'); + + editImportedClusterPage.name().value().should('eq', importGenericName ); + // Issue #10432: Edit Cluster screen falsely gives impression imported cluster's name and description can be edited + editImportedClusterPage.name().expectToBeDisabled(); + + editImportedClusterPage.ace().enable(); + editImportedClusterPage.ace().enterFdqn(fqdn); + editImportedClusterPage.ace().enterCaCerts(cacert); + + editImportedClusterPage.save(); + + // We should be taken back to the list page if the save was successful + clusterList.waitForPage(); + clusterList.list().actionMenu(importGenericName).getMenuItem('Edit Config').click(); editImportedClusterPage.waitForPage('mode=edit'); + editImportedClusterPage.ace().fqdn().value().should('eq', fqdn ); + editImportedClusterPage.ace().caCerts().value().should('eq', cacert ); }); it('can delete cluster by bulk actions', () => { diff --git a/cypress/e2e/tests/pages/manager/repositories.spec.ts b/cypress/e2e/tests/pages/manager/repositories.spec.ts index e6b8d7b91c..a7cf99fd78 100644 --- a/cypress/e2e/tests/pages/manager/repositories.spec.ts +++ b/cypress/e2e/tests/pages/manager/repositories.spec.ts @@ -6,6 +6,7 @@ import * as jsyaml from 'js-yaml'; import { LONG_TIMEOUT_OPT } from '@/cypress/support/utils/timeouts'; const chartBranch = `release-v${ CURRENT_RANCHER_VERSION }`; +const gitRepoUrl = 'https://github.com/rancher/charts'; describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: ['@manager', '@adminUser'] }, () => { const repositoriesPage = new ChartRepositoriesPagePo(undefined, 'manager'); @@ -27,7 +28,7 @@ describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: [ repositoriesPage.createEditRepositories().nameNsDescription().name().set(this.repoName); repositoriesPage.createEditRepositories().nameNsDescription().description().set(`${ this.repoName }-description`); repositoriesPage.createEditRepositories().repoRadioBtn().set(1); - repositoriesPage.createEditRepositories().gitRepoUrl().set('https://github.com/rancher/charts'); + repositoriesPage.createEditRepositories().gitRepoUrl().set(gitRepoUrl); repositoriesPage.createEditRepositories().gitBranch().set(chartBranch); repositoriesPage.createEditRepositories().saveAndWaitForRequests('POST', '/v1/catalog.cattle.io.clusterrepos').its('response.statusCode').should('eq', 201); repositoriesPage.waitForPage(); @@ -129,7 +130,7 @@ describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: [ repositoriesPage.createEditRepositories().nameNsDescription().name().set(`${ this.repoName }basic`); repositoriesPage.createEditRepositories().nameNsDescription().description().set(`${ this.repoName }-description`); repositoriesPage.createEditRepositories().repoRadioBtn().set(1); - repositoriesPage.createEditRepositories().gitRepoUrl().set('https://github.com/rancher/charts'); + repositoriesPage.createEditRepositories().gitRepoUrl().set(gitRepoUrl); repositoriesPage.createEditRepositories().gitBranch().set(chartBranch); repositoriesPage.createEditRepositories().clusterRepoAuthSelectOrCreate().createBasicAuth('test', 'test'); repositoriesPage.createEditRepositories().saveAndWaitForRequests('POST', '/v1/catalog.cattle.io.clusterrepos'); @@ -148,7 +149,7 @@ describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: [ repositoriesPage.createEditRepositories().nameNsDescription().name().set(`${ this.repoName }ssh`); repositoriesPage.createEditRepositories().nameNsDescription().description().set(`${ this.repoName }-description`); repositoriesPage.createEditRepositories().repoRadioBtn().set(1); - repositoriesPage.createEditRepositories().gitRepoUrl().set('https://github.com/rancher/charts'); + repositoriesPage.createEditRepositories().gitRepoUrl().set(gitRepoUrl); repositoriesPage.createEditRepositories().gitBranch().set(chartBranch); repositoriesPage.createEditRepositories().clusterRepoAuthSelectOrCreate().createSSHAuth('privateKey', 'publicKey'); repositoriesPage.createEditRepositories().saveAndWaitForRequests('POST', '/v1/catalog.cattle.io.clusterrepos'); @@ -254,4 +255,55 @@ describe('Cluster Management Helm Repositories', { testIsolation: 'off', tags: [ // check list details cy.contains(this.repoName).should('not.exist'); }); + + it('can disable/enable a repository', function() { + // create repo + ChartRepositoriesPagePo.navTo(); + repositoriesPage.waitForPage(); + repositoriesPage.create(); + repositoriesPage.createEditRepositories().waitForPage(); + repositoriesPage.createEditRepositories().nameNsDescription().name().set(this.repoName); + repositoriesPage.createEditRepositories().nameNsDescription().description().set(`${ this.repoName }-description`); + repositoriesPage.createEditRepositories().repoRadioBtn().set(1); + repositoriesPage.createEditRepositories().gitRepoUrl().set(gitRepoUrl); + repositoriesPage.createEditRepositories().gitBranch().set(chartBranch); + repositoriesPage.createEditRepositories().saveAndWaitForRequests('POST', '/v1/catalog.cattle.io.clusterrepos').its('response.statusCode').should('eq', 201); + repositoriesPage.waitForPage(); + + // check list details + repositoriesPage.list().details(this.repoName, 2).should('be.visible'); + repositoriesPage.list().details(this.repoName, 1).contains('In Progress').should('be.visible'); + + // refresh should be displayed for an enabled repo + repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Refresh').should('be.visible'); + // close action menu + repositoriesPage.list().closeActionMenu(); + // disable repo + repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Disable').click(); + repositoriesPage.list().details(this.repoName, 1).contains('Disabled', { timeout: 10000 }).scrollIntoView() + .should('be.visible'); + + // refresh should NOT be displayed for a disabled repo + repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Refresh').should('not.exist'); + // close action menu + repositoriesPage.list().closeActionMenu(); + // enable repo + repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Enable').click(); + repositoriesPage.list().details(this.repoName, 1).contains('Active', LONG_TIMEOUT_OPT).scrollIntoView() + .should('be.visible'); + + // delete repo + repositoriesPage.list().actionMenu(this.repoName).getMenuItem('Delete').click(); + + const promptRemove = new PromptRemove(); + + cy.intercept('DELETE', `v1/catalog.cattle.io.clusterrepos/${ this.repoName }`).as('deleteRepository'); + + promptRemove.remove(); + cy.wait('@deleteRepository'); + repositoriesPage.waitForPage(); + + // check list details + cy.contains(this.repoName).should('not.exist'); + }); }); diff --git a/cypress/globals.d.ts b/cypress/globals.d.ts index 060c3b6e1e..81b932c271 100644 --- a/cypress/globals.d.ts +++ b/cypress/globals.d.ts @@ -29,7 +29,7 @@ export type CreateAmazonRke2ClusterParams = { type: string, clusterName: string, namespace: string -}, + }, cloudCredentialsAmazon: { workspace: string, name: string, @@ -40,7 +40,11 @@ export type CreateAmazonRke2ClusterParams = { rke2ClusterAmazon: { clusterName: string, namespace: string, - } + }, + metadata?: { + labels?: { [key: string]: string }, + annotations?: { [key: string]: string }, + }, } export type CreateAmazonRke2ClusterWithoutMachineConfigParams = { cloudCredentialsAmazon: { diff --git a/cypress/jenkins/Jenkinsfile b/cypress/jenkins/Jenkinsfile index f1206e1814..f52de682f6 100644 --- a/cypress/jenkins/Jenkinsfile +++ b/cypress/jenkins/Jenkinsfile @@ -27,7 +27,9 @@ node { string(credentialsId: 'AWS_SECRET_ACCESS_KEY', variable: 'AWS_SECRET_ACCESS_KEY'), string(credentialsId: 'AZURE_AKS_SUBSCRIPTION_ID', variable: 'AZURE_AKS_SUBSCRIPTION_ID'), string(credentialsId: 'AZURE_CLIENT_ID', variable: 'AZURE_CLIENT_ID'), - string(credentialsId: 'AZURE_CLIENT_SECRET', variable: 'AZURE_CLIENT_SECRET')]) { + string(credentialsId: 'AZURE_CLIENT_SECRET', variable: 'AZURE_CLIENT_SECRET'), + string(credentialsId: 'GKE_SERVICE_ACCOUNT', variable: 'GKE_SERVICE_ACCOUNT') + ]) { withEnv(paramsMap) { stage('Checkout') { deleteDir() diff --git a/cypress/jenkins/cypress.config.jenkins.ts b/cypress/jenkins/cypress.config.jenkins.ts index 444b035c69..7b9455daa9 100644 --- a/cypress/jenkins/cypress.config.jenkins.ts +++ b/cypress/jenkins/cypress.config.jenkins.ts @@ -86,7 +86,8 @@ export default defineConfig({ azureClientId: process.env.AZURE_CLIENT_ID, azureClientSecret: process.env.AZURE_CLIENT_SECRET, customNodeIp: process.env.CUSTOM_NODE_IP, - customNodeKey: process.env.CUSTOM_NODE_KEY + customNodeKey: process.env.CUSTOM_NODE_KEY, + gkeServiceAccount: process.env.GKE_SERVICE_ACCOUNT }, // Jenkins reporters configuration jUnit and HTML reporter: 'cypress-multi-reporters', diff --git a/cypress/jenkins/init.sh b/cypress/jenkins/init.sh index e1d7a22356..317aa3b1c9 100755 --- a/cypress/jenkins/init.sh +++ b/cypress/jenkins/init.sh @@ -93,6 +93,7 @@ corral config vars set azure_subscription_id "${AZURE_AKS_SUBSCRIPTION_ID}" corral config vars set azure_client_id "${AZURE_CLIENT_ID}" corral config vars set azure_client_secret "${AZURE_CLIENT_SECRET}" corral config vars set create_initial_clusters "${CREATE_INITIAL_CLUSTERS}" +corral config vars set gke_service_account "${GKE_SERVICE_ACCOUNT}" create_initial_clusters() { shopt -u nocasematch diff --git a/cypress/support/commands/rancher-api-commands.ts b/cypress/support/commands/rancher-api-commands.ts index 601db0080c..6e5fbf3564 100644 --- a/cypress/support/commands/rancher-api-commands.ts +++ b/cypress/support/commands/rancher-api-commands.ts @@ -602,7 +602,9 @@ Cypress.Commands.add('deleteNodeTemplate', (nodeTemplateId, timeout = 30000, fai * Create RKE2 cluster with Amazon EC2 cloud provider */ Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2ClusterParams) => { - const { machineConfig, rke2ClusterAmazon, cloudCredentialsAmazon } = params; + const { + machineConfig, rke2ClusterAmazon, cloudCredentialsAmazon, metadata + } = params; return cy.createAwsCloudCredentials(cloudCredentialsAmazon.workspace, cloudCredentialsAmazon.name, cloudCredentialsAmazon.region, cloudCredentialsAmazon.accessKey, cloudCredentialsAmazon.secretKey) .then((resp: Cypress.Response) => { @@ -625,8 +627,12 @@ Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2Cluster type: 'provisioning.cattle.io.cluster', metadata: { namespace: rke2ClusterAmazon.namespace, - annotations: { 'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description` }, - name: rke2ClusterAmazon.clusterName + annotations: { + 'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description`, + ...(metadata?.annotations || {}), + }, + labels: metadata?.labels || {}, + name: rke2ClusterAmazon.clusterName }, spec: { rkeConfig: { diff --git a/package.json b/package.json index 81f7fd79d5..d1ce8952ef 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "cookie": "0.7.0", "cookie-universal": "2.2.2", "cron-validator": "1.2.0", - "cronstrue": "1.95.0", + "cronstrue": "2.53.0", "cross-env": "7.0.3", "custom-event-polyfill": "1.0.7", "d3": "7.3.0", @@ -90,6 +90,7 @@ "express": "4.17.1", "file-saver": "2.0.2", "floating-vue": "5.2.2", + "focus-trap": "7.6.2", "https": "1.0.0", "identicon.js": "2.3.3", "intl-messageformat": "7.8.4", @@ -135,7 +136,7 @@ "yaml": "2.5.1" }, "devDependencies": { - "@babel/plugin-proposal-optional-chaining": "7.14.5", + "@babel/plugin-proposal-optional-chaining": "7.21.0", "@babel/plugin-proposal-private-methods": "7.18.6", "@babel/plugin-proposal-private-property-in-object": "7.14.5", "@babel/plugin-transform-nullish-coalescing-operator": "7.23.4", @@ -178,6 +179,7 @@ "eslint-plugin-cypress": "2.12.1", "eslint-plugin-import": "2.31.0", "eslint-plugin-jest": "24.4.0", + "eslint-plugin-local-rules": "link:./eslint-plugin-local-rules", "eslint-plugin-node": "11.1.0", "eslint-plugin-vue": "9.10.0", "eslint": "7.32.0", @@ -188,7 +190,7 @@ "lodash.debounce": "4.0.8", "nodemon": "2.0.22", "nyc": "15.1.0", - "start-server-and-test": "1.13.1", + "start-server-and-test": "2.0.10", "style-loader": "3.3.2", "ts-jest": "27.1.5", "typescript": "5.6.3", @@ -197,8 +199,7 @@ "webpack-virtual-modules": "0.4.3", "worker-loader": "3.0.8", "yaml-lint": "1.7.0", - "yarn": "1.22.18", - "eslint-plugin-local-rules": "link:./eslint-plugin-local-rules" + "yarn": "1.22.18" }, "resolutions": { "html-webpack-plugin": "^5.0.0" diff --git a/pkg/aks/components/AksNodePool.vue b/pkg/aks/components/AksNodePool.vue index e293eb3e48..6f3b48920a 100644 --- a/pkg/aks/components/AksNodePool.vue +++ b/pkg/aks/components/AksNodePool.vue @@ -387,26 +387,28 @@ export default defineComponent({ v-if="(taints && taints.length) || isView" class="taints" > - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/shell/components/form/ResourceLabeledSelect.vue b/shell/components/form/ResourceLabeledSelect.vue index 3326a99279..f6ebe7ed39 100644 --- a/shell/components/form/ResourceLabeledSelect.vue +++ b/shell/components/form/ResourceLabeledSelect.vue @@ -3,53 +3,8 @@ import { PropType, defineComponent } from 'vue'; import LabeledSelect from '@shell/components/form/LabeledSelect.vue'; import { PaginationParamFilter } from '@shell/types/store/pagination.types'; import { labelSelectPaginationFunction, LabelSelectPaginationFunctionOptions } from '@shell/components/form/labeled-select-utils/labeled-select.utils'; -import { LabelSelectPaginateFn, LabelSelectPaginateFnOptions, LabelSelectPaginateFnResponse } from '@shell/types/components/labeledSelect'; - -type PaginateTypeOverridesFn = (opts: LabelSelectPaginationFunctionOptions) => LabelSelectPaginationFunctionOptions; - -interface SharedSettings { - /** - * Provide specific LabelSelect options for this mode (paginated / not paginated) - */ - labelSelectOptions?: { [key: string]: any }, - /** - * Map the resources shown in LabelSelect - */ - mapResult?: (resources: any[]) => any[] -} - -/** - * Settings to use when the LabelSelect is paginating - */ -export interface ResourceLabeledSelectPaginateSettings extends SharedSettings { - /** - * Override the convience function which fetches a page of results - */ - overrideRequest?: LabelSelectPaginateFn, - /** - * Override the default settings used in the convenience function to fetch a page of results - */ - requestSettings?: PaginateTypeOverridesFn, -} - -/** - * Settings to use when the LabelSelect is fetching all resources (not paginating) - */ -export type ResourceLabeledSelectSettings = SharedSettings - -/** - * Force a specific mode - */ -export enum RESOURCE_LABEL_SELECT_MODE { - /** - * Fetch all resources - */ - ALL_RESOURCES = 'ALL', // eslint-disable-line no-unused-vars - /** - * Determine if all resources are fetched given system settings - */ - DYNAMIC = 'DYNAMIC', // eslint-disable-line no-unused-vars -} +import { LabelSelectPaginateFnOptions, LabelSelectPaginateFnResponse } from '@shell/types/components/labeledSelect'; +import { RESOURCE_LABEL_SELECT_MODE, ResourceLabeledSelectPaginateSettings, ResourceLabeledSelectSettings } from '@shell/types/components/resourceLabeledSelect'; /** * Convenience wrapper around the LabelSelect component to support pagination @@ -66,6 +21,8 @@ export default defineComponent({ components: { LabeledSelect }, + emits: ['update:value'], + props: { /** * Resource to show @@ -89,7 +46,7 @@ export default defineComponent({ }, /** - * Specific settings to use when we're showing all results + * Specific settings to use when we're showing all results in the drop down */ allResourcesSettings: { type: Object as PropType, @@ -97,7 +54,7 @@ export default defineComponent({ }, /** - * Specific settings to use when we're showing paginated results + * Specific settings to use when we're showing paginated results in the drop down */ paginatedResourceSettings: { type: Object as PropType, @@ -120,6 +77,7 @@ export default defineComponent({ } if (!this.paginate) { + // The resource won't be paginated and component expects everything up front await this.$store.dispatch(`${ this.inStore }/findAll`, { type: this.resourceType }); } }, @@ -148,13 +106,14 @@ export default defineComponent({ const all = this.$store.getters[`${ this.inStore }/all`](this.resourceType); - return this.allResourcesSettings?.mapResult ? this.allResourcesSettings.mapResult(all) : all; + return this.allResourcesSettings?.updateResources ? this.allResourcesSettings.updateResources(all) : all; } }, methods: { /** - * Typeof LabelSelectPaginateFn + * Make the request to fetch the resource given the state of the label select (filter, page, page size, etc see LabelSelectPaginateFn) + * opts: Typeof LabelSelectPaginateFn */ async paginateType(opts: LabelSelectPaginateFnOptions): Promise { if (this.paginatedResourceSettings?.overrideRequest) { @@ -175,9 +134,9 @@ export default defineComponent({ const options = this.paginatedResourceSettings?.requestSettings ? this.paginatedResourceSettings.requestSettings(defaultOptions) : defaultOptions; const res = await labelSelectPaginationFunction(options); - return this.paginatedResourceSettings?.mapResult ? { + return this.paginatedResourceSettings?.updateResources ? { ...res, - page: this.paginatedResourceSettings.mapResult(res.page) + page: this.paginatedResourceSettings.updateResources(res.page) } : res; }, }, @@ -190,5 +149,6 @@ export default defineComponent({ :loading="$fetchState.pending" :options="allOfType" :paginate="paginateType" + @update:value="$emit('update:value', $event)" /> diff --git a/shell/components/form/ResourceSelector.vue b/shell/components/form/ResourceSelector.vue index 78ab9ec674..2a50136ec2 100644 --- a/shell/components/form/ResourceSelector.vue +++ b/shell/components/form/ResourceSelector.vue @@ -36,6 +36,7 @@ export default { }, async fetch() { + // Used in conjunction with `matches/match/label selectors`. Requires https://github.com/rancher/dashboard/issues/10417 to fix const hash = await allHash({ allResources: this.$store.dispatch('cluster/findAll', { type: this.type }) }); this.allResources = hash.allResources; diff --git a/shell/components/form/ResourceTabs/index.vue b/shell/components/form/ResourceTabs/index.vue index b8f469c6b9..e80d5344a0 100644 --- a/shell/components/form/ResourceTabs/index.vue +++ b/shell/components/form/ResourceTabs/index.vue @@ -8,10 +8,14 @@ import Tab from '@shell/components/Tabbed/Tab'; import CreateEditView from '@shell/mixins/create-edit-view'; import Conditions from '@shell/components/form/Conditions'; import { EVENT } from '@shell/config/types'; -import SortableTable from '@shell/components/SortableTable'; +import PaginatedResourceTable from '@shell/components/PaginatedResourceTable.vue'; import { _VIEW } from '@shell/config/query-params'; import RelatedResources from '@shell/components/RelatedResources'; import { isConditionReadyAndWaiting } from '@shell/plugins/dashboard-store/resource-class'; +import { PaginationParamFilter } from '@shell/types/store/pagination.types'; +import { MESSAGE, REASON } from '@shell/config/table-headers'; +import { STEVE_EVENT_LAST_SEEN, STEVE_EVENT_TYPE, STEVE_NAME_COL } from '@shell/config/pagination-table-headers'; +import { headerFromSchemaColString } from '@shell/store/type-map.utils'; export default { @@ -21,7 +25,7 @@ export default { Tabbed, Tab, Conditions, - SortableTable, + PaginatedResourceTable, RelatedResources, }, @@ -69,14 +73,25 @@ export default { data() { const inStore = this.$store.getters['currentStore'](EVENT); + const eventSchema = this.$store.getters[`${ inStore }/schemaFor`](EVENT); // @TODO be smarter about which resources actually ever have events return { - hasEvents: this.$store.getters[`${ inStore }/schemaFor`](EVENT), // @TODO be smarter about which resources actually ever have events - allEvents: [], - selectedTab: this.defaultTab, - didLoadEvents: false, + eventSchema, + EVENT, + selectedTab: this.defaultTab, inStore, - showConditions: false, + showConditions: false, + paginationHeaders: [ + STEVE_EVENT_LAST_SEEN, + STEVE_EVENT_TYPE, + REASON, + headerFromSchemaColString('Subobject', eventSchema, this.$store.getters, true), + headerFromSchemaColString('Source', eventSchema, this.$store.getters, true), + MESSAGE, + headerFromSchemaColString('First Seen', eventSchema, this.$store.getters, true), + headerFromSchemaColString('Count', eventSchema, this.$store.getters, true), + STEVE_NAME_COL, + ] }; }, @@ -92,7 +107,7 @@ export default { computed: { showEvents() { - return this.isView && this.needEvents && this.hasEvents; + return this.isView && this.needEvents && this.eventSchema; }, showRelated() { return this.isView && this.needRelated; @@ -128,18 +143,6 @@ export default { }, ]; }, - events() { - return this.allEvents.filter((event) => { - return event.involvedObject?.uid === this.value?.metadata?.uid; - }).map((event) => { - return { - reason: (`${ event.reason || this.t('generic.unknown') }${ event.count > 1 ? ` (${ event.count })` : '' }`).trim(), - message: event.message || this.t('generic.unknown'), - date: event.lastTimestamp || event.firstTimestamp || event.metadata.creationTimestamp, - eventType: event.eventType - }; - }); - }, conditionsHaveIssues() { if (this.showConditions) { return this.value.status?.conditions?.filter((cond) => !isConditionReadyAndWaiting(cond)).some((cond) => cond.error); @@ -153,15 +156,6 @@ export default { // Ensures we only fetch events and show the table when the events tab has been activated tabChange(neu) { this.selectedTab = neu?.selectedName; - - if (!this.didLoadEvents && this.selectedTab === 'events') { - const inStore = this.$store.getters['currentStore'](EVENT); - - this.$store.dispatch(`${ inStore }/findAll`, { type: EVENT }).then((events) => { - this.allEvents = events; - this.didLoadEvents = true; - }); - } }, /** @@ -180,6 +174,54 @@ export default { this.showConditions = this.$store.getters[`${ this.inStore }/pathExistsInSchema`](this.value.type, 'status.conditions'); } }, + + /** + * Filter out hidden repos from list of all repos + */ + filterEventsLocal(rows) { + return rows.filter((event) => event.involvedObject?.uid === this.value?.metadata?.uid); + }, + + /** + * Filter out hidden repos via api + * + * pagination: PaginationArgs + * returns: PaginationArgs + */ + filterEventsApi(pagination) { + if (!pagination.filters) { + pagination.filters = []; + } + + const field = `involvedObject.uid`; // Pending API Support - https://github.com/rancher/rancher/issues/48603 + + // of type PaginationParamFilter + let existing = null; + + for (let i = 0; i < pagination.filters.length; i++) { + const filter = pagination.filters[i]; + + if (!!filter.fields.find((f) => f.field === field)) { + existing = filter; + break; + } + } + + const required = PaginationParamFilter.createSingleField({ + field, + exact: true, + value: this.value.metadata.uid, + equals: true + }); + + if (!!existing) { + Object.assign(existing, required); + } else { + pagination.filters.push(required); + } + + return pagination; + } } }; @@ -208,15 +250,16 @@ export default { name="events" :weight="-2" > - + diff --git a/shell/components/form/SecretSelector.vue b/shell/components/form/SecretSelector.vue index 4b491db872..7f164582f7 100644 --- a/shell/components/form/SecretSelector.vue +++ b/shell/components/form/SecretSelector.vue @@ -70,7 +70,7 @@ export default { secrets: null, SECRET, allSecretsSettings: { - mapResult: (secrets) => { + updateResources: (secrets) => { const allSecretsInNamespace = secrets.filter((secret) => this.types.includes(secret._type) && secret.namespace === this.namespace); const mappedSecrets = this.mapSecrets(allSecretsInNamespace.sort((a, b) => a.name.localeCompare(b.name))); @@ -81,7 +81,7 @@ export default { }, paginateSecretsSetting: { requestSettings: this.paginatePageOptions, - mapResult: (secrets) => { + updateResources: (secrets) => { const mappedSecrets = this.mapSecrets(secrets); this.secrets = secrets; // We need the key from the selected secret. When paginating we won't touch the store, so just pass back here diff --git a/shell/components/form/Select.vue b/shell/components/form/Select.vue index 2f0d3734db..e5e68fc9cb 100644 --- a/shell/components/form/Select.vue +++ b/shell/components/form/Select.vue @@ -114,11 +114,24 @@ export default { calculatePosition(dropdownList, component, width, this.placement); }, - focus() { - this.focusSearch(); - }, - focusSearch() { + // we need this override as in a "closeOnSelect" type of component + // if we don't have this override, it would open again + if (this.overridesMixinPreventDoubleTriggerKeysOpen) { + this.$nextTick(() => { + const el = this.$refs['select']; + + if ( el ) { + el.focus(); + } + + this.overridesMixinPreventDoubleTriggerKeysOpen = false; + }); + + return; + } + this.$refs['select-input'].open = true; + this.$nextTick(() => { const el = this.$refs['select-input']?.searchEl; @@ -176,6 +189,11 @@ export default { }, report(e) { alert(e); + }, + handleDropdownOpen(args) { + // function that prevents the "opening dropdown on focus" + // default behaviour of v-select + return args.noDrop || args.disabled ? false : args.open; } }, computed: { @@ -227,7 +245,7 @@ export default { ref="select" class="unlabeled-select" :class="{ - disabled: disabled && !isView, + disabled: disabled || isView, focused, [mode]: true, [status]: status, @@ -236,7 +254,9 @@ export default { 'compact-input': compact, [$attrs.class]: $attrs.class }" - @focus="focusSearch" + :tabindex="disabled || isView ? -1 : 0" + @click="focusSearch" + @keyup.enter.space.down="focusSearch" > { expect(secondKeyInput.exists()).toBe(false); expect(secondValueInput.exists()).toBe(false); - const addButton = wrapper.find('[data-testid="add_link_button"]'); + const addButton = wrapper.find('[data-testid="add_row_item_button"]'); addButton.trigger('click'); await nextTick(); diff --git a/shell/components/formatter/FleetClusterSummaryGraph.vue b/shell/components/formatter/FleetClusterSummaryGraph.vue index 7fc03cbbd2..ba5ea1a654 100644 --- a/shell/components/formatter/FleetClusterSummaryGraph.vue +++ b/shell/components/formatter/FleetClusterSummaryGraph.vue @@ -11,7 +11,7 @@ export default { required: true }, - clusterLabel: { + clusterId: { type: String, required: true } @@ -22,6 +22,6 @@ export default { diff --git a/shell/components/formatter/FleetSummaryGraph.vue b/shell/components/formatter/FleetSummaryGraph.vue index 58d3b571d2..f6dd595b93 100644 --- a/shell/components/formatter/FleetSummaryGraph.vue +++ b/shell/components/formatter/FleetSummaryGraph.vue @@ -14,7 +14,7 @@ export default { required: true }, - clusterLabel: { + clusterId: { type: String, required: false, default: null, @@ -23,10 +23,8 @@ export default { computed: { summary() { - if (this.clusterLabel) { - return this.row.clusterResourceStatus.find((x) => { - return x.clusterLabel === this.clusterLabel; - })?.status.resourceCounts || {}; + if (this.clusterId) { + return this.row.statusResourceCountsForCluster(this.clusterId); } return this.row.status?.resourceCounts || {}; @@ -37,7 +35,8 @@ export default { }, stateParts() { - const keys = Object.keys(this.summary).filter((x) => !x.startsWith('desired')); + const summary = this.summary; + const keys = Object.keys(summary).filter((x) => !x.startsWith('desired')); const out = keys.map((key) => { const textColor = colorForState(key); @@ -46,7 +45,7 @@ export default { label: ucFirst(key), color: textColor.replace(/text-/, 'bg-'), textColor, - value: this.summary[key], + value: summary[key], sort: stateSort(textColor, key), }; }).filter((x) => x.value > 0); diff --git a/shell/components/formatter/WorkloadHealthScale.vue b/shell/components/formatter/WorkloadHealthScale.vue index 7adcc0dbe0..afd0a939db 100644 --- a/shell/components/formatter/WorkloadHealthScale.vue +++ b/shell/components/formatter/WorkloadHealthScale.vue @@ -182,14 +182,21 @@ export default {
diff --git a/shell/components/nav/Group.vue b/shell/components/nav/Group.vue index 3e52d8fdd7..f6dd437f09 100644 --- a/shell/components/nav/Group.vue +++ b/shell/components/nav/Group.vue @@ -211,26 +211,40 @@ export default { v-if="showHeader" class="header" :class="{'active': isOverview, 'noHover': !canCollapse}" + role="button" + tabindex="0" + :aria-label="group.labelDisplay || group.label || ''" @click="groupSelected()" + @keyup.enter="groupSelected()" + @keyup.space="groupSelected()" > -
+
+ +
+ > + +
    {{ type.label }}  @@ -178,6 +183,16 @@ export default { margin-right: 4px; } + .type-link:focus-visible span.label { + @include focus-outline; + outline-offset: 2px; + } + + .nav-link a:focus-visible .label { + @include focus-outline; + outline-offset: 2px; + } + .child { margin: 0 var(--outline) 0 0; diff --git a/shell/config/labels-annotations.js b/shell/config/labels-annotations.js index 425f1cc401..6b9ac1ab19 100644 --- a/shell/config/labels-annotations.js +++ b/shell/config/labels-annotations.js @@ -112,6 +112,8 @@ export const FLEET = { CLUSTER_DISPLAY_NAME: 'management.cattle.io/cluster-display-name', CLUSTER_NAME: 'management.cattle.io/cluster-name', BUNDLE_ID: 'fleet.cattle.io/bundle-id', + BUNDLE_NAME: 'fleet.cattle.io/bundle-name', + BUNDLE_NAMESPACE: 'fleet.cattle.io/bundle-namespace', MANAGED: 'fleet.cattle.io/managed', CLUSTER_NAMESPACE: 'fleet.cattle.io/cluster-namespace', CLUSTER: 'fleet.cattle.io/cluster' diff --git a/shell/config/pagination-table-headers.js b/shell/config/pagination-table-headers.js index 6ae3cce8bd..5691e288ee 100644 --- a/shell/config/pagination-table-headers.js +++ b/shell/config/pagination-table-headers.js @@ -1,5 +1,7 @@ import { - STATE, NAME as NAME_COL, NAMESPACE as NAMESPACE_COL, AGE, OBJECT + STATE, NAME as NAME_COL, NAMESPACE as NAMESPACE_COL, AGE, OBJECT, + EVENT_LAST_SEEN_TIME, + EVENT_TYPE } from '@shell/config/table-headers'; // This file contains table headers @@ -52,6 +54,18 @@ export const STEVE_EVENT_OBJECT = { search: 'involvedObject.kind', }; +export const STEVE_EVENT_LAST_SEEN = { + ...EVENT_LAST_SEEN_TIME, + value: 'metadata.fields.0', + sort: 'metadata.fields.0', +}; + +export const STEVE_EVENT_TYPE = { + ...EVENT_TYPE, + value: '_type', + sort: '_type', +}; + export const STEVE_LIST_GROUPS = [{ tooltipKey: 'resourceTable.groupBy.none', icon: 'icon-list-flat', diff --git a/shell/config/product/explorer.js b/shell/config/product/explorer.js index e92087b93b..237adfc979 100644 --- a/shell/config/product/explorer.js +++ b/shell/config/product/explorer.js @@ -20,12 +20,13 @@ import { STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE, HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA, ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, LAST_USED, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE, POD_RESTARTS, - DURATION, MESSAGE, REASON, LAST_SEEN_TIME, EVENT_TYPE, OBJECT, ROLE, ROLES, VERSION, INTERNAL_EXTERNAL_IP, KUBE_NODE_OS, CPU, RAM, SECRET_DATA + DURATION, MESSAGE, REASON, EVENT_TYPE, OBJECT, ROLE, ROLES, VERSION, INTERNAL_EXTERNAL_IP, KUBE_NODE_OS, CPU, RAM, SECRET_DATA, + EVENT_LAST_SEEN_TIME } from '@shell/config/table-headers'; import { DSL } from '@shell/store/type-map'; import { - STEVE_AGE_COL, STEVE_EVENT_OBJECT, STEVE_LIST_GROUPS, STEVE_NAMESPACE_COL, STEVE_NAME_COL, STEVE_STATE_COL + STEVE_AGE_COL, STEVE_EVENT_LAST_SEEN, STEVE_EVENT_OBJECT, STEVE_EVENT_TYPE, STEVE_LIST_GROUPS, STEVE_NAMESPACE_COL, STEVE_NAME_COL, STEVE_STATE_COL } from '@shell/config/pagination-table-headers'; import { COLUMN_BREAKPOINTS } from '@shell/types/store/type-map'; @@ -321,23 +322,12 @@ export function init(store) { ] ); - const eventLastSeenTime = { - ...LAST_SEEN_TIME, - defaultSort: true, - }; - headers(EVENT, - [STATE, eventLastSeenTime, EVENT_TYPE, REASON, OBJECT, 'Subobject', 'Source', MESSAGE, 'First Seen', 'Count', NAME_COL, NAMESPACE_COL], + [STATE, EVENT_LAST_SEEN_TIME, EVENT_TYPE, REASON, OBJECT, 'Subobject', 'Source', MESSAGE, 'First Seen', 'Count', NAME_COL, NAMESPACE_COL], [ - STEVE_STATE_COL, { - ...eventLastSeenTime, - value: 'metadata.fields.0', - sort: 'metadata.fields.0', - }, { - ...EVENT_TYPE, - value: '_type', - sort: '_type', - }, + STEVE_STATE_COL, + STEVE_EVENT_LAST_SEEN, + STEVE_EVENT_TYPE, REASON, STEVE_EVENT_OBJECT, 'Subobject', diff --git a/shell/config/table-headers.js b/shell/config/table-headers.js index e06b0b7474..f754d81f5c 100644 --- a/shell/config/table-headers.js +++ b/shell/config/table-headers.js @@ -515,6 +515,12 @@ export const LAST_SEEN_TIME = { sort: 'lastTimestamp:desc', tooltip: 'tableHeaders.lastSeenTooltip' }; + +export const EVENT_LAST_SEEN_TIME = { + ...LAST_SEEN_TIME, + defaultSort: true, +}; + export const LAST_HEARTBEAT_TIME = { name: 'lastHeartbeatTime', labelKey: 'tableHeaders.lastSeen', diff --git a/shell/core/plugin.ts b/shell/core/plugin.ts index a1e27da1a0..faafad8c47 100644 --- a/shell/core/plugin.ts +++ b/shell/core/plugin.ts @@ -13,11 +13,18 @@ import { LocationConfig, ExtensionPoint, TabLocation, + ModelExtensionConstructor, PluginRouteRecordRaw, RegisterStore, UnregisterStore, CoreStoreSpecifics, CoreStoreConfig, OnNavToPackage, OnNavAwayFromPackage, OnLogOut } from './types'; import coreStore, { coreStoreModule, coreStoreState } from '@shell/plugins/dashboard-store'; import { defineAsyncComponent, markRaw, Component } from 'vue'; +// Registration IDs used for different extension points in the extensions catalog +export const EXT_IDS = { + MODELS: 'models', + MODEL_EXTENSION: 'model-extension', +}; + export type ProductFunction = (plugin: IPlugin, store: any) => void; export class Plugin implements IPlugin { @@ -25,6 +32,7 @@ export class Plugin implements IPlugin { public name: string; public types: any = {}; public l10n: { [key: string]: Function[] } = {}; + public modelExtensions: { [key: string]: Function[] } = {}; public locales: { locale: string, label: string}[] = []; public products: ProductFunction[] = []; public productNames: string[] = []; @@ -186,6 +194,17 @@ export class Plugin implements IPlugin { this._addUIConfig(ExtensionPoint.CARD, where, when, this._createAsyncComponent(card)); } + /** + * Adds a model extension + * @experimental May change or be removed in the future + * + * @param type Model type + * @param clz Class for the model extension (constructor) + */ + addModelExtension(type: string, clz: ModelExtensionConstructor): void { + this.register(EXT_IDS.MODEL_EXTENSION, type, clz); + } + /** * Wraps a component from an extensionConfig with defineAsyncComponent and * markRaw. This prepares the component to be loaded dynamically and prevents @@ -317,10 +336,18 @@ export class Plugin implements IPlugin { } this.l10n[name].push(fn); + + // Accumulate model extensions + } else if (type === EXT_IDS.MODEL_EXTENSION) { + if (!this.modelExtensions[name]) { + this.modelExtensions[name] = []; + } + this.modelExtensions[name].push(fn); } else { if (!this.types[type]) { this.types[type] = {}; } + this.types[type][name] = fn; } } diff --git a/shell/core/plugins.js b/shell/core/plugins.js index 6dfa825966..3f23c15c8e 100644 --- a/shell/core/plugins.js +++ b/shell/core/plugins.js @@ -1,12 +1,10 @@ import { productsLoaded } from '@shell/store/type-map'; import { clearModelCache } from '@shell/plugins/dashboard-store/model-loader'; -import { Plugin } from './plugin'; +import { EXT_IDS, Plugin } from './plugin'; import { PluginRoutes } from './plugin-routes'; import { UI_PLUGIN_BASE_URL } from '@shell/config/uiplugins'; import { ExtensionPoint } from './types'; -const MODEL_TYPE = 'models'; - export default function(context, inject, vueApp) { const { app, store, $axios, redirect @@ -26,6 +24,21 @@ export default function(context, inject, vueApp) { uiConfig[ExtensionPoint[ep]] = {}; } + /** + * When an extension adds a model extension, it provides the class - we will instantiate that class and store and use that + */ + function instantiateModelExtension($plugin, clz) { + const context = { + dispatch: store.dispatch, + getters: store.getters, + t: store.getters['i18n/t'], + $axios, + $plugin, + }; + + return new clz(context); + } + inject( 'plugin', { @@ -78,72 +91,49 @@ export default function(context, inject, vueApp) { element.id = id; element.dataset.purpose = 'extension'; - // id is `-`. - const oldPlugin = Object.values(plugins).find((p) => id.startsWith(p.name)); + element.onload = () => { + if (!window[id] || (typeof window[id].default !== 'function')) { + return reject(new Error('Could not load plugin code')); + } - let removed = Promise.resolve(); + // Update the timestamp that new plugins were loaded - may be needed + // to update caches when new plugins are loaded + _lastLoaded = new Date().getTime(); - if (oldPlugin) { - // Uninstall existing plugin if there is one. This ensures that last loaded plugin is not always used - // (nav harv1-->harv2-->harv1 and harv2 would be shown) - removed = this.removePlugin(oldPlugin.name).then(() => { - delete window[oldPlugin.id]; + // name is the name of the plugin, including the version number + const plugin = new Plugin(id); - delete plugins[oldPlugin.id]; + plugins[id] = plugin; - const oldElement = document.getElementById(oldPlugin.id); - - oldElement.parentElement.removeChild(oldElement); - }); - } - - removed.then(() => { - element.onload = () => { - if (!window[id]) { - return reject(new Error('Could not load plugin code')); - } - - // Update the timestamp that new plugins were loaded - may be needed - // to update caches when new plugins are loaded - _lastLoaded = new Date().getTime(); - - // name is the name of the plugin, including the version number - const plugin = new Plugin(id); - - plugins[id] = plugin; - - // Initialize the plugin + // Initialize the plugin + try { window[id].default(plugin, this.internal()); + } catch (e) { + delete plugins[id]; - // Uninstall existing plugin if there is one - this.removePlugin(plugin.name); // Removing this causes the plugin to not load on refresh + return reject(new Error('Could not initialize plugin')); + } - // Load all of the types etc from the plugin - this.applyPlugin(plugin); + // Load all of the types etc from the plugin + this.applyPlugin(plugin); - // Add the plugin to the store - store.dispatch('uiplugins/addPlugin', plugin); + // Add the plugin to the store + store.dispatch('uiplugins/addPlugin', plugin); - resolve(); - }; + resolve(); + }; - element.onerror = (e) => { - element.parentElement.removeChild(element); + element.onerror = (e) => { + element.parentElement.removeChild(element); - // Massage the error into something useful - const errorMessage = `Failed to load script from '${ e.target.src }'`; - - console.error(errorMessage, e); // eslint-disable-line no-console - reject(new Error(errorMessage)); // This is more useful where it's used - }; - - document.head.appendChild(element); - }).catch((e) => { - const errorMessage = `Failed to unload old plugin${ oldPlugin?.id }`; + // Massage the error into something useful + const errorMessage = `Failed to load script from '${ e.target.src }'`; console.error(errorMessage, e); // eslint-disable-line no-console reject(new Error(errorMessage)); // This is more useful where it's used - }); + }; + + document.head.appendChild(element); }); }, @@ -215,7 +205,7 @@ export default function(context, inject, vueApp) { Object.keys(plugin.types[typ]).forEach((name) => { this.unregister(typ, name); - if (typ === MODEL_TYPE) { + if (typ === EXT_IDS.MODELS) { clearModelCache(name); } }); @@ -284,6 +274,13 @@ export default function(context, inject, vueApp) { }); }); + // Model extensions + Object.keys(plugin.modelExtensions).forEach((name) => { + plugin.modelExtensions[name].forEach((fn) => { + this.register(EXT_IDS.MODEL_EXTENSION, name, instantiateModelExtension(this, fn)); + }); + }); + // Initialize the product if the store is ready if (productsLoaded()) { this.loadProducts([plugin]); @@ -317,8 +314,8 @@ export default function(context, inject, vueApp) { dynamic[type] = {}; } - // Accumulate l10n resources rather than replace - if (type === 'l10n') { + // Accumulate l10n resources and model extensions rather than replace + if (type === 'l10n' || type === EXT_IDS.MODEL_EXTENSION) { if (!dynamic[type][name]) { dynamic[type][name] = []; } diff --git a/shell/core/types-provisioning.ts b/shell/core/types-provisioning.ts index d9f190d1ff..c6a81b8603 100644 --- a/shell/core/types-provisioning.ts +++ b/shell/core/types-provisioning.ts @@ -13,6 +13,37 @@ export type ClusterSaveHook = (cluster: any) => Promise */ export type RegisterClusterSaveHook = (hook: ClusterSaveHook, name: string, priority?: number, fnContext?: any) => void; +export type ClusterDetailTabs = { + /** + * RKE2 machine pool tabs + */ + machines: boolean, + /** + * RKE2 provisioning logs + */ + logs: boolean, + /** + * RKE2 registration commands + */ + registration: boolean, + /** + * RKE2 snapshots + */ + snapshots: boolean, + /** + * Kube resources related to the instance of provisioning.cattle.io.cluster + */ + related: boolean, + /** + * Kube events associated with the instance of provisioning.cattle.io.cluster + */ + events: boolean, + /** + * Kube conditions of the provisioning.cattle.io.cluster instance + */ + conditions: boolean +}; + /** * Params used when constructing an instance of the cluster provisioner */ @@ -57,7 +88,6 @@ export interface ClusterProvisionerContext { * The majority of these hooks are used in shell/edit/provisioning.cattle.io.cluster/rke2.vue */ export interface IClusterProvisioner { - /** * Unique ID of the Cluster Provisioner * If this overlaps with the name of an existing provisioner (seen in the type query param while creating a cluster) this provisioner will overwrite the built-in ui @@ -233,7 +263,7 @@ export interface IClusterProvisioner { registerSaveHooks?(registerBeforeHook: RegisterClusterSaveHook, registerAfterHook: RegisterClusterSaveHook, cluster: any): void; /** - * Optionally override the save of the cluster resource itself. + * Optionally override the save of the cluster resource itself * * https://github.com/rancher/dashboard/blob/master/shell/mixins/create-edit-view/impl.js#L179 * @@ -263,3 +293,62 @@ export interface IClusterProvisioner { */ provision?(cluster: any, pools: any[]): Promise; } + +/** + * Interface that a model extension for the provisioning cluster model should implement + */ +export interface IClusterModelExtension { + /** + * Indicates if this extension should be used for the given cluster + * + * This allows the extension to determine if it should be used for a cluster based on attributes/metadata of its choosing + * + * @param cluster The cluster model (`provisioning.cattle.io.cluster`) + * @returns Whether to use this provisioner for the given cluster. + */ + useFor(cluster: any): boolean; + + /** + * Optionally Process the available actions for a cluster and return a (possibly modified) set of actions + * + * @param cluster The cluster model (`provisioning.cattle.io.cluster`) + * @returns List of actions for the cluster or undefined if the list is modified in-place + */ + availableActions?(cluster: any, actions: any[]): any[] | undefined; + + /** + * Get the display name for the machine provider for this model + * + * @param cluster The cluster model (`provisioning.cattle.io.cluster`) + * @returns Machine provider display name + */ + machineProviderDisplay?(cluster: any): string; + + /** + * Get the display name for the provisioner for this model + * + * @param cluster The cluster model (`provisioning.cattle.io.cluster`) + * @returns Provisioner display name + */ + provisionerDisplay?(cluster: any): string; + + /** + * Get the parent cluster for this cluster, or undefined if no parent cluster + * + * @param cluster The cluster model (`provisioning.cattle.io.cluster`) + * @returns ID of the parent cluster + */ + parentCluster?(cluster: any): string; + + /** + * Function to run after the cluster has been deleted + * + * @param cluster The cluster (`provisioning.cattle.io.cluster`) + */ + postDelete?(cluster: any): void; + + /** + * Existing tabs to show or hide in the cluster's detail view + */ + get detailTabs(): ClusterDetailTabs; +} diff --git a/shell/core/types.ts b/shell/core/types.ts index 96df1c615f..bd6b3a6a20 100644 --- a/shell/core/types.ts +++ b/shell/core/types.ts @@ -478,6 +478,37 @@ export interface DSLReturnType { // weightType: (input, weight, forBasic) } +/** + * Context for the constructor of a model extension + */ +export type ModelExtensionContext = { + /** + * Dispatch vuex actions + */ + dispatch: any, + /** + * Get from vuex store + */ + getters: any, + /** + * Used to make http requests + */ + axios: any, + /** + * Definition of the extension + */ + $plugin: any, + /** + * Function to retrieve a localised string + */ + t: (key: string) => string, +}; + +/** + * Constructor signature for a model extension + */ +export type ModelExtensionConstructor = (context: ModelExtensionContext) => Object; + /** * Interface for a Dashboard plugin */ @@ -584,6 +615,15 @@ export interface IPlugin { onLogOut?: OnLogOut ): void; + /** + * Adds a model extension + * @experimental May change or be removed in the future + * + * @param type Model type + * @param clz Class for the model extension (constructor) + */ + addModelExtension(type: string, clz: ModelExtensionConstructor): void; + /** * Register 'something' that can be dynamically loaded - e.g. model, edit, create, list, i18n * @param {String} type type of thing to register, e.g. 'edit' diff --git a/shell/detail/__tests__/autoscaling.horizontalpodautoscaler.test.ts b/shell/detail/__tests__/autoscaling.horizontalpodautoscaler.test.ts index c274017b39..92f5b0b644 100644 --- a/shell/detail/__tests__/autoscaling.horizontalpodautoscaler.test.ts +++ b/shell/detail/__tests__/autoscaling.horizontalpodautoscaler.test.ts @@ -7,9 +7,18 @@ describe('view: autoscaling.horizontalpodautoscaler', () => { 'i18n/t': (text: string) => text, t: (text: string) => text, currentStore: () => 'current_store', - 'current_store/schemaFor': jest.fn(), - 'current_store/all': jest.fn(), - workspace: jest.fn(), + 'current_store/schemaFor': () => ({ + attributes: { + columns: [ + { name: 'Subobject', field: '' }, + { name: 'Source', field: '' }, + { name: 'First Seen', field: '' }, + { name: 'Count', field: '' }] + } + }), + 'current_store/all': jest.fn(), + workspace: jest.fn(), + 'i18n/exists': jest.fn(), }, }; diff --git a/shell/detail/catalog.cattle.io.app.vue b/shell/detail/catalog.cattle.io.app.vue index 4c90ae4e7f..c6702f3f50 100644 --- a/shell/detail/catalog.cattle.io.app.vue +++ b/shell/detail/catalog.cattle.io.app.vue @@ -34,7 +34,7 @@ export default { const promises = { catalog: this.$store.dispatch('catalog/load'), allOperations: this.$store.dispatch('cluster/findAll', { type: CATALOG.OPERATION }), - secrets: this.value.fetchValues(true), + secret: this.value.fetchValues(true), }; const res = await allHash(promises); diff --git a/shell/detail/fleet.cattle.io.cluster.vue b/shell/detail/fleet.cattle.io.cluster.vue index 4669324062..cca17db8ac 100644 --- a/shell/detail/fleet.cattle.io.cluster.vue +++ b/shell/detail/fleet.cattle.io.cluster.vue @@ -29,11 +29,11 @@ export default { }, async fetch() { - const clusterId = this.value?.metadata?.labels[FLEET_LABELS.CLUSTER_NAME]; + const managementClusterId = this.value?.metadata?.labels[FLEET_LABELS.CLUSTER_NAME]; const hash = await allHash({ rancherCluster: this.$store.dispatch('management/find', { type: MANAGEMENT.CLUSTER, - id: clusterId + id: managementClusterId }), repos: this.$store.dispatch('management/findAll', { type: FLEET.GIT_REPO }), workspaces: this.$store.dispatch('management/findAll', { type: FLEET.WORKSPACE }), @@ -53,7 +53,7 @@ export default { return this.value.bundleDeployments; }, clusterId() { - return this.value?.metadata?.labels[FLEET_LABELS.CLUSTER_NAME]; + return this.value.id; }, repos() { diff --git a/shell/detail/namespace.vue b/shell/detail/namespace.vue index a4deb7d988..c9f785d429 100644 --- a/shell/detail/namespace.vue +++ b/shell/detail/namespace.vue @@ -10,9 +10,7 @@ import Tab from '@shell/components/Tabbed/Tab'; import ResourceTable from '@shell/components/ResourceTable'; import SortableTable from '@shell/components/SortableTable'; import Loading from '@shell/components/Loading'; -import { - flatten, compact, filter, findKey, values -} from 'lodash'; +import { flatten, compact, findKey, values } from 'lodash'; export default { emits: ['input'], @@ -89,6 +87,9 @@ export default { }, ]; + const params = this.$route.params; + const { id: namespaceId } = params; + return { allWorkloads: { default: () => ([]), @@ -97,7 +98,10 @@ export default { resourceTypes: [], summaryStates: ['success', 'info', 'warning', 'error', 'unknown'], headers, - workloadSchema: WORKLOAD_SCHEMA + workloadSchema: WORKLOAD_SCHEMA, + inStore: this.$store.getters['currentProduct'].inStore, + statesByType: getStatesByType(), + namespaceId, }; }, @@ -106,10 +110,6 @@ export default { }, computed: { - inStore() { - return this.$store.getters['currentProduct'].inStore; - }, - namespacedResourceCounts() { const allClusterResourceCounts = this.$store.getters[`${ this.inStore }/all`](COUNT)[0].counts; @@ -148,20 +148,11 @@ export default { }, totals); }, - statesByType() { - return getStatesByType(); - }, - /** * Workload table data for current namespace */ workloadRows() { - const params = this.$route.params; - const { id } = params; - const rows = flatten(compact(this.allWorkloads)).filter((row) => !row.ownedByWorkload); - const namespacedRows = filter(rows, ({ metadata: { namespace } }) => namespace === id); - - return namespacedRows; + return flatten(compact(this.allWorkloads)).filter((row) => !row.ownedByWorkload); } }, @@ -198,7 +189,10 @@ export default { return Promise.all(values(WORKLOAD_TYPES) // You may not have RBAC to see some of the types .filter((type) => Boolean(this.schemaFor(type))) - .map((type) => this.$store.dispatch('cluster/findAll', { type })) + // findAll on each workload type here, argh! however... + // - results are shown in a single table containing all workloads rather than an SSP compatible way (one table per type) + // - we're restricting by namespace. not great, but a big improvement + .map((type) => this.$store.dispatch('cluster/findAll', { type, opt: { namespaced: this.namespaceId } })) ); }, diff --git a/shell/detail/networking.k8s.io.ingress.vue b/shell/detail/networking.k8s.io.ingress.vue index 1ac52715e2..3d514b006b 100644 --- a/shell/detail/networking.k8s.io.ingress.vue +++ b/shell/detail/networking.k8s.io.ingress.vue @@ -1,12 +1,10 @@