merge master and resolve conflicts

This commit is contained in:
Mo Mesgin 2025-02-05 09:38:08 -08:00
commit 18e7e51614
175 changed files with 4082 additions and 1365 deletions

View File

@ -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 }}

View File

@ -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);
}
}

View File

@ -82,4 +82,8 @@ export default class ComponentPo {
checkNotExists(options?: GetOptions): Cypress.Chainable<boolean> {
return this.self(options).should('not.exist');
}
shouldHaveValue(value: string, options?: GetOptions): Cypress.Chainable<boolean> {
return this.self(options).should('have.value', value);
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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

View File

@ -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<Cypress.AUTWindow> {
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());
}

View File

@ -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());
}

View File

@ -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<Cypress.AUTWindow> {
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() {

View File

@ -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<Cypress.AUTWindow> {
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();
}
}

View File

@ -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();
}

View File

@ -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();
}
}

View File

@ -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<Cypress.AUTWindow> {
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<Cypress.AUTWindow> {
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"]');
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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');
}

View File

@ -32,7 +32,7 @@ export class HomeLinksPagePo extends RootClusterPage {
}
addLinkButton() {
return cy.getId('add_link_button');
return cy.getId('add_row_item_button');
}
removeLinkButton() {

View File

@ -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');
}

View File

@ -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"]}');
});
});
});
});

View File

@ -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');
});
});

View File

@ -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<any>) => {
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));
}
});
});

View File

@ -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"]}');
});

View File

@ -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');

View File

@ -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);

View File

@ -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);

View File

@ -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();

View File

@ -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', () => {

View File

@ -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');
});
});

View File

@ -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: {

View File

@ -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()

View File

@ -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',

View File

@ -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

View File

@ -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<any>) => {
@ -625,7 +627,11 @@ Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2Cluster
type: 'provisioning.cattle.io.cluster',
metadata: {
namespace: rke2ClusterAmazon.namespace,
annotations: { 'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description` },
annotations: {
'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description`,
...(metadata?.annotations || {}),
},
labels: metadata?.labels || {},
name: rke2ClusterAmazon.clusterName
},
spec: {

View File

@ -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"

View File

@ -387,6 +387,7 @@ export default defineComponent({
v-if="(taints && taints.length) || isView"
class="taints"
>
<thead>
<tr>
<th>
<label class="text-label">
@ -407,6 +408,7 @@ export default defineComponent({
</th>
<th />
</tr>
</thead>
<template v-if="taints && taints.length">
<Taint
v-for="(keyedTaint, i) in taints"

View File

@ -1,7 +1,5 @@
export default ['1.31',
export default ['1.32',
'1.30',
'1.31',
'1.29',
'1.28'];
'1.30'];

View File

@ -388,17 +388,16 @@ export default defineComponent({
</div>
</div>
<div class="col span-6">
<div class="col span-6 mt-20">
<KeyValue
:value="tags"
:mode="mode"
:title="t('eks.tags.label')"
:as-map="true"
:read-allowed="false"
@update:value="$emit('update:tags', $event)"
>
<template #title>
<label class="text-label">{{ t('eks.tags.label') }}</label>
<h3 v-t="'eks.tags.label'" />
</template>
</KeyValue>
</div>

View File

@ -296,7 +296,7 @@ export default defineComponent({
</ArrayList>
</div>
</div>
<div class="row mb-10">
<div class="row mb-10 mt-20">
<div
v-if="isNew"
class="col span-6"

View File

@ -604,7 +604,7 @@ export default defineComponent({
:label="minMaxDesiredErrors"
/>
<div class="row mb-10">
<div class="col span-6">
<div class="col span-6 mt-20">
<KeyValue
:mode="mode"
:title="t('eks.nodeGroups.groupLabels.label')"
@ -614,11 +614,13 @@ export default defineComponent({
@update:value="$emit('update:labels', $event)"
>
<template #title>
<label class="text-label">{{ t('eks.nodeGroups.groupLabels.label') }}</label>
<h4>
{{ t('eks.nodeGroups.groupLabels.label') }}
</h4>
</template>
</KeyValue>
</div>
<div class="col span-6">
<div class="col span-6 mt-20">
<KeyValue
:mode="mode"
:title="t('eks.nodeGroups.groupTags.label')"
@ -629,7 +631,7 @@ export default defineComponent({
@update:value="$emit('update:tags', $event)"
>
<template #title>
<label class="text-label">{{ t('eks.nodeGroups.groupTags.label') }}</label>
<h4>{{ t('eks.nodeGroups.groupTags.label') }}</h4>
</template>
</KeyValue>
</div>
@ -812,7 +814,7 @@ export default defineComponent({
</div>
</div>
<div row="mb-10">
<div class="col span-12">
<div class="col span-12 mt-20">
<KeyValue
:mode="mode"
label-key="eks.nodeGroups.resourceTags.label"
@ -823,7 +825,9 @@ export default defineComponent({
@update:value="$emit('update:resourceTags', $event)"
>
<template #title>
<label class="text-label">{{ t('eks.nodeGroups.resourceTags.label') }}</label>
<h4>
{{ t('eks.nodeGroups.resourceTags.label') }}
</h4>
</template>
</KeyValue>
</div>

View File

@ -35,7 +35,7 @@ harvesterManager:
prompt-standard-user: Please contact your system administrator to install the latest Harvester UI Extension, if any
missingVersion:
warning: "Could not find a compatible version"
prompt: "Please update Rancher to get the latest compatible version of the Harvester UI extension"
prompt: "Please update Rancher to get the latest compatible version of the Harvester UI extension or try to install it manually"
prompt-standard-user: Please contact your system administrator
error:
warning: "Warning, Harvester UI extension automatic installation failed"

View File

@ -14,11 +14,12 @@ import { allHash } from '@shell/utils/promise';
import { NAME as APP_PRODUCT } from '@shell/config/product/apps';
import { BLANK_CLUSTER } from '@shell/store/store-types.js';
import { UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
import { HARVESTER_CHART, HARVESTER_COMMUNITY_REPO, HARVESTER_RANCHER_REPO } from '../types';
import { HARVESTER_CHART, HARVESTER_COMMUNITY_REPO, HARVESTER_RANCHER_REPO, communityRepoRegexes } from '../types';
import {
getLatestExtensionVersion,
getHelmRepository,
ensureHelmRepository,
getHelmRepositoryExact,
getHelmRepositoryMatch,
createHelmRepository,
refreshHelmRepository,
installHelmChart,
waitForUIExtension,
@ -102,9 +103,9 @@ export default {
},
watch: {
async harvesterRepository(value) {
if (value) {
await refreshHelmRepository(this.$store, HARVESTER_REPO.spec.gitRepo, HARVESTER_REPO.spec.gitBranch);
async harvesterRepository(neu) {
if (neu) {
await refreshHelmRepository(this.$store, neu.spec.gitRepo || neu.spec.url);
if (this.harvester.extension) {
await this.setHarvesterUpdateVersion();
@ -212,7 +213,11 @@ export default {
methods: {
async getHarvesterRepository() {
try {
return await getHelmRepository(this.$store, HARVESTER_REPO.spec.gitRepo, HARVESTER_REPO.spec.gitBranch);
if (isRancherPrime()) {
return await getHelmRepositoryExact(this.$store, HARVESTER_REPO.gitRepo);
} else {
return await getHelmRepositoryMatch(this.$store, communityRepoRegexes);
}
} catch (error) {
this.harvesterRepositoryError = true;
}
@ -234,13 +239,17 @@ export default {
let installed = false;
try {
const harvesterRepo = await ensureHelmRepository(this.$store, HARVESTER_REPO.spec.gitRepo, HARVESTER_REPO.metadata.name, HARVESTER_REPO.spec.gitBranch);
let harvesterRepository = this.harvesterRepository;
if (!harvesterRepository) {
harvesterRepository = await createHelmRepository(this.$store, HARVESTER_REPO.metadata.name, HARVESTER_REPO.gitRepo, HARVESTER_REPO.gitBranch);
}
/**
* Server issue
* It needs to refresh the HelmRepository because the server can have a previous one in the cache.
*/
await refreshHelmRepository(this.$store, HARVESTER_REPO.spec.gitRepo, HARVESTER_REPO.spec.gitBranch);
await refreshHelmRepository(this.$store, harvesterRepository.spec.gitRepo || harvesterRepository.spec.url);
this.harvesterInstallVersion = await getLatestExtensionVersion(this.$store, HARVESTER_CHART.name, this.rancherVersion, this.kubeVersion);
@ -250,7 +259,16 @@ export default {
return;
}
await installHelmChart(harvesterRepo, { ...HARVESTER_CHART, version: this.harvesterInstallVersion }, {}, UI_PLUGIN_NAMESPACE, 'install');
await installHelmChart(
harvesterRepository,
{
...HARVESTER_CHART,
version: this.harvesterInstallVersion
},
{},
UI_PLUGIN_NAMESPACE,
'install'
);
const extension = await waitForUIExtension(this.$store, HARVESTER_CHART.name);
@ -272,7 +290,7 @@ export default {
try {
if (this.harvester.missingRepository) {
this.harvesterRepository = await ensureHelmRepository(this.$store, HARVESTER_REPO.spec.gitRepo, HARVESTER_REPO.metadata.name, HARVESTER_REPO.spec.gitBranch);
this.harvesterRepository = await createHelmRepository(this.$store, HARVESTER_REPO.metadata.name, HARVESTER_REPO.gitRepo, HARVESTER_REPO.gitBranch);
await this.setHarvesterUpdateVersion();
}
@ -283,7 +301,16 @@ export default {
return;
}
await installHelmChart(this.harvesterRepository, { ...HARVESTER_CHART, version: this.harvesterUpdateVersion }, {}, UI_PLUGIN_NAMESPACE, 'upgrade');
await installHelmChart(
this.harvesterRepository,
{
...HARVESTER_CHART,
version: this.harvesterUpdateVersion
},
{},
UI_PLUGIN_NAMESPACE,
'upgrade'
);
const extension = await waitForUIExtension(this.$store, HARVESTER_CHART.name);

View File

@ -1,5 +1,10 @@
import { UI_PLUGINS_REPOS } from '@shell/config/uiplugins';
export const communityRepoRegexes = [
/^https:\/\/github\.com\/.*\/harvester-ui-extension+/g,
/^https:\/\/.*\.github\.io\/harvester-ui-extension+/g,
];
export const HARVESTER_CHART = {
name: 'harvester',
version: '',
@ -8,21 +13,13 @@ export const HARVESTER_CHART = {
};
export const HARVESTER_COMMUNITY_REPO = {
type: 'catalog.cattle.io.clusterrepo',
metadata: { name: 'harvester' },
spec: {
clientSecret: null,
gitRepo: 'https://github.com/harvester/harvester-ui-extension',
gitBranch: 'gh-pages'
}
gitBranch: 'gh-pages',
};
export const HARVESTER_RANCHER_REPO = {
type: 'catalog.cattle.io.clusterrepo',
metadata: { name: 'rancher' },
spec: {
clientSecret: null,
gitRepo: UI_PLUGINS_REPOS.OFFICIAL.URL,
gitBranch: UI_PLUGINS_REPOS.OFFICIAL.BRANCH,
}
};

View File

@ -0,0 +1 @@
module.exports = require('./.shell/pkg/babel.config.js');

View File

@ -0,0 +1,178 @@
<script>
import { defineComponent } from 'vue';
import { MANAGEMENT } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
import { _EDIT } from '@shell/config/query-params';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import { Checkbox } from '@components/Form/Checkbox';
import { getAllOptionsAfterCurrentVersion, filterOutDeprecatedPatchVersions } from '@shell/utils/cluster';
export default defineComponent({
name: 'Basics',
components: {
LabeledSelect, Checkbox, LabeledInput
},
props: {
mode: {
type: String,
default: _EDIT
},
versions: {
type: Array,
default: () => {
return [];
}
},
defaultVersion: {
type: String,
default: () => {
return '';
}
},
value: {
type: Object,
default: () => {
return {};
}
},
config: {
type: Object,
default: () => {
return {};
}
},
upgradeStrategy: {
type: Object,
default: () => {
return {};
}
},
loadingVersions: {
type: Boolean,
default: false
},
rules: {
default: () => ({
workerConcurrency: [],
controlPlaneConcurrency: []
}),
type: Object,
},
},
emits: ['kubernetes-version-changed', 'drain-server-nodes-changed', 'server-concurrency-changed',
'drain-worker-nodes-changed', 'worker-concurrency-changed', 'enable-authorized-endpoint', 'input'],
data() {
const store = this.$store;
const supportedVersionRange = store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.UI_SUPPORTED_K8S_VERSIONS)?.value;
const originalVersion = this.config.kubernetesVersion;
return {
supportedVersionRange, originalVersion, showDeprecatedPatchVersions: false
};
},
computed: {
versionOptions() {
const cur = this.originalVersion;
let out = getAllOptionsAfterCurrentVersion(this.$store, this.versions, cur, this.defaultVersion);
if (!this.showDeprecatedPatchVersions) {
// Normally, we only want to show the most recent patch version
// for each Kubernetes minor version. However, if the user
// opts in to showing deprecated versions, we don't filter them.
out = filterOutDeprecatedPatchVersions(out, cur);
}
const existing = out.find((x) => x.value === cur);
if (existing) {
existing.disabled = false;
}
return out;
}
},
});
</script>
<template>
<div>
<div class="row row-basics mb-20">
<div class="col-basics mr-10 span-6">
<LabeledSelect
v-model:value="config.kubernetesVersion"
data-testid="cruimported-kubernetesversion"
:mode="mode"
:options="versionOptions"
label-key="cluster.kubernetesVersion.label"
option-key="value"
option-label="label"
:loading="loadingVersions"
@update:value="$emit('kubernetes-version-changed', $event)"
/>
</div>
<div class="col-basics span-6 mt-15">
<Checkbox
v-model:value="showDeprecatedPatchVersions"
:label="t('cluster.kubernetesVersion.deprecatedPatches')"
:tooltip="t('cluster.kubernetesVersion.deprecatedPatchWarning')"
class="patch-version"
/>
</div>
</div>
<h3 v-t="'imported.upgradeStrategy.header'" />
<div class="col mt-10 mb-10">
<div class="col mt-5">
<Checkbox
:value="upgradeStrategy.drainServerNodes"
:mode="mode"
:label="t('imported.drainControlPlaneNodes.label')"
@update:value="$emit('drain-server-nodes-changed', $event)"
/>
</div>
<div class="col mt-5">
<Checkbox
:value="upgradeStrategy.drainWorkerNodes"
:mode="mode"
:label="t('imported.drainWorkerNodes.label')"
@update:value="$emit('drain-worker-nodes-changed', $event)"
/>
</div>
</div>
<div class="row row-basics">
<div class="col-basics mr-10 span-6">
<LabeledInput
:value="upgradeStrategy.serverConcurrency"
:mode="mode"
:label="t('cluster.rke2.controlPlaneConcurrency.label')"
:rules="rules.concurrency"
required
class="mb-10"
@update:value="$emit('server-concurrency-changed', $event)"
/>
</div>
<div class="col-basics span-6">
<LabeledInput
:value="upgradeStrategy.workerConcurrency"
:mode="mode"
:label="t('cluster.rke2.workerConcurrency.label')"
:rules="rules.concurrency"
required
class="mb-10"
@update:value="$emit('worker-concurrency-changed', $event)"
/>
</div>
</div>
</div>
</template>
<style>
@media screen and (max-width: 996px) {
.row-basics {
flex-direction: column;
}
.col-basics {
width: 100%
}
}
</style>

View File

@ -0,0 +1,396 @@
<script>
import { set } from '@shell/utils/object';
import { mapGetters } from 'vuex';
import { defineComponent } from 'vue';
import { allHash } from '@shell/utils/promise';
import isEmpty from 'lodash/isEmpty';
import { _CREATE, _EDIT } from '@shell/config/query-params';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import CruResource from '@shell/components/CruResource.vue';
import Loading from '@shell/components/Loading.vue';
import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
import Accordion from '@components/Accordion/Accordion.vue';
import Banner from '@components/Banner/Banner.vue';
import ClusterMembershipEditor, { canViewClusterMembershipEditor } from '@shell/components/form/Members/ClusterMembershipEditor.vue';
import Labels from '@shell/components/form/Labels.vue';
import Basics from '@pkg/imported/components/Basics.vue';
import ACE from '@shell/edit/provisioning.cattle.io.cluster/tabs/networking/ACE';
import { MANAGEMENT } from '@shell/config/types';
import KeyValue from '@shell/components/form/KeyValue';
import { Checkbox } from '@components/Form/Checkbox';
export default defineComponent({
name: 'CruImported',
components: {
Basics, ACE, Loading, CruResource, KeyValue, LabeledInput, Accordion, Banner, ClusterMembershipEditor, Labels, Checkbox
},
mixins: [CreateEditView, FormValidation],
props: {
mode: {
type: String,
default: _CREATE
},
// provisioning cluster object
value: {
type: Object,
default: () => {
return {};
}
}
},
async fetch() {
const store = this.$store;
if (this.value.id) {
const liveNormanCluster = await this.value.findNormanCluster();
this.normanCluster = await store.dispatch(`rancher/clone`, { resource: liveNormanCluster });
this.config = this.normanCluster.rke2Config || this.normanCluster.k3sConfig;
if ( this.normanCluster && isEmpty(this.normanCluster.localClusterAuthEndpoint) ) {
set(this.normanCluster, 'localClusterAuthEndpoint', { enabled: false });
}
if ( this.normanCluster && !this.normanCluster?.agentEnvVars) {
this.normanCluster.agentEnvVars = [];
}
this.getVersions();
}
},
data() {
return {
normanCluster: { name: '' },
loadingVersions: false,
membershipUpdate: {},
config: {},
allVersions: [],
defaultVer: '',
fvFormRuleSets: [{
path: 'workerConcurrency',
rules: ['workerConcurrencyRule']
},
{
path: 'controlPlaneConcurrency',
rules: ['controlPlaneConcurrencyRule']
},
],
};
},
created() {
this.registerAfterHook(this.saveRoleBindings, 'save-role-bindings');
},
computed: {
...mapGetters({ t: 'i18n/t' }),
fvExtraRules() {
return {
workerConcurrencyRule: () => {
const val = this?.normanCluster?.k3sConfig?.k3supgradeStrategy?.workerConcurrency || this?.normanCluster?.rke2Config?.rke2upgradeStrategy?.workerConcurrency || '';
const exists = this?.normanCluster?.k3sConfig?.k3supgradeStrategy || this?.normanCluster?.rke2Config?.rke2upgradeStrategy;
// BE is only checking that the value is an integer >= 1
const valIsInvalid = Number(val) < 1 || !Number.isInteger(+val) || `${ val }`.match(/\.+/g);
return !!exists && valIsInvalid ? this.t('imported.errors.concurrency', { key: 'Worker Concurrency' }) : undefined ;
},
controlPlaneConcurrencyRule: () => {
const val = this?.normanCluster?.k3sConfig?.k3supgradeStrategy?.serverConcurrency || this?.normanCluster?.rke2Config?.rke2upgradeStrategy?.serverConcurrency || '';
const exists = this?.normanCluster?.k3sConfig?.k3supgradeStrategy || this?.normanCluster?.rke2Config?.rke2upgradeStrategy;
// BE is only checking that the value is an integer >= 1
const valIsInvalid = Number(val) < 1 || !Number.isInteger(+val) || `${ val }`.match(/\.+/g);
return !!exists && valIsInvalid ? this.t('imported.errors.concurrency', { key: 'Control Plane Concurrency' }) : undefined ;
},
};
},
upgradeStrategy: {
get() {
if ( this.normanCluster?.rke2Config ) {
return this.normanCluster.rke2Config?.rke2upgradeStrategy;
}
return this.normanCluster?.k3sConfig?.k3supgradeStrategy;
},
set(newValue) {
if ( this.normanCluster?.rke2Config ) {
this.normanCluster.rke2Config.rke2upgradeStrategy = newValue;
}
this.normanCluster.k3sConfig.k3supgradeStrategy = newValue;
}
},
isEdit() {
return this.mode === _CREATE || this.mode === _EDIT;
},
isK3s() {
return !!this.value.isK3s;
},
isRke2() {
return !!this.value.isRke2;
},
enableNetworkPolicySupported() {
// https://github.com/rancher/rancher/pull/33070/files
return !this.isK3s && !this.isRke2;
},
isLocal() {
return !!this.value.isLocal;
},
doneRoute() {
return this.value?.listLocation?.name;
},
canManageMembers() {
return canViewClusterMembershipEditor(this.$store);
},
providerTabKey() {
return this.isK3s ? this.t('imported.accordions.k3sOptions') : this.t('imported.accordions.rke2Options');
},
// If the cluster hasn't been fully imported yet, we won't have this information yet
// and Basics should be hidden
showBasics() {
return !!this.config;
}
},
methods: {
onMembershipUpdate(update) {
this.membershipUpdate = update;
},
async saveRoleBindings() {
if (this.membershipUpdate.save) {
await this.membershipUpdate.save(this.normanCluster.id);
}
},
async actuallySave() {
return await this.normanCluster.save();
},
async getVersions() {
this.loadingVersions = true;
this.versionOptions = [];
try {
const globalSettings = await this.$store.getters['management/all'](MANAGEMENT.SETTING) || [];
let hash = {};
if (this.isK3s) {
hash = { versions: this.$store.dispatch('management/request', { url: '/v1-k3s-release/releases' }) };
const defaultK3sSetting = globalSettings.find((setting) => setting.id === 'k3s-default-version') || {};
this.defaultVersion = defaultK3sSetting?.value || defaultK3sSetting?.default;
// Use the channel if we can not get the version from the settings
if (!this.defaultVersion) {
hash.channels = this.$store.dispatch('management/request', { url: '/v1-k3s-release/channels' });
}
} else {
hash = { versions: this.$store.dispatch('management/request', { url: '/v1-rke2-release/releases' }) };
const defaultRke2Setting = globalSettings.find((setting) => setting.id === 'rke2-default-version') || {};
this.defaultVersion = defaultRke2Setting?.value || defaultRke2Setting?.default;
if (!this.defaultVersion) {
hash.channels = this.$store.dispatch('management/request', { url: '/v1-rke2-release/channels' });
}
}
const res = await allHash(hash);
this.allVersions = res.versions?.data || [];
if (!this.defaultVersion) {
const channels = res.channels?.data || [];
this.defaultVersion = channels.find((x) => x.id === 'default')?.latest;
}
this.loadingVersions = false;
} catch (err) {
this.loadingVersions = false;
const errors = this.errors;
errors.push(this.t('imported.errors.kubernetesVersions', { e: err.error || err }));
}
},
kubernetesVersionChanged(val) {
if ( !this.isK3s ) {
this.normanCluster.rke2Config.kubernetesVersion = val;
} else {
this.normanCluster.k3sConfig.kubernetesVersion = val;
}
},
enableLocalClusterAuthEndpoint(neu) {
this.normanCluster.localClusterAuthEndpoint.enabled = neu;
if (!!neu) {
this.normanCluster.localClusterAuthEndpoint.caCerts = '';
this.normanCluster.localClusterAuthEndpoint.fqdn = '';
} else {
delete this.normanCluster.localClusterAuthEndpoint.caCerts;
delete this.normanCluster.localClusterAuthEndpoint.fqdn;
}
},
},
});
</script>
<template>
<CruResource
:resource="value"
:mode="mode"
:can-yaml="false"
:done-route="doneRoute"
:errors="fvUnreportedValidationErrors"
:validation-passed="fvFormIsValid"
@error="e=>errors=e"
@finish="save"
>
<Loading
v-if="$fetchState.pending"
mode="relative"
/>
<div v-else>
<div class="mt-10">
<div class="row mb-10">
<div class="col span-3">
<LabeledInput
v-model:value="normanCluster.name"
:mode="mode"
:disabled="true"
label-key="generic.name"
data-testid="imported-name"
/>
</div>
<div
v-if="isLocal"
class="col span-3"
>
<LabeledInput
v-model:value="normanCluster.description"
:mode="mode"
label-key="nameNsDescription.description.label"
:placeholder="t('nameNsDescription.description.placeholder')"
/>
</div>
</div>
</div>
<Accordion
v-if="showBasics"
:title="providerTabKey"
:open-initially="true"
class="mb-20"
>
<Basics
:value="normanCluster"
:mode="mode"
:config="config"
:upgrade-strategy="upgradeStrategy"
:versions="allVersions"
:default-version="defaultVersion"
:loading-versions="loadingVersions"
:rules="{workerConcurrency: fvGetAndReportPathRules('workerConcurrency'), controlPlaneConcurrency: fvGetAndReportPathRules('controlPlaneConcurrency') }"
@kubernetes-version-changed="kubernetesVersionChanged"
@drain-server-nodes-changed="(val)=>upgradeStrategy.drainServerNodes = val"
@drain-worker-nodes-changed="(val)=>upgradeStrategy.drainWorkerNodes = val"
@server-concurrency-changed="(val)=>upgradeStrategy.serverConcurrency = val"
@worker-concurrency-changed="(val)=>upgradeStrategy.workerConcurrency = val"
/>
</Accordion>
<Accordion
class="mb-20"
title-key="imported.accordions.clusterMembers"
:open-initially="true"
>
<Banner
v-if="isEdit"
color="info"
>
{{ t('cluster.memberRoles.removeMessage') }}
</Banner>
<ClusterMembershipEditor
v-if="canManageMembers"
:mode="mode"
:parent-id="normanCluster.id ? normanCluster.id : null"
@membership-update="onMembershipUpdate"
/>
</Accordion>
<Accordion
class="mb-20"
title-key="imported.accordions.labels"
:open-initially="true"
>
<Labels
v-model:value="normanCluster"
:mode="mode"
/>
</Accordion>
<Accordion
class="mb-20"
title-key="imported.accordions.networking"
:open-initially="true"
>
<div
v-if="enableNetworkPolicySupported"
class="mb-20"
>
<Banner
v-if="!!normanCluster.enableNetworkPolicy"
color="info"
label-key="imported.network.banner"
/>
<Checkbox
v-model:value="normanCluster.enableNetworkPolicy"
:mode="mode"
:label="t('cluster.rke2.enableNetworkPolicy.label')"
/>
</div>
<h3 v-t="'cluster.tabs.ace'" />
<ACE
v-model:value="normanCluster.localClusterAuthEndpoint"
:mode="mode"
@local-cluster-auth-endpoint-changed="enableLocalClusterAuthEndpoint"
@ca-certs-changed="(val)=>normanCluster.localClusterAuthEndpoint.caCerts = val"
@fqdn-changed="(val)=>normanCluster.localClusterAuthEndpoint.fqdn = val"
/>
</Accordion>
<Accordion
class="mb-20"
title-key="imported.accordions.advanced"
:open-initially="false"
>
<h3>
{{ t('imported.agentEnv.header') }}
</h3>
<KeyValue
v-model:value="normanCluster.agentEnvVars"
:mode="mode"
key-name="name"
:as-map="false"
:preserve-keys="['valueFrom']"
:supported="(row) => typeof row.valueFrom === 'undefined'"
:read-allowed="true"
:value-can-be-empty="true"
:key-label="t('cluster.agentEnvVars.keyLabel')"
:parse-lines-from-file="true"
/>
</Accordion>
</div>
</CruResource>
</template>
<style lang="scss" scoped>
</style>

16
pkg/imported/index.ts Normal file
View File

@ -0,0 +1,16 @@
import { importTypes } from '@rancher/auto-import';
import { IPlugin } from '@shell/core/types';
import { ImportedProvisioner, LocalProvisioner } from './provisioner';
// Init the package
export default function(plugin: IPlugin): void {
// Auto-import model, detail, edit from the folders
importTypes(plugin);
// Provide plugin metadata from package.json
plugin.metadata = require('./package.json');
// Register custom provisioner object
plugin.register('provisioner', ImportedProvisioner.ID, ImportedProvisioner);
plugin.register('provisioner', LocalProvisioner.ID, LocalProvisioner);
}

View File

@ -0,0 +1,24 @@
imported:
label: Imported
agentEnv:
header: Agent Environment Variables
drainWorkerNodes:
label: Drain Worker Nodes
drainControlPlaneNodes:
label: Drain Control Plane Nodes
network:
banner: The imported cluster must support Kubernetes NetworkPolicy resources for Project Network Isolation to be enforced
accordions:
advanced: Advanced
basics: Basics
upgrade: Upgrade Strategy
networking: Networking
clusterMembers: Cluster Members
labels: Labels and Annotations
k3sOptions: K3S Options
rke2Options: RKE2 Options
upgradeStrategy:
header: Upgrade Strategy
errors:
concurrency: '{key} must be an integer greater than 0'
kubernetesVersions: 'An error occured while fetching the available kubernetes versions: {e}'

28
pkg/imported/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "imported",
"description": "Edit imported clusters in Rancher",
"version": "0.1.0",
"private": false,
"rancher": {
"annotations": {
"catalog.cattle.io/display-name": "Edit imported cluster"
}
},
"scripts": {
"dev": "./node_modules/.bin/nuxt dev",
"nuxt": "./node_modules/.bin/nuxt"
},
"engines": {
"node": ">=20.0.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

View File

@ -0,0 +1,51 @@
import { IClusterProvisioner } from '@shell/core/types';
import CruImported from './components/CruImported.vue';
import { Component } from 'vue';
export class ImportedProvisioner implements IClusterProvisioner {
static ID = 'imported'
get id(): string {
return ImportedProvisioner.ID;
}
get component(): Component {
return CruImported;
}
get detailTabs(): any {
return {};
}
get hidden(): boolean {
return true;
}
get group(): string {
return 'kontainer';
}
}
export class LocalProvisioner implements IClusterProvisioner {
static ID = 'local'
get id(): string {
return LocalProvisioner.ID;
}
get component(): Component {
return CruImported;
}
get detailTabs(): any {
return {};
}
get hidden(): boolean {
return true;
}
get group(): string {
return 'kontainer';
}
}

View File

@ -0,0 +1,54 @@
{
"compilerOptions": {
"allowJs": true,
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"preserveSymlinks": true,
"typeRoots": [
"../../node_modules",
"../../shell/types",
"./types"
],
"types": [
"node",
"webpack-env",
"@types/node",
"@types/jest",
"@types/lodash",
"rancher",
"shell"
],
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
],
"paths": {
"@shell/*": [
"../../shell/*"
],
"@components/*": [
"@rancher/components/*"
]
}
},
"include": [
"**/*.ts",
"**/*.d.ts",
"**/*.tsx",
"**/*.vue"
],
"exclude": [
"../../node_modules"
]
}

View File

@ -0,0 +1 @@
module.exports = require('./.shell/pkg/vue.config')(__dirname);

View File

@ -36,7 +36,7 @@
"babel-eslint": "10.1.0",
"core-js": "3.40.0",
"cron-validator": "1.3.1",
"cronstrue": "2.50.0",
"cronstrue": "2.53.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "5.2.0",

View File

@ -47,12 +47,12 @@ export default defineComponent({
data-testid="accordion-chevron"
/>
<slot name="header">
<h4
<h2
data-testid="accordion-title-slot-content"
class="mb-0"
>
{{ titleKey ? t(titleKey) : title }}
</h4>
</h2>
</slot>
</div>
<div
@ -70,7 +70,7 @@ export default defineComponent({
border: 1px solid var(--border)
}
.accordion-header {
padding: 5px;
padding: 16px 16px 16px 11px;
display: flex;
align-items: center;
&>*{
@ -81,6 +81,6 @@ export default defineComponent({
}
}
.accordion-body {
padding: 10px;
padding: 0px 16px 16px;
}
</style>

View File

@ -94,6 +94,13 @@ export default defineComponent({
background: transparent;
border-color: var(--success);
}
// Added badge-disabled instead of bg-disabled since bg-disabled is used in other places with !important styling, an investigation is needed to make the naming consistent
&.badge-disabled {
color: var(--badge-state-disabled-text);
background-color: var( --badge-state-disabled-bg);
border: 1px solid var(--badge-state-disabled-border);
}
}
</style>
<style lang="scss">

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { createFocusTrap, FocusTrap } from 'focus-trap';
export default defineComponent({
name: 'Card',
@ -50,12 +51,37 @@ export default defineComponent({
type: Boolean,
default: false,
},
triggerFocusTrap: {
type: Boolean,
default: false,
},
},
data() {
return { focusTrapInstance: {} as FocusTrap };
},
mounted() {
if (this.triggerFocusTrap) {
this.focusTrapInstance = createFocusTrap(this.$refs.cardContainer as HTMLElement, {
escapeDeactivates: true,
allowOutsideClick: true,
});
this.$nextTick(() => {
this.focusTrapInstance.activate();
});
}
},
beforeUnmount() {
if (this.focusTrapInstance && this.triggerFocusTrap) {
this.focusTrapInstance.deactivate();
}
},
});
</script>
<template>
<div
ref="cardContainer"
class="card-container"
:class="{'highlight-border': showHighlightBorder, 'card-sticky': sticky}"
data-testid="card"

View File

@ -264,13 +264,15 @@ export default defineComponent({
<template v-else-if="label">{{ label }}</template>
<i
v-if="tooltipKey"
v-clean-tooltip="t(tooltipKey)"
v-clean-tooltip="{content: t(tooltipKey), triggers: ['hover', 'touch', 'focus']}"
class="checkbox-info icon icon-info icon-lg"
:tabindex="isDisabled ? -1 : 0"
/>
<i
v-else-if="tooltip"
v-clean-tooltip="tooltip"
v-clean-tooltip="{content: tooltip, triggers: ['hover', 'touch', 'focus']}"
class="checkbox-info icon icon-info icon-lg"
:tabindex="isDisabled ? -1 : 0"
/>
</slot>
</span>
@ -329,6 +331,11 @@ $fontColor: var(--input-label);
.checkbox-info {
line-height: normal;
margin-left: 2px;
&:focus-visible {
@include focus-outline;
outline-offset: 2px;
}
}
.checkbox-custom {

View File

@ -20,7 +20,7 @@ describe('component: LabeledInput', () => {
expect(wrapper.emitted('update:value')![0][0]).toBe(value);
});
it('using mode "multiline" should emit input value correctly', () => {
it('using type "multiline" should emit input value correctly', () => {
const value = 'any-string';
const delay = 1;
const wrapper = mount(LabeledInput, {
@ -37,4 +37,21 @@ describe('component: LabeledInput', () => {
expect(wrapper.emitted('update:value')).toHaveLength(1);
expect(wrapper.emitted('update:value')![0][0]).toBe(value);
});
describe('using type "chron"', () => {
it.each([
['0 * * * *', 'Every hour, every day'],
['@daily', 'At 12:00 AM, every day'],
['You must fail! Go!', '%generic.invalidCron%'],
])('passing value %p should display hint %p', (value, hint) => {
const wrapper = mount(LabeledInput, {
propsData: { value, type: 'cron' },
mocks: { $store: { getters: { 'i18n/t': jest.fn() } } }
});
const subLabel = wrapper.find('[data-testid="sub-label"]');
expect(subLabel.text()).toBe(hint);
});
});
});

View File

@ -179,14 +179,28 @@ export default defineComponent({
if (this.type !== 'cron' || !this.value) {
return;
}
// TODO - #13202: This is required due use of 2 libraries and 3 different libraries through the code.
const predefined = [
'@yearly',
'@annually',
'@monthly',
'@weekly',
'@daily',
'@midnight',
'@hourly'
];
const isPredefined = predefined.includes(this.value as string);
// refer https://github.com/GuillaumeRochat/cron-validator#readme
if (!isValidCron(this.value as string, {
if (!isPredefined && !isValidCron(this.value as string, {
alias: true,
allowBlankDay: true,
allowSevenAsSunday: true,
})) {
return this.t('generic.invalidCron');
}
try {
const hint = cronstrue.toString(this.value as string || '', { verbose: true });
@ -382,6 +396,7 @@ export default defineComponent({
<div
v-if="cronHint || subLabel"
class="sub-label"
data-testid="sub-label"
>
<div
v-if="cronHint"

View File

@ -243,14 +243,8 @@ $fontColor: var(--input-label);
min-width: 14px;
background-color: var(--input-bg);
border-radius: 50%;
transition: all 0.3s ease-out;
border: 1.5px solid var(--border);
margin-top: 5px;
&:focus {
outline: none;
border-radius: 50%;
}
}
input {

View File

@ -145,6 +145,9 @@ export default defineComponent({
*/
isDisabled(): boolean {
return (this.disabled || this.isView);
},
radioGroupLabel(): string {
return this.labelKey ? this.t(this.labelKey) : this.label ? this.label : '';
}
},
@ -202,9 +205,10 @@ export default defineComponent({
<!-- Group -->
<div
role="radiogroup"
:aria-label="radioGroupLabel"
class="radio-group"
:class="{'row':row}"
tabindex="0"
@keyup.down.stop="clickNext(1)"
@keyup.up.stop="clickNext(-1)"
>

View File

@ -62,12 +62,30 @@ INPUT,
SELECT,
TEXTAREA,
.labeled-input,
.labeled-select,
.unlabeled-select,
.checkbox-custom,
.radio-custom {
.checkbox-custom {
&:focus, &.focused {
@include form-focus
@include form-focus;
}
}
.radio-custom,
.labeled-select,
.unlabeled-select {
&:focus-visible, &.focused {
@include focus-outline;
}
}
.labeled-select,
.unlabeled-select {
&.focused {
border-color: var(--outline);
}
}
.unlabeled-select {
&:focus-visible, &.focused {
@include focus-outline;
}
}

View File

@ -158,5 +158,6 @@
@mixin focus-outline {
// Focus for form like elements (not to be confused with basic :focus style)
// we need to use !important because it needs to superseed other classes that might impact outlines
outline: 2px solid var(--primary-keyboard-focus);
}

View File

@ -141,6 +141,11 @@ button,
color: var(--link);
box-shadow: none;
}
&:focus-visible {
@include focus-outline;
outline-offset: 2px;
}
}
.role-multi-action {
@ -185,6 +190,11 @@ fieldset[disabled] .btn {
z-index: 1;
}
&:focus-visible {
z-index: 1;
@include focus-outline;
}
&.active {
@extend .bg-primary;
}

View File

@ -27,8 +27,8 @@ TEXTAREA,
@include input-status-color;
&:focus, &.focused {
@include form-focus
&:focus:not(.unlabeled-select):not(.labeled-select), &.focused:not(.unlabeled-select):not(.labeled-select) {
@include form-focus;
}
LABEL {

View File

@ -219,4 +219,8 @@
--product-icon : #{$lighter};
--product-icon-active : #{$lightest};
--badge-state-disabled-text : #{$desc-light};
--badge-state-disabled-bg : #{$medium};
--badge-state-disabled-border: #{$lighter};
}

View File

@ -544,4 +544,8 @@ BODY, .theme-light {
--product-icon : #{$darker};
--product-icon-active : #{$darkest};
--badge-state-disabled-text : #{$darker};
--badge-state-disabled-bg : #{$lighter};
--badge-state-disabled-border: #{$medium};
}

View File

@ -74,6 +74,10 @@
color: var(--dropdown-text);
white-space: nowrap;
z-index: 1000;
overflow-y: auto;
// needs overflow-x hidden so that select for type of charts in cluster chart page
// doesn't show a horizontal scroll https://github.com/rancher/dashboard/pull/13139/files/9e6a8e64181de4525ad378d4d1b93c8efc2ed6d5#r1929296126
overflow-x: hidden;
&:hover {
cursor: pointer;

View File

@ -9,6 +9,7 @@ generic:
back: Back
cancel: Cancel
confirm: Confirm
colorPicker: Color picker
clear: Clear
clearAll: Clear All
close: Close
@ -134,6 +135,8 @@ nav:
support: Support page link
about: About page link
pinCluster: Pin/Unpin cluster
collapseExpand: Collapse/Expand menu group
productAboutPage: Product about page link
alt:
mainMenuIcon: Main menu icon
mainMenuRancherLogo: Main menu Rancher logo
@ -514,6 +517,9 @@ authConfig:
starttls:
label: Start TLS
tip: Upgrades non-encrypted connections by wrapping with TLS during the connection process. Can not be used in conjunction with TLS.
searchUsingServiceAccount:
label: Enable Service Account Search
tip: When enabled, Rancher will use the service account instead of the user account to search for users and groups.
tls: TLS
userEnabledAttribute: User Enabled Attribute
userMemberAttribute: User Member Attribute
@ -933,6 +939,7 @@ catalog:
experimentalWarning: '{chartName} has been marked as experimental. Use caution when installing this helm chart as it might not function as expected.'
deprecatedAndExperimentalWarning: '{chartName} has been marked as deprecated and experimental. Use caution when installing this helm chart as it might be removed in the future and might not function as expected.'
charts:
refresh: refresh charts
all: All
categories:
all: All Categories
@ -952,7 +959,7 @@ catalog:
all: All Operating Systems
linux: Linux
windows: Windows
search: Filter
search: Filter charts results
deprecatedChartsFilter:
label: Show deprecated apps
install:
@ -963,7 +970,7 @@ catalog:
chart: Chart
warning:
managed:
Warning, Rancher manages deployment and upgrade of the {name} app. Upgrading this app is not supported.<br>
Warning, {manager} manages deployment and upgrade of the {name} app. Upgrading this app is not supported.<br>
{version, select,
null { }
other { Under most circumstances, no user intervention should be needed to ensure that the {version} version is compatible with the version of Rancher that you are running.}
@ -1063,7 +1070,7 @@ catalog:
windows: '{ver} (Windows-only)'
delete:
warning:
managed: Warning, Rancher manages deployment and upgrade of the {name} app. Deleting this app is not supported.
managed: Warning, {manager} manages deployment and upgrade of the {name} app. Deleting this app is not supported.
operation:
tableHeaders:
action: Action
@ -2076,6 +2083,7 @@ cluster:
label: Control Plane Concurrency
toolTip: "This can be either a fixed number of nodes (e.g. 1) at a time or a percentage (e.g. 10%)"
workerConcurrency:
header: Worker Nodes
label: Worker Concurrency
toolTip: "This can be either a fixed number of nodes (e.g. 1) at a time or a percentage (e.g. 10%)"
drain:
@ -2111,6 +2119,7 @@ cluster:
tlsSan:
label: TLS Alternate Names
fqdn:
label: FQDN
toolTip: A FQDN which will resolve to the healthy control plane nodes of the cluster.
caCerts:
label: CA Certificates
@ -2383,6 +2392,9 @@ fleet:
cluster:
summary: Resource Summary
nonReady: Non-Ready Bundles
labels: Labels
hideLabels: Show less
showLabels: Show more
clusters:
harvester: |-
There {count, plural,
@ -6032,6 +6044,8 @@ validation:
flowOutput:
both: Requires "Output" or "Cluster Output" to be selected.
global: Requires "Cluster Output" to be selected.
git:
repository: Repository URL must be a HTTP(s) or SSH url with no trailing spaces
output:
logdna:
apiKey: Required an "Api Key" to be set.
@ -6135,6 +6149,11 @@ validation:
interval: '"{key}" must be of a format with digits followed by a unit i.e. 1h, 2m, 30s'
tab: "One or more fields in this tab contain a form validation error"
carousel:
previous: Previous
next: Next
controlItem: Go to slide nº {number}
wizard:
previous: Previous
finish: Finish
@ -6178,7 +6197,7 @@ wm:
containerShell:
clear: Clear
containerName: "Container: {label}"
failed: "Unable to open a shell to the container (none of the shell commmands succeeded)\n\r"
failed: "Unable to open a shell to the container (none of the shell commands succeeded)\n\r"
logLevel:
info: INFO
error: ERROR
@ -6191,6 +6210,11 @@ wm:
title: "Kubectl: {name}"
workload:
scaleWorkloads: Scale workloads
healthWorkloads: Jobs/Pods health status
healthScaleToggle: Toggle/Expand workloads scaling/health
plus: Scale up workload
minus: Scale down workload
container:
command:
addEnvVar: Add Variable
@ -7434,6 +7458,8 @@ registryConfig:
##############################
advancedSettings:
setEnv: Set by Environment Variable
hideShow: Hide/show setting
label: Settings
subtext: Typical users will not need to change these. Proceed with caution, incorrect values can break your {appName} installation. Settings which have been customized from default settings are tagged 'Modified'.
show: Show

View File

@ -82,6 +82,8 @@ export default {
type="button"
:class="opt.class"
:disabled="disabled || opt.disabled"
role="button"
:aria-label="opt.labelKey ? t(opt.labelKey) : opt.label"
@click="change(opt.value)"
>
<slot

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineEmits } from 'vue';
import { computed } from 'vue';
defineEmits(['click']);

View File

@ -195,6 +195,36 @@ export default {
</div>
</component>
</div>
<div
ref="prev"
role="button"
class="prev"
:aria-label="t('carousel.previous')"
:aria-disabled="sliders.length === 1"
:class="{'disable': sliders.length === 1}"
tabindex="0"
@click="nextPrev('prev')"
@keyup.enter.space="nextPrev('prev')"
>
<i
class="icon icon-chevron-left icon-4x"
/>
</div>
<div
ref="next"
role="button"
class="next"
:aria-label="t('carousel.next')"
:aria-disabled="sliders.length === 1"
:class="{'disable': sliders.length === 1}"
tabindex="0"
@click="nextPrev('next')"
@keyup.enter.space="nextPrev('next')"
>
<i
class="icon icon-chevron-right icon-4x"
/>
</div>
<div
class="controls"
:class="{'disable': sliders.length === 1}"
@ -204,25 +234,13 @@ export default {
:key="i"
class="control-item"
:class="{'active': activeItemId === i}"
role="button"
tabindex="0"
:aria-label="t('carousel.controlItem', { number: i+1 })"
@click="scrollSlide(i, slider.length)"
@keyup.enter.space="scrollSlide(i, slider.length)"
/>
</div>
<div
ref="prev"
class="prev"
:class="{'disable': sliders.length === 1}"
@click="nextPrev('prev')"
>
<i class="icon icon-chevron-left icon-4x" />
</div>
<div
ref="next"
class="next"
:class="{'disable': sliders.length === 1}"
@click="nextPrev('next')"
>
<i class="icon icon-chevron-right icon-4x" />
</div>
</div>
</template>
@ -240,20 +258,6 @@ export default {
&.disable::after {
display: none;
}
&.disable:hover {
.prev,
.next {
display: none;
}
}
&:hover {
.prev,
.next {
display: block;
}
}
}
.slide-track {
@ -367,13 +371,16 @@ export default {
position: absolute;
z-index: 20;
top: 90px;
display: none;
cursor: pointer;
&.disabled .icon {
color: var(--disabled-bg);
cursor: not-allowed;
}
.icon:focus-visible {
@include focus-outline;
}
}
.next {

View File

@ -11,6 +11,10 @@ export default {
mode: {
type: String,
default: ''
},
showIcon: {
type: Boolean,
default: true
}
},
@ -89,7 +93,10 @@ export default {
class="locale-chooser"
>
{{ selectedLocaleLabel }}
<i class="icon icon-fw icon-sort-down" />
<i
v-if="showIcon"
class="icon icon-fw icon-sort-down"
/>
</a>
<template #popper>
<ul

View File

@ -2,13 +2,6 @@
import { defineComponent } from 'vue';
import ResourceFetch from '@shell/mixins/resource-fetch';
import ResourceTable from '@shell/components/ResourceTable.vue';
import { StorePaginationResult } from '@shell/types/store/pagination.types';
export type FetchSecondaryResourcesOpts = { canPaginate: boolean }
export type FetchSecondaryResources = (opts: FetchSecondaryResourcesOpts) => Promise<any>
export type FetchPageSecondaryResourcesOpts = { canPaginate: boolean, force: boolean, page: any[], pagResult: StorePaginationResult }
export type FetchPageSecondaryResources = (opts: FetchPageSecondaryResourcesOpts) => Promise<any>
/**
* This is meant to enable ResourceList like capabilities outside of List pages / components
@ -57,6 +50,8 @@ export default defineComponent({
* Information may be required from resources other than the primary one shown per row
*
* This will fetch them ALL and will be run in a non-server-side pagination world
*
* of type PagTableFetchSecondaryResources
*/
fetchSecondaryResources: {
type: Function,
@ -69,6 +64,8 @@ export default defineComponent({
* This will fetch only those relevant to the current page using server-side pagination based filters
*
* called from shell/mixins/resource-fetch-api-pagination.js
*
* of type PagTableFetchPageSecondaryResources
*/
fetchPageSecondaryResources: {
type: Function,

View File

@ -90,6 +90,19 @@ export default {
return out.filter((obj) => obj.percent);
},
ariaLabelText() {
if (Array.isArray(this.values) && this.values.length) {
let ariaLabel = '';
this.values.forEach((val) => {
ariaLabel += `${ val.value } ${ val.value === 1 ? 'item' : 'items' } ${ val.label }`;
});
return ariaLabel;
}
return '';
}
}
};
@ -108,6 +121,7 @@ function toPercent(value, min, max) {
<div
v-trim-whitespace
:class="{progress: true, multi: pieces.length > 1}"
:aria-label="ariaLabelText"
>
<div
v-for="(piece, idx) of pieces"

View File

@ -1,7 +1,8 @@
<script>
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { filterBy } from '@shell/utils/array';
import ResourceLabeledSelect from '@shell/components/form/ResourceLabeledSelect.vue';
import { PaginationParamFilter } from '@shell/types/store/pagination.types';
import { PVC, STORAGE_CLASS } from '@shell/config/types';
import Question from './Question';
@ -14,7 +15,7 @@ const LEGACY_MAP = {
export default {
emits: ['update:value'],
components: { LabeledInput, LabeledSelect },
components: { LabeledInput, ResourceLabeledSelect },
mixins: [Question],
props: {
@ -29,12 +30,6 @@ export default {
},
},
async fetch() {
if ( this.typeSchema ) {
this.all = await this.$store.dispatch(`${ this.inStore }/findAll`, { type: this.typeName });
}
},
data() {
const t = this.question.type;
@ -58,28 +53,60 @@ export default {
typeName,
typeSchema,
all: [],
allResourceSettings: {
updateResources: (all) => {
// Filter to only include required namespaced resources
const resources = this.isNamespaced ? all.filter((r) => r.metadata.namespace === this.targetNamespace) : all;
return this.mapResourcesToOptions(resources);
}
},
paginateResourceSetting: {
updateResources: (resources) => {
return this.mapResourcesToOptions(resources);
},
/**
* of type PaginateTypeOverridesFn
* @param [LabelSelectPaginationFunctionOptions] opts
* @returns LabelSelectPaginationFunctionOptions
*/
requestSettings: (opts) => {
// Filter to only include required namespaced resources
const filters = this.isNamespaced ? [
PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: this.targetNamespace }),
] : [];
return {
...opts,
filters,
groupByNamespace: false,
classify: true,
};
}
},
};
},
methods: {
mapResourcesToOptions(resources) {
return resources.map((r) => {
if (r.id) {
return {
label: r.nameDisplay || r.metadata.name,
value: r.metadata.name
};
} else {
return r;
}
});
},
},
computed: {
isNamespaced() {
return !!this.typeSchema?.attributes?.namespaced;
},
options() {
let out = this.all;
if ( this.isNamespaced ) {
out = filterBy(this.all, 'metadata.namespace', this.targetNamespace);
}
return out.map((x) => {
return {
label: x.nameDisplay || x.metadata.name,
value: x.metadata.name
};
});
}
},
};
</script>
@ -90,15 +117,17 @@ export default {
class="row"
>
<div class="col span-6">
<LabeledSelect
:mode="mode"
:options="options"
<ResourceLabeledSelect
:resource-type="typeName"
:in-store="inStore"
:disabled="$fetchState.pending || disabled"
:label="displayLabel"
:placeholder="question.description"
:required="question.required"
:value="value"
:tooltip="displayTooltip"
:paginated-resource-settings="paginateResourceSetting"
:all-resources-settings="allResourceSettings"
@update:value="!$fetchState.pending && $emit('update:value', $event)"
/>
</div>

View File

@ -1,50 +0,0 @@
<script>
import {
STATE, NAME, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, TYPE, AGE
} from '@shell/config/table-headers';
import { WORKLOAD_TYPES } from '@shell/config/types';
import ResourceTable from '@shell/components/ResourceTable';
export default {
components: { ResourceTable },
props: {
filter: {
type: Function,
required: true
}
},
async fetch() {
// Enumerating instead of using Object.values() because it looks like we don't want to include replica sets for this.
const types = [
WORKLOAD_TYPES.DEPLOYMENT,
WORKLOAD_TYPES.CRON_JOB,
WORKLOAD_TYPES.DAEMON_SET,
WORKLOAD_TYPES.JOB,
WORKLOAD_TYPES.STATEFUL_SET
];
const allWorkloadsNested = await Promise.all(types.map((type) => this.$store.dispatch('cluster/findAll', { type })));
const allWorkloads = allWorkloadsNested.flat();
this.relatedWorkloadRows = allWorkloads.filter(this.filter);
},
data() {
const relatedWorkloadHeaders = [STATE, NAME, WORKLOAD_IMAGES, WORKLOAD_ENDPOINTS, TYPE, AGE];
return {
relatedWorkloadRows: [],
relatedWorkloadHeaders,
schema: this.$store.getters['cluster/schemaFor'](WORKLOAD_TYPES.DEPLOYMENT)
};
},
};
</script>
<template>
<div>
<ResourceTable
:schema="schema"
:rows="relatedWorkloadRows"
:headers="relatedWorkloadHeaders"
/>
</div>
</template>

View File

@ -443,6 +443,9 @@ export default {
<router-link
v-if="location"
:to="location"
role="link"
class="masthead-resource-list-link"
:aria-label="parent.displayName"
>
{{ parent.displayName }}:
</router-link>
@ -584,10 +587,10 @@ export default {
}
HEADER {
margin: 0;
margin: 0 0 0 -5px;
.title {
overflow: hidden;
overflow-x: hidden;
}
}
@ -598,7 +601,7 @@ export default {
h1 {
margin: 0;
overflow: hidden;
overflow-x: hidden;
display: flex;
flex-direction: row;
align-items: center;
@ -606,9 +609,13 @@ export default {
.masthead-resource-title {
padding: 0 8px;
text-overflow: ellipsis;
overflow: hidden;
overflow-x: hidden;
white-space: nowrap;
}
.masthead-resource-list-link {
margin: 5px;
}
}
}

View File

@ -104,6 +104,10 @@ export default {
:is="asLink ? 'a' : 'div'"
v-for="(r, idx) in rows"
:key="get(r, keyField)"
:role="asLink ? 'link' : null"
:aria-disabled="asLink && get(r, disabledField) === true ? true : null"
:aria-label="get(r, nameField)"
:tabindex="get(r, disabledField) === true ? -1 : 0"
:href="asLink ? get(r, linkField) : null"
:target="get(r, targetField)"
:rel="rel"
@ -111,9 +115,12 @@ export default {
:data-testid="componentTestid + '-' + get(r, nameField)"
:class="{
'has-description': !!get(r, descriptionField),
'has-side-label': !!get(r, sideLabelField), [colorFor(r, idx)]: true, disabled: get(r, disabledField) === true
'has-side-label': !!get(r, sideLabelField),
[colorFor(r, idx)]: true,
disabled: get(r, disabledField) === true
}"
@click="select(r, idx)"
@keyup.enter.space="select(r, idx)"
>
<div
class="side-label"
@ -212,6 +219,10 @@ export default {
text-decoration: none !important;
color: $color;
&:focus-visible {
@include focus-outline;
}
&:hover:not(.disabled) {
box-shadow: 0 0 30px var(--shadow);
transition: box-shadow 0.1s ease-in-out;

View File

@ -19,10 +19,11 @@ import { NAME as EXPLORER } from '@shell/config/product/explorer';
import { TYPE_MODES } from '@shell/store/type-map';
import { NAME as NAVLINKS } from '@shell/config/product/navlinks';
import Group from '@shell/components/nav/Group';
import LocaleSelector from '@shell/components/LocaleSelector';
export default {
name: 'SideNav',
components: { Group },
components: { Group, LocaleSelector },
data() {
return {
groups: [],
@ -112,9 +113,7 @@ export default {
computed: {
...mapState(['managementReady', 'clusterReady']),
...mapGetters(['isStandaloneHarvester', 'productId', 'clusterId', 'currentProduct', 'rootProduct', 'isSingleProduct', 'namespaceMode', 'isExplorer', 'isVirtualCluster']),
...mapGetters({
locale: 'i18n/selectedLocaleLabel', availableLocales: 'i18n/availableLocales', hasMultipleLocales: 'i18n/hasMultipleLocales'
}),
...mapGetters({ locale: 'i18n/selectedLocaleLabel', hasMultipleLocales: 'i18n/hasMultipleLocales' }),
...mapGetters('type-map', ['activeProducts']),
favoriteTypes: mapPref(FAVORITE_TYPES),
@ -360,10 +359,6 @@ export default {
});
},
switchLocale(locale) {
this.$store.dispatch('i18n/switchTo', locale);
},
syncNav() {
const refs = this.$refs.groups;
@ -427,6 +422,8 @@ export default {
<router-link
:to="supportLink"
class="pull-right"
role="link"
:aria-label="t('nav.support', {hasSupport: true})"
>
{{ t('nav.support', {hasSupport: true}) }}
</router-link>
@ -439,36 +436,11 @@ export default {
</span>
<!-- locale selector -->
<span v-if="isSingleProduct && hasMultipleLocales && !isStandaloneHarvester">
<v-dropdown
popperClass="localeSelector"
placement="top"
:triggers="['click']"
>
<a
data-testid="locale-selector"
class="locale-chooser"
>
{{ locale }}
</a>
<template #popper>
<ul
class="list-unstyled dropdown"
style="margin: -1px;"
>
<li
v-for="(label, name) in availableLocales"
:key="name"
class="hand"
@click="switchLocale(name)"
>
{{ label }}
</li>
</ul>
</template>
</v-dropdown>
</span>
<LocaleSelector
v-if="isSingleProduct && hasMultipleLocales && !isStandaloneHarvester"
mode="login"
:show-icon="false"
/>
</div>
<!-- SideNav footer alternative -->
<div
@ -478,6 +450,8 @@ export default {
<router-link
v-if="singleProductAbout"
:to="singleProductAbout"
role="link"
:aria-label="t('nav.ariaLabel.productAboutPage')"
>
{{ displayVersion }}
</router-link>

View File

@ -258,7 +258,6 @@ export default {
role="tablist"
class="tabs"
:class="{'clearfix':!sideTabs, 'vertical': sideTabs, 'horizontal': !sideTabs}"
tabindex="0"
data-testid="tabbed-block"
@keydown.right.prevent="selectNext(1)"
@keydown.left.prevent="selectNext(-1)"
@ -277,8 +276,12 @@ export default {
:data-testid="`btn-${tab.name}`"
:aria-controls="'#' + tab.name"
:aria-selected="tab.active"
:aria-label="tab.labelDisplay"
role="tab"
tabindex="0"
@click.prevent="select(tab.name, $event)"
@keyup.enter="select(tab.name, $event)"
@keyup.space="select(tab.name, $event)"
>
<span>{{ tab.labelDisplay }}</span>
<span
@ -403,6 +406,14 @@ export default {
text-decoration: underline;
}
}
&:focus-visible {
@include focus-outline;
span {
text-decoration: underline;
}
}
}
.conditions-alert-icon {

View File

@ -85,9 +85,11 @@ export default {
class="name"
>
<table>
<tbody>
<tr><td>{{ t('principal.name') }}: </td><td>{{ principal.name || principal.loginName }}</td></tr>
<tr><td>{{ t('principal.loginName') }}: </td><td>{{ principal.loginName }}</td></tr>
<tr><td>{{ t('principal.type') }}: </td><td>{{ principal.displayType }}</td></tr>
</tbody>
</table>
</div>
<template v-else>

View File

@ -1,10 +1,11 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Tag from '@shell/components/Tag.vue';
import { STATE, NAME, AGE, FLEET_SUMMARY } from '@shell/config/table-headers';
import { FLEET, MANAGEMENT } from '@shell/config/types';
export default {
components: { ResourceTable },
components: { ResourceTable, Tag },
props: {
rows: {
@ -75,6 +76,12 @@ export default {
pluralLabel: this.$store.getters['type-map/labelFor'](schema, 99),
};
},
},
methods: {
toggleCustomLabels(row) {
row['displayCustomLabels'] = !row.displayCustomLabels;
}
}
};
</script>
@ -85,6 +92,7 @@ export default {
:schema="schema"
:headers="headers"
:rows="rows"
:sub-rows="true"
:loading="loading"
:use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
key-field="_key"
@ -123,5 +131,78 @@ export default {
:class="{'text-error': !row.bundleInfo.total}"
>{{ row.bundleInfo.total }}</span>
</template>
<template #sub-row="{fullColspan, row, onRowMouseEnter, onRowMouseLeave}">
<tr
class="labels-row sub-row"
@mouseenter="onRowMouseEnter"
@mouseleave="onRowMouseLeave"
>
<template v-if="row.customLabels.length">
<td>&nbsp;</td>
<td>&nbsp;</td>
<td :colspan="fullColspan-2">
<span
v-if="row.customLabels.length"
class="mt-5"
> {{ t('fleet.cluster.labels') }}:
<span
v-for="(label, i) in row.customLabels"
:key="i"
class="mt-5 labels"
>
<Tag
v-if="i < 7"
class="mr-5 label"
>
{{ label }}
</Tag>
<Tag
v-else-if="i > 6 && row.displayCustomLabels"
class="mr-5 label"
>
{{ label }}
</Tag>
</span>
<a
v-if="row.customLabels.length > 7"
href="#"
@click.prevent="toggleCustomLabels(row)"
>
{{ t(`fleet.cluster.${row.displayCustomLabels? 'hideLabels' : 'showLabels'}`) }}
</a>
</span>
</td>
</template>
<td
v-else
:colspan="fullColspan"
>
&nbsp;
</td>
</tr>
</template>
</ResourceTable>
</template>
<style lang='scss' scoped>
.labels-row {
td {
padding-top:0;
.tag {
margin-right: 5px;
display: inline-block;
margin-top: 2px;
}
}
}
.labels {
display: inline;
flex-wrap: wrap;
.label {
display: inline-block;
margin-top: 2px;
}
}
</style>

View File

@ -6,17 +6,15 @@ import FleetIntro from '@shell/components/fleet/FleetIntro';
import {
AGE,
STATE,
NAME,
FLEET_SUMMARY,
FLEET_REPO,
FLEET_REPO_TARGET,
FLEET_REPO_CLUSTERS_READY,
FLEET_REPO_CLUSTER_SUMMARY,
FLEET_REPO_PER_CLUSTER_STATE
FLEET_REPO_CLUSTERS_READY,
FLEET_REPO_PER_CLUSTER_STATE,
FLEET_REPO_TARGET,
FLEET_SUMMARY,
NAME,
STATE,
} from '@shell/config/table-headers';
import { STATES_ENUM } from '@shell/plugins/dashboard-store/resource-class';
// i18n-ignore repoDisplay
export default {
@ -77,31 +75,18 @@ export default {
headers() {
// Cluster summary is only shown in the cluster view
const fleetClusterSummary = {
const summary = this.isClusterView ? [{
...FLEET_REPO_CLUSTER_SUMMARY,
formatterOpts: {
// Fleet uses labels to identify clusters
clusterLabel: this.clusterId
},
};
formatterOpts: { clusterId: this.clusterId },
}] : [FLEET_REPO_CLUSTERS_READY, FLEET_SUMMARY];
// if hasPerClusterState then use the repo state
const fleetPerClusterState = {
const state = this.isClusterView ? {
...FLEET_REPO_PER_CLUSTER_STATE,
value: (row) => {
const statePerCluster = row.clusterResourceStatus?.find((c) => {
return c.clusterLabel === this.clusterId;
});
value: (repo) => repo.clusterState(this.clusterId),
} : STATE;
return statePerCluster ? statePerCluster?.status?.displayStatus : STATES_ENUM.ACTIVE;
},
};
const summary = this.isClusterView ? [fleetClusterSummary] : [FLEET_REPO_CLUSTERS_READY, FLEET_SUMMARY];
const state = this.isClusterView ? fleetPerClusterState : STATE;
const out = [
return [
state,
NAME,
FLEET_REPO,
@ -109,8 +94,6 @@ export default {
...summary,
AGE
];
return out;
},
},
methods: {

View File

@ -73,7 +73,21 @@ export default {
mounted() {
// Ensures that if the default value is used, the model is updated with it
this.$emit('update:value', this.inputValue);
},
methods: {
handleKeyup(ev) {
if (this.isDisabled) {
return '';
}
return this.$refs.input.click(ev);
}
},
// according to https://www.w3.org/TR/html-aria/
// input type="color" has no applicable role
// and only aria-label and aria-disabled
};
</script>
@ -82,6 +96,8 @@ export default {
class="color-input"
:class="{[mode]:mode, disabled: isDisabled}"
:data-testid="componentTestid + '-color-input'"
:tabindex="isDisabled ? -1 : 0"
@keyup.enter.space.stop="handleKeyup($event)"
>
<label class="text-label"><t
v-if="labelKey"
@ -99,8 +115,11 @@ export default {
>
<input
ref="input"
:aria-disabled="isDisabled ? 'true' : 'false'"
:aria-label="t('generic.colorPicker')"
type="color"
:disabled="isDisabled"
tabindex="-1"
:value="inputValue"
@input="$emit('update:value', $event.target.value)"
>
@ -116,6 +135,10 @@ export default {
border-radius: var(--border-radius);
padding: 10px;
&:focus-visible {
@include focus-outline;
}
&.disabled, &.disabled .selected, &[disabled], &[disabled]:hover {
color: var(--input-disabled-text);
background-color: var(--input-disabled-bg);

View File

@ -798,7 +798,7 @@ export default {
v-if="addAllowed"
type="button"
class="btn role-tertiary add"
data-testid="add_link_button"
data-testid="add_row_item_button"
:disabled="loading || disabled || (keyOptions && filteredKeyOptions.length === 0)"
@click="add()"
>

View File

@ -154,11 +154,22 @@ export default {
methods: {
// resizeHandler = in mixin
focusSearch() {
const blurredAgo = Date.now() - this.blurred;
// 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;
});
if (!this.focused && blurredAgo < 250) {
return;
}
this.$refs['select-input'].open = true;
this.$nextTick(() => {
const el = this.$refs['select-input']?.searchEl;
@ -278,8 +289,9 @@ export default {
'no-label': !hasLabel
}
]"
:tabindex="isView || disabled ? -1 : 0"
@click="focusSearch"
@focus="focusSearch"
@keyup.enter.space.down="focusSearch"
>
<div
:class="{ 'labeled-container': true, raised, empty, [mode]: true }"
@ -319,7 +331,7 @@ export default {
:selectable="selectable"
:modelValue="value != null && !loading ? value : ''"
:dropdown-should-open="dropdownShouldOpen"
:tabindex="-1"
@update:modelValue="$emit('selecting', $event); $emit('update:value', $event)"
@search:blur="onBlur"
@search:focus="onFocus"
@ -384,7 +396,7 @@ export default {
<template #list-footer>
<div
v-if="canPaginate && totalResults"
v-if="canPaginate && totalResults && pages > 1"
class="pagination-slot"
>
<div class="load-more">

View File

@ -54,11 +54,17 @@ export default {
<button
v-clean-tooltip="minusTooltip"
:disabled="disabled || !canMinus"
:aria-disabled="disabled || !canMinus"
type="button"
role="button"
:aria-label="t('workload.plus')"
class="btn btn-sm role-secondary"
@click="$emit('minus')"
>
<i class="icon icon-sm icon-minus" />
<i
class="icon icon-sm icon-minus"
:alt="t('workload.plus')"
/>
</button>
<div class="value">
{{ value }}
@ -66,11 +72,17 @@ export default {
<button
v-clean-tooltip="plusTooltip"
:disabled="disabled || !canPlus"
:aria-disabled="disabled || !canPlus"
type="button"
role="button"
:aria-label="t('workload.minus')"
class="btn btn-sm role-secondary"
@click="$emit('plus')"
>
<i class="icon icon-sm icon-plus" />
<i
class="icon icon-sm icon-plus"
:alt="t('workload.minus')"
/>
</button>
</div>
</template>

View File

@ -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<ResourceLabeledSelectSettings>,
@ -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<ResourceLabeledSelectPaginateSettings>,
@ -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<LabelSelectPaginateFnResponse> {
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)"
/>
</template>

View File

@ -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;

View File

@ -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: [],
eventSchema,
EVENT,
selectedTab: this.defaultTab,
didLoadEvents: false,
inStore,
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;
}
}
};
</script>
@ -208,15 +250,16 @@ export default {
name="events"
:weight="-2"
>
<SortableTable
<!-- namespaced: false given we don't want the default handling of namespaced resource (apply header filter) -->
<PaginatedResourceTable
v-if="selectedTab === 'events'"
:rows="events"
:schema="eventSchema"
:local-filter="filterEventsLocal"
:api-filter="filterEventsApi"
:use-query-params-for-simple-filtering="false"
:headers="eventHeaders"
key-field="id"
:search="false"
:table-actions="false"
:row-actions="false"
default-sort-by="date"
:paginationHeaders="paginationHeaders"
:namespaced="false"
/>
</Tab>

View File

@ -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

View File

@ -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"
>
<v-select
ref="select-input"
@ -258,6 +278,8 @@ export default {
:searchable="isSearchable"
:selectable="selectable"
:modelValue="value != null ? value : ''"
:dropdownShouldOpen="handleDropdownOpen"
:tabindex="-1"
@update:modelValue="$emit('update:value', $event)"
@search:blur="onBlur"

View File

@ -127,7 +127,7 @@ describe('component: KeyValue', () => {
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();

View File

@ -11,7 +11,7 @@ export default {
required: true
},
clusterLabel: {
clusterId: {
type: String,
required: true
}
@ -22,6 +22,6 @@ export default {
<template>
<FleetSummaryGraph
:row="row"
:clusterLabel="clusterLabel"
:clusterId="clusterId"
/>
</template>

View File

@ -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);

View File

@ -182,14 +182,21 @@ export default {
<div
id="trigger"
class="hs-popover__trigger"
aria-role="button"
tabindex="0"
:class="{expanded}"
:aria-roledescription="t('workload.scaleWorkloads')"
:aria-label="t('workload.healthScaleToggle')"
:aria-expanded="expanded"
@click="expanded = !expanded"
@keyup.enter.space="expanded = !expanded"
>
<ProgressBarMulti
v-if="parts"
class="health"
:values="parts"
:show-zeros="true"
:aria-describedby="t('workload.healthWorkloads')"
/>
<i :class="{icon: true, 'icon-chevron-up': expanded, 'icon-chevron-down': !expanded}" />
</div>

View File

@ -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()"
>
<slot name="header">
<router-link
v-if="hasOverview"
:to="group.children[0].route"
:exact="group.children[0].exact"
:tabindex="-1"
>
<h6 v-clean-html="group.labelDisplay || group.label" />
<h6>
<span v-clean-html="group.labelDisplay || group.label" />
</h6>
</router-link>
<h6
v-else
v-clean-html="group.labelDisplay || group.label"
/>
>
<span v-clean-html="group.labelDisplay || group.label" />
</h6>
</slot>
<i
v-if="!onlyHasOverview && canCollapse"
class="icon toggle"
class="icon toggle toggle-accordion"
:class="{'icon-chevron-right': !isExpanded, 'icon-chevron-down': isExpanded}"
role="button"
tabindex="0"
:aria-label="t('nav.ariaLabel.collapseExpand')"
@click="peek($event, true)"
@keyup.enter="peek($event, true)"
@keyup.space="peek($event, true)"
/>
</div>
<ul
@ -288,6 +302,7 @@ export default {
cursor: pointer;
color: var(--body-text);
height: 33px;
outline: none;
H6 {
color: var(--body-text);
@ -315,6 +330,17 @@ export default {
.accordion {
.header {
&:focus-visible {
h6 span {
@include focus-outline;
outline-offset: 2px;
}
}
.toggle-accordion:focus-visible {
@include focus-outline;
outline-offset: -6px;
}
&.active {
color: var(--primary-hover-text);
background-color: var(--primary-hover-bg);

View File

@ -118,7 +118,10 @@ export default {
{{ type.labelKey ? t(type.labelKey) : (type.labelDisplay || type.label) }}
</TabTitle>
<a
role="link"
:aria-label="type.labelKey ? t(type.labelKey) : (type.labelDisplay || type.label)"
:href="href"
class="type-link"
@click="selectType(); navigate($event);"
@mouseenter="setNear(true)"
@mouseleave="setNear(false)"
@ -161,9 +164,11 @@ export default {
data-testid="link-type"
>
<a
role="link"
:href="type.link"
:target="type.target"
rel="noopener noreferrer nofollow"
:aria-label="type.label"
>
<span class="label">{{ type.label }}&nbsp;<i class="icon icon-external-link" /></span>
</a>
@ -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;

View File

@ -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'

Some files were not shown because too many files have changed in this diff Show More