Merge remote-tracking branch 'upstream/master' into pagination-extensions-resource-enable

This commit is contained in:
Richard Cox 2025-10-06 11:20:05 +01:00
commit 4305bcb0ba
48 changed files with 1660 additions and 682 deletions

View File

@ -50,12 +50,12 @@ jobs:
- name: Apply gcs auth
# https://github.com/google-github-actions/auth
uses: 'google-github-actions/auth@v2'
uses: 'google-github-actions/auth@v3'
with:
credentials_json: "${{ env.GOOGLE_AUTH }}"
- name: Upload build
uses: 'google-github-actions/upload-cloud-storage@v2'
uses: 'google-github-actions/upload-cloud-storage@v3'
# https://github.com/google-github-actions/upload-cloud-storage
with:
path: ${{steps.build-hosted.outputs.BUILD_HOSTED_DIR}}
@ -95,12 +95,12 @@ jobs:
- name: Apply gcs auth
# https://github.com/google-github-actions/auth
uses: 'google-github-actions/auth@v2'
uses: 'google-github-actions/auth@v3'
with:
credentials_json: "${{ env.GOOGLE_AUTH }}"
- name: Upload tar
uses: 'google-github-actions/upload-cloud-storage@v2'
uses: 'google-github-actions/upload-cloud-storage@v3'
with:
path: ${{steps.build-embedded.outputs.BUILD_EMBEDED_TGZ}}
# Example - https://releases.rancher.com/ui/2.8.0.tar.gz

View File

@ -2488,7 +2488,7 @@ export function generateFakeClusterDataAndIntercepts(fakeProvClusterId = 'some-p
localCluster.metadata.annotations['field.cattle.io/description'] = longClusterDescription;
}
res.body.data.unshift(fakeNavClusterData.provClusterObj);
res.body.data.push(fakeNavClusterData.provClusterObj);
res.send(res.body);
});
@ -2506,7 +2506,7 @@ export function generateFakeClusterDataAndIntercepts(fakeProvClusterId = 'some-p
localCluster.metadata.annotations['field.cattle.io/description'] = longClusterDescription;
}
res.body.data.unshift(fakeNavClusterData.provClusterObj);
res.body.data.push(fakeNavClusterData.provClusterObj);
res.send(res.body);
});
@ -2515,7 +2515,7 @@ export function generateFakeClusterDataAndIntercepts(fakeProvClusterId = 'some-p
// add extra cluster to the nav list to test https://github.com/rancher/dashboard/issues/10452
cy.intercept('GET', `/v1/management.cattle.io.clusters?*`, (req) => {
req.continue((res) => {
res.body.data.unshift(fakeNavClusterData.mgmtClusterObj);
res.body.data.push(fakeNavClusterData.mgmtClusterObj);
res.send(res.body);
});
}).as('mgmtClusters');

View File

@ -1,28 +1,5 @@
import BaseResourceList from '@/cypress/e2e/po/lists/base-resource-list.po';
export default class JWTAuthenticationListPo extends BaseResourceList {
details(name: string, index: number) {
return this.resourceTable().sortableTable().rowWithName(name).column(index);
}
state(clusterName: string) {
return this.resourceTable().sortableTable().rowWithName(clusterName).column(1);
}
activate() {
return cy.getId('sortable-table-activate').click();
}
deactivate() {
return cy.getId('sortable-table-deactivate').click();
}
clickRowActionMenuItem(name: string, itemLabel:string) {
return this.resourceTable().sortableTable().rowActionMenuOpen(name).getMenuItem(itemLabel)
.click();
}
getRowActionMenuItem(name: string, itemLabel:string) {
return this.resourceTable().sortableTable().rowActionMenuOpen(name).getMenuItem(itemLabel);
}
}

View File

@ -1,9 +1,9 @@
import PagePo from '@/cypress/e2e/po/pages/page.po';
import { BaseListPagePo } from '@/cypress/e2e/po/pages/base/base-list-page.po';
import JWTAuthenticationListPo from '@/cypress/e2e/po/lists/jwt-authentication-list.po';
import BurgerMenuPo from '@/cypress/e2e/po/side-bars/burger-side-menu.po';
import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po';
export default class JWTAuthenticationPagePo extends PagePo {
export default class JWTAuthenticationPagePo extends BaseListPagePo {
private static createPath(clusterId: string) {
return `/c/${ clusterId }/manager/jwt.authentication`;
}
@ -24,11 +24,7 @@ export default class JWTAuthenticationPagePo extends PagePo {
sideNav.navToSideMenuEntryByLabel('JWT Authentication');
}
title() {
return cy.contains('.title > h1', 'JWT Authentication');
}
list(): JWTAuthenticationListPo {
return new JWTAuthenticationListPo(this.self().find('[data-testid="jwt-authentication-list"]'));
return new JWTAuthenticationListPo('[data-testid="jwt-authentication-list"]');
}
}

View File

@ -150,12 +150,12 @@ export default class ExtensionsPagePo extends PagePo {
return this.clickAction(extensionTitle, 'Install');
}
extensionCardUpdateClick(extensionTitle: string): Cypress.Chainable {
return this.clickAction(extensionTitle, 'Update');
extensionCardUpgradeClick(extensionTitle: string): Cypress.Chainable {
return this.clickAction(extensionTitle, 'Upgrade');
}
extensionCardRollbackClick(extensionTitle: string): Cypress.Chainable {
return this.clickAction(extensionTitle, 'Rollback');
extensionCardDowngradeClick(extensionTitle: string): Cypress.Chainable {
return this.clickAction(extensionTitle, 'Downgrade');
}
extensionCardUninstallClick(extensionTitle: string): Cypress.Chainable {
@ -227,19 +227,11 @@ export default class ExtensionsPagePo extends PagePo {
// ------------------ extension tabs ------------------
extensionTabInstalledClick(): Cypress.Chainable {
return this.extensionTabs.clickNthTab(1);
return this.extensionTabs.clickTabWithName('installed');
}
extensionTabAvailableClick(): Cypress.Chainable {
return this.extensionTabs.clickNthTab(2);
}
extensionTabUpdatesClick(): Cypress.Chainable {
return this.extensionTabs.clickNthTab(3);
}
extensionTabAllClick(): Cypress.Chainable {
return this.extensionTabs.clickTabWithName('all');
return this.extensionTabs.clickTabWithName('available');
}
extensionTabBuiltinClick(): Cypress.Chainable {

View File

@ -693,8 +693,8 @@ describe('Shell a11y testing', { tags: ['@adminUser', '@accessibility'] }, () =>
extensionsPo.goTo();
extensionsPo.waitForPage(null, 'available');
extensionsPo.loading().should('not.exist');
extensionsPo.extensionTabAllClick();
extensionsPo.waitForPage(null, 'all');
extensionsPo.extensionTabBuiltinClick();
extensionsPo.waitForPage(null, 'builtin');
extensionsPo.extensionCard('AKS Provisioning').checkVisible();
cy.injectAxe();

View File

@ -25,11 +25,22 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => {
cy.login();
});
it('should go to the available tab by default', () => {
it('should go to the available tab by default and preserve active tab on reload', () => {
const extensionsPo = new ExtensionsPagePo();
// With no extensions installed, should default to "Available"
extensionsPo.goTo();
extensionsPo.waitForPage(null, 'available');
// Preserve active tab on reload
cy.setUserPreference({ 'plugin-developer': true });
extensionsPo.goTo(); // reload to get pref
extensionsPo.waitForPage(null, 'available');
extensionsPo.extensionTabBuiltinClick();
extensionsPo.waitForPage(null, 'builtin');
cy.reload();
extensionsPo.waitForPage(null, 'builtin');
cy.setUserPreference({ 'plugin-developer': false });
});
it('should show built-in extensions only when configured', () => {
@ -91,7 +102,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => {
extensionsPo.goTo();
extensionsPo.waitForPage();
extensionsPo.extensionTabInstalledClick(); // Avoid nav guard failures that probably auto move user to this tab
extensionsPo.extensionTabAvailableClick(); // Avoid nav guard failures that probably auto move user to this tab
// install the rancher plugin examples
extensionsPo.addExtensionsRepository('https://github.com/rancher/ui-plugin-examples', 'main', GIT_REPO_NAME).then(() => {
@ -303,9 +314,10 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => {
extensionsPo.extensionReloadBanner().should('be.visible');
extensionsPo.extensionReloadClick();
// make sure extension card is in the installed tab
extensionsPo.extensionTabInstalledClick();
// make sure we land on the installed tab by default
extensionsPo.waitForPage(null, 'installed');
// make sure extension card is in the installed tab
extensionsPo.extensionCardClick(EXTENSION_NAME);
extensionsPo.extensionDetailsTitle().should('contain', EXTENSION_NAME);
extensionsPo.extensionDetailsCloseClick();
@ -327,7 +339,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => {
cy.contains(`[data-testid="extension-card-${ EXTENSION_NAME }"]`).should('not.exist');
});
it('Should update an extension version', () => {
it('Should upgrade an extension version', () => {
cy.intercept('POST', `${ CLUSTER_REPOS_BASE_URL }/${ GIT_REPO_NAME }?action=upgrade`).as('upgradeExtension');
const extensionsPo = new ExtensionsPagePo();
@ -338,7 +350,7 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => {
extensionsPo.waitForPage(null, 'installed');
// click on update button on card
extensionsPo.extensionCardUpdateClick(EXTENSION_NAME);
extensionsPo.extensionCardUpgradeClick(EXTENSION_NAME);
extensionsPo.installModalInstallClick();
cy.wait('@upgradeExtension').its('response.statusCode').should('eq', 201);
@ -346,14 +358,14 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => {
extensionsPo.extensionReloadBanner().should('be.visible');
extensionsPo.extensionReloadClick();
// make sure extension card is not available anymore on the updates tab
// make sure extension card is still on the installed tab
// since we installed the latest version
extensionsPo.extensionTabUpdatesClick();
extensionsPo.waitForPage(null, 'updates');
cy.contains(`[data-testid="extension-card-${ EXTENSION_NAME }"]`).should('not.exist');
extensionsPo.extensionTabInstalledClick();
extensionsPo.waitForPage(null, 'installed');
extensionsPo.extensionCard(EXTENSION_NAME).checkVisible();
});
it('Should rollback an extension version', () => {
it('Should downgrade an extension version', () => {
const extensionsPo = new ExtensionsPagePo();
extensionsPo.goTo();
@ -362,18 +374,18 @@ describe('Extensions page', { tags: ['@extensions', '@adminUser'] }, () => {
extensionsPo.extensionTabInstalledClick();
extensionsPo.waitForPage(null, 'installed');
// click on the rollback button on card
// this will rollback to the immediate previous version
extensionsPo.extensionCardRollbackClick(EXTENSION_NAME);
// click on the downgrade button on card
// this will downgrade to the immediate previous version
extensionsPo.extensionCardDowngradeClick(EXTENSION_NAME);
extensionsPo.installModalInstallClick();
// let's check the extension reload banner and reload the page
extensionsPo.extensionReloadBanner().should('be.visible');
extensionsPo.extensionReloadClick();
// make sure extension card is on the updates tab
extensionsPo.extensionTabUpdatesClick();
extensionsPo.waitForPage(null, 'updates');
// make sure extension card is on the installed tab and is visible
extensionsPo.extensionTabInstalledClick();
extensionsPo.waitForPage(null, 'installed');
extensionsPo.extensionCard(EXTENSION_NAME).checkVisible();
});

View File

@ -62,7 +62,7 @@ describe('Home Page', () => {
cy.percySnapshot('Home Page');
});
it('Can see that cluster details match those in Cluster Manangement page', { tags: ['@generic', '@adminUser'] }, () => {
it('Can see that cluster details match those in Cluster Management page', { tags: ['@generic', '@adminUser'] }, () => {
/**
* Get cluster details from the Home page
* Verify that the cluster details match those on the Cluster Management page
@ -120,7 +120,15 @@ describe('Home Page', () => {
// since I wasn't able to fully mock a list of clusters
// the next best thing is to add a description to the current local cluster
// testing https://github.com/rancher/dashboard/issues/10441
cy.intercept('GET', `/v1/provisioning.cattle.io.clusters?*`, (req) => {
const homePageWithLocalPagination = '/v1/provisioning.cattle.io.clusters?*';
// Why the long intercept url?
// There are two requests to fetch clusters (side nav + cluster list). In theory "cy.intercept('GET', `/v1/provisioning.cattle.io.clusters?*`" should intercept them both
// how is not, only the first one for the side nav, and not the second for the list.
// const homePageWithSSP = `/v1/provisioning.cattle.io.clusters?page=1&pagesize=100&sort=metadata.annotations[provisioning.cattle.io/management-cluster-display-name]&filter=metadata.labels[provider.cattle.io]!=harvester&filter=status.provider!=harvester&exclude=metadata.managedFields`;
cy.intercept('GET', homePageWithLocalPagination, (req) => {
req.continue((res) => {
const localIndex = res.body.data.findIndex((item) => item.id.includes('/local'));

View File

@ -4,9 +4,23 @@ import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po';
import ClusterManagerListPagePo from '@/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po';
import JWTAuthenticationPagePo from '@/cypress/e2e/po/pages/cluster-manager/jwt-authentication.po';
describe('JWT Authentication', { testIsolation: 'off', tags: ['@manager', '@adminUser', '@jenkins'] }, () => {
const jwtAuthenticationPage = new JWTAuthenticationPagePo();
const jwtAuthenticationPage = new JWTAuthenticationPagePo();
// Go the JWT Authentication page and ensure the page is fully loaded
function goToJWTAuthenticationPageAndSettle() {
cy.intercept('GET', '/v1/management.cattle.io.clusterproxyconfigs?*').as('fetchJWTAuthentication');
jwtAuthenticationPage.goTo();
jwtAuthenticationPage.waitForPage();
cy.wait('@fetchJWTAuthentication');
// Wait for the jwt table to load and filter so there are no rows
jwtAuthenticationPage.list().resourceTable().sortableTable().filter('random text', 200);
jwtAuthenticationPage.list().resourceTable().sortableTable().rowElements()
.should((el) => expect(el).to.contain.text('There are no rows which match your search query.'));
jwtAuthenticationPage.list().resourceTable().sortableTable().resetFilter();
}
describe('JWT Authentication', { testIsolation: 'off', tags: ['@manager', '@adminUser', '@jenkins'] }, () => {
let instance0 = '';
let instance1 = '';
let removeCluster0 = false;
@ -61,80 +75,84 @@ describe('JWT Authentication', { testIsolation: 'off', tags: ['@manager', '@admi
});
it('should show the JWT Authentication list page', () => {
JWTAuthenticationPagePo.navTo();
jwtAuthenticationPage.waitForPage();
jwtAuthenticationPage.title().should('be.visible');
goToJWTAuthenticationPageAndSettle();
jwtAuthenticationPage.list().masthead().title().should('contain', 'JWT Authentication');
jwtAuthenticationPage.list().resourceTable().sortableTable().checkVisible();
jwtAuthenticationPage.list().resourceTable().sortableTable().checkLoadingIndicatorNotVisible();
jwtAuthenticationPage.list().state(instance0).should('contain', 'Disabled');
jwtAuthenticationPage.list().state(instance1).should('contain', 'Disabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance0, 1).should('contain', 'Disabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance1, 1).should('contain', 'Disabled');
});
it('should be able to enable JWT Authentication for a cluster', () => {
JWTAuthenticationPagePo.navTo();
jwtAuthenticationPage.waitForPage();
goToJWTAuthenticationPageAndSettle();
cy.intercept('POST', `/v1/management.cattle.io.clusterproxyconfigs`).as('enableJWT');
jwtAuthenticationPage.list().clickRowActionMenuItem(instance0, 'Enable');
jwtAuthenticationPage.list().resourceTable().sortableTable().rowActionMenuOpen(instance0)
.getMenuItem('Enable')
.click();
cy.wait('@enableJWT', { requestTimeout: 10000 }).then(({ request, response }) => {
expect(response?.statusCode).to.eq(201);
expect(request.body.enabled).to.equal(true);
});
jwtAuthenticationPage.list().state(instance0).should('contain', 'Enabled');
jwtAuthenticationPage.list().state(instance1).should('contain', 'Disabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance0, 1).should('contain', 'Enabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance1, 1).should('contain', 'Disabled');
});
it('should be able to disable JWT Authentication for a cluster', () => {
JWTAuthenticationPagePo.navTo();
jwtAuthenticationPage.waitForPage();
goToJWTAuthenticationPageAndSettle();
cy.intercept('PUT', `/v1/management.cattle.io.clusterproxyconfigs/**`).as('disableJWT');
jwtAuthenticationPage.list().clickRowActionMenuItem(instance0, 'Disable');
jwtAuthenticationPage.list().resourceTable().sortableTable().rowActionMenuOpen(instance0)
.getMenuItem('Disable')
.click();
cy.wait('@disableJWT', { requestTimeout: 10000 }).then(({ request, response }) => {
expect(response?.statusCode).to.eq(200);
expect(request.body.enabled).to.equal(false);
});
jwtAuthenticationPage.list().state(instance0).should('contain', 'Disabled');
jwtAuthenticationPage.list().state(instance1).should('contain', 'Disabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance0, 1).should('contain', 'Disabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance1, 1).should('contain', 'Disabled');
});
it('should be able to enable JWT Authentication in bulk', () => {
JWTAuthenticationPagePo.navTo();
jwtAuthenticationPage.waitForPage();
cy.intercept('PUT', `/v1/management.cattle.io.clusterproxyconfigs/**`).as('enableJWT');
goToJWTAuthenticationPageAndSettle();
cy.intercept('POST', `/v1/management.cattle.io.clusterproxyconfigs`).as('enableJWT');
jwtAuthenticationPage.list().resourceTable().sortableTable().rowSelectCtlWithName(instance0)
.set();
jwtAuthenticationPage.list().resourceTable().sortableTable().rowSelectCtlWithName(instance1)
.set();
jwtAuthenticationPage.list().activate();
jwtAuthenticationPage.list().resourceTable().sortableTable().bulkActionButton('Enable')
.click();
cy.wait('@enableJWT', { requestTimeout: 10000 }).then(({ request, response }) => {
expect(response?.statusCode).to.eq(200);
expect(response?.statusCode).to.eq(201);
expect(request.body.enabled).to.equal(true);
});
jwtAuthenticationPage.list().state(instance0).should('contain', 'Enabled');
jwtAuthenticationPage.list().state(instance1).should('contain', 'Enabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance0, 1).should('contain', 'Enabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance1, 1).should('contain', 'Enabled');
});
it('should be able to disable JWT Authentication in bulk', () => {
JWTAuthenticationPagePo.navTo();
jwtAuthenticationPage.waitForPage();
goToJWTAuthenticationPageAndSettle();
cy.intercept('PUT', `/v1/management.cattle.io.clusterproxyconfigs/**`).as('disableJWT');
jwtAuthenticationPage.list().resourceTable().sortableTable().rowSelectCtlWithName(instance0)
.set();
jwtAuthenticationPage.list().resourceTable().sortableTable().rowSelectCtlWithName(instance1)
.set();
jwtAuthenticationPage.list().deactivate();
jwtAuthenticationPage.list().resourceTable().sortableTable().bulkActionButton('Disable')
.click();
cy.wait('@disableJWT', { requestTimeout: 10000 }).then(({ request, response }) => {
expect(response?.statusCode).to.eq(200);
expect(request.body.enabled).to.equal(false);
});
jwtAuthenticationPage.list().state(instance0).should('contain', 'Disabled');
jwtAuthenticationPage.list().state(instance1).should('contain', 'Disabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance0, 1).should('contain', 'Disabled');
jwtAuthenticationPage.list().resourceTable().resourceTableDetails(instance1, 1).should('contain', 'Disabled');
});
after('clean up', () => {

View File

@ -5,7 +5,7 @@ import DeactivateDriverDialogPo from '@/cypress/e2e/po/prompts/deactivateDriverD
import ClusterManagerListPagePo from '@/cypress/e2e/po/pages/cluster-manager/cluster-manager-list.po';
import ClusterManagerCreatePagePo from '@/cypress/e2e/po/edit/provisioning.cattle.io.cluster/create/cluster-create.po';
import PromptRemove from '@/cypress/e2e/po/prompts/promptRemove.po';
import { LONG_TIMEOUT_OPT } from '@/cypress/support/utils/timeouts';
import { EXTRA_LONG_TIMEOUT_OPT } from '@/cypress/support/utils/timeouts';
describe('Kontainer Drivers', { testIsolation: 'off', tags: ['@manager', '@adminUser'] }, () => {
const driversPage = new KontainerDriversPagePo();
@ -42,7 +42,7 @@ describe('Kontainer Drivers', { testIsolation: 'off', tags: ['@manager', '@admin
driversPage.waitForPage();
cy.intercept('POST', '/v3/kontainerdrivers?action=refresh').as('refresh');
driversPage.refreshKubMetadata().click({ force: true });
cy.wait('@refresh', LONG_TIMEOUT_OPT).its('response.statusCode').should('eq', 200);
cy.wait('@refresh', EXTRA_LONG_TIMEOUT_OPT).its('response.statusCode').should('eq', 200);
});
it('can create new driver', () => {

View File

@ -210,8 +210,8 @@ describe('Harvester', { tags: ['@virtualizationMgmt', '@adminUser'] }, () => {
extensionsPo.extensionReloadClick();
extensionsPo.loading().should('not.exist');
// check harvester version on card - should not be older version
extensionsPo.extensionCardVersion(harvesterTitle).should('contain', versions[1]);
// check harvester version on card - should be the latest available version
extensionsPo.extensionCardVersion(harvesterTitle).should('contain', versions[0]);
harvesterPo.goTo();
harvesterPo.waitForPage();

View File

@ -1,67 +1,13 @@
#!groovy
def branch = "master"
def test_tags_raw = "${env.TEST_TAGS}".split(',')
def test_tags = []
for (String tag : test_tags_raw) {
test_tags.add(tag.trim())
}
def test_tags = "${env.TEST_TAGS}".split(',')
def k8s_versions = "${env.K3S_KUBERNETES_VERSIONS}".split(',')
def all_cypress_tags = "${env.CYPRESS_TAGS}".split('\\|')
// Function to get KDM branch for a Rancher version
def getKdmBranch(rancherVersion, testTags) {
if (rancherVersion == "head") {
// Find the highest version from test tags and increment it
def latestVersion = ""
for (String tag : testTags) {
def versionMatch = tag =~ /v(\d+\.\d+)-head/
if (versionMatch) {
def currentVersion = versionMatch[0][1]
if (currentVersion > latestVersion) {
latestVersion = currentVersion
}
}
}
// Increment minor version
if (latestVersion.isEmpty()) {
error("Cannot determine KDM branch for 'head' image. Please provide at least one version tag (e.g., v2.12-head) in TEST_TAGS.")
}
def parts = latestVersion.split('\\.')
def minor = (parts[1] as Integer) + 1
return "dev-v${parts[0]}.${minor}"
}
// Extract version from image tags (e.g."v2.12-head")
def versionMatch = rancherVersion =~ /v(\d+\.\d+)-head/
if (versionMatch) {
def majorMinor = versionMatch[0][1]
return "dev-v${majorMinor}"
}
error("Unable to determine KDM branch for Rancher version: ${rancherVersion}")
if (test_tags.length != k8s_versions.length) {
error("Number of Rancher versions (${test_tags.length}) must match number of K8s versions (${k8s_versions.length}). TEST_TAGS and K3S_KUBERNETES_VERSIONS must correspond positionally - first version pairs with first tag, second with second, etc.")
}
// Function to fetch latest K3s version from KDM
def getLatestK3sVersion(rancherVersion, testTags) {
def kdmBranch = getKdmBranch(rancherVersion, testTags)
def kdmUrl = "https://raw.githubusercontent.com/rancher/kontainer-driver-metadata/${kdmBranch}/data/data.json"
try {
def kdmData = sh(
script: "curl -s '${kdmUrl}' | grep '\"version\":' | grep '+k3s' | sed 's/.*\"version\": \"\\([^\"]*+k3s[0-9]*\\)\".*/\\1/' | tail -1",
returnStdout: true
).trim()
if (!kdmData || kdmData.isEmpty()) {
error("Failed to fetch K3s version from KDM for ${rancherVersion}")
}
return kdmData
} catch (Exception e) {
error("Error fetching KDM data for ${rancherVersion}: ${e.getMessage()}")
}
}
if ("${env.branch}" != "null" && "${env.branch}" != "") {
branch = "${env.branch}"
}
@ -91,21 +37,12 @@ node {
userRemoteConfigs: scm.userRemoteConfigs
])
}
// Generate K8s versions dynamically from KDM
def k8s_versions = []
for (String rancherVersion : test_tags) {
def k8sVersion = getLatestK3sVersion(rancherVersion, test_tags)
k8s_versions.add(k8sVersion)
}
try {
stage('Run Tests') {
jobs = [:]
test_tags.eachWithIndex { String rancher_version, int i ->
String k8s_version = k8s_versions[i]
all_cypress_tags.each { String ct ->
echo "RANCHER_TAG: ${rancher_version}, K8S_VERSION: ${k8s_version}, CYPRESS_TAGS: ${ct}"
for ( String ct : all_cypress_tags ) {
params = null
params = [ string(name: 'JOB_TYPE', value: "${JOB_TYPE}"),
string(name: 'CORRAL_PACKAGES_BRANCH', value: "${CORRAL_PACKAGES_BRANCH}"),
@ -141,8 +78,10 @@ node {
string(name: 'QASE_PROJECT', value: "${QASE_PROJECT}"),
string(name: 'QASE_REPORT', value: "${QASE_REPORT}"),
string(name: 'RANCHER_IMAGE_TAG', value: rancher_version)]
echo "RANCHER_TAG: ${rancher_version}, K8S_VERSION: ${k8s_version}, CYPRESS_TAGS: ${ct}"
build (job: 'ui-automation-job', parameters: params, propagate: false, wait: false)
}
}
}
} catch (err) {
@ -153,4 +92,4 @@ node {
}
}
}
}
}

View File

@ -1,5 +0,0 @@
name: Kamaji
author: Clastix
description: Deploys and operates Kubernetes Control Plane at scale with a fraction of the operational burden
url: https://kamaji.clastix.io/
icon: https://kamaji.clastix.io/images/logo.png

View File

@ -67,6 +67,8 @@ export default {
this.mgmtClusters = hash.mgmtClusters;
this.harvesterRepository = await this.getHarvesterRepository();
this.kubeVersion = this.$store.getters['management/byId'](MANAGEMENT.CLUSTER, 'local')?.kubernetesVersionBase || '';
},
data() {
@ -82,7 +84,7 @@ export default {
hciClusters: [],
mgmtClusters: [],
rancherVersion: getVersionData()?.Version || '',
kubeVersion: this.$store.getters['management/byId'](MANAGEMENT.CLUSTER, 'local')?.kubernetesVersionBase || '',
kubeVersion: null,
harvesterRepository: null,
harvesterInstallVersion: true,
harvesterUpdateVersion: null,

View File

@ -18,6 +18,10 @@ if [[ $BRANCH_NAME =~ ^release-(.*)$ ]]; then
# Update scripts/e2e-docker-start
sed -i "s|RANCHER_IMG_VERSION=head|RANCHER_IMG_VERSION=${NEW_TAG}|g" scripts/e2e-docker-start
echo "Updated scripts/e2e-docker-start"
# Update scripts/build-e2e
sed -i "s|ui/latest2/index.html|ui/${RELEASE_VERSION}/index.html|g" scripts/build-e2e
echo "Updated scripts/build-e2e"
else
echo "Not a release branch, no changes made."
fi

View File

@ -723,6 +723,17 @@ authConfig:
tokenEndpoint: Token Endpoint
userInfoEndpoint: User Info Endpoint
acrValue: Authorization Context Reference
customClaims:
label: Custom Claims
enable:
label: Add custom claims
tooltip: Manually map OIDC claims when your provider doesnt use standard claim names in tokens.
nameClaim:
label: Custom Name Claim
groupsClaim:
label: Custom Groups Claim
emailClaim:
label: Custom Email Claim
cognitoIssuer: Issuer URL
cognitoHelp: "You will need to create an application client in Cognito of type <b>Traditional web application</b> with the Return URL set to the URL shown below."
scope:
@ -941,10 +952,6 @@ asyncButton:
success: Updated
waiting: Updating&hellip;
upgrade:
action: Upgrade
success: Upgrading
waiting: Starting&hellip;
upgradeVersion:
action: Upgrade
success: Upgraded
waiting: Upgrading&hellip;
@ -1262,7 +1269,6 @@ catalog:
upgrade { upgrade }
downgrade { upgrade }
editVersion { update }
upgradeVersion { upgrade }
} the {existing, select,
true { app}
false { chart}
@ -5128,16 +5134,17 @@ plugins:
closePluginPanel: Close plugin description panel
viewVersionDetails: View extension {name} version {version} details/Readme
labels:
builtin: Built-in
builtin: Built-In
experimental: Experimental
third-party: Third-Party
image: Image
installing: Installing ...
uninstalling: Uninstalling ...
updating: Updating ...
rollingBack: Rolling back ...
upgrading: Upgrading ...
downgrading: Downgrading ...
menu: Extensions menu
reloadRancher: Reload Rancher
current: current
descriptions:
experimental: This Extension is marked as experimental
third-party: This Extension is provided by a Third-Party
@ -5166,6 +5173,8 @@ plugins:
name: Extension module name
persist: Persist extension by creating custom resource
info:
actions: Actions
noActions: No actions available
detail: Detail
versions: Versions
versionError: Could not load version information
@ -5229,7 +5238,7 @@ plugins:
tabs:
all: All
available: Available
builtin: Built-in
builtin: Built-In
images: Images
installed: Installed
updates: Updates
@ -5237,22 +5246,22 @@ plugins:
version: "Version: {version}"
install:
label: Install
title: Install Extension {name}
prompt: "Are you sure that you want to install this Extension?"
title: Install extension {name}
prompt: "Are you sure that you want to install this extension?"
version: Version
warnNotCertified: Please ensure that you are aware of the risks of installing Extensions from untrusted authors
update:
label: Update
title: Update Extension {name}
prompt: "Are you sure that you want to update this Extension?"
rollback:
label: Rollback
title: Rollback Extension {name}
prompt: "Are you sure that you want to rollback this Extension?"
upgrade:
label: Upgrade
title: Upgrade extension {name}
prompt: "Are you sure that you want to upgrade this extension?"
downgrade:
label: Downgrade
title: Downgrade extension {name}
prompt: "Are you sure that you want to downgrade this extension?"
uninstall:
label: Uninstall
title: "Uninstall Extension: {name}"
prompt: "Are you sure that you want to uninstall this Extension?"
title: Uninstall extension {name}
prompt: "Are you sure that you want to uninstall this extension?"
catalog: "Are you sure that you want to uninstall this Extension Catalog Image? This will also remove any Extensions provided by this image."
upgradeAvailable: A newer version of this Extension is available
reload: Extensions changed - reload required

View File

@ -181,6 +181,7 @@ export default {
if (e.status === 404 || e.status === 403) {
store.dispatch('loadingError', new Error(this.t('nav.failWhale.resourceIdNotFound', { resource: resourceType, fqid }, true)));
}
console.debug(`Could not find '${ resourceType }' with id '${ id }''`, e); // eslint-disable-line no-console
liveModel = {};
notFound = fqid;
}

View File

@ -139,12 +139,6 @@ export abstract class BaseTopLevelMenuHelper {
this.$store = $store;
this.hasProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER);
// Reduce flicker when component is recreated on a different layout
const { clustersPinned = [], clustersOthers = [] } = this.$store.getters['sideNavCache'] || {};
this.clustersPinned.push(...clustersPinned);
this.clustersOthers.push(...clustersOthers);
}
protected convertToCluster(mgmtCluster: MgmtCluster, provCluster: ProvCluster): TopLevelMenuCluster {
@ -163,10 +157,6 @@ export abstract class BaseTopLevelMenuHelper {
clusterRoute: { name: 'c-cluster-explorer', params: { cluster: mgmtCluster.id } }
};
}
protected cacheClusters() {
this.$store.dispatch('setSideNavCache', { clustersPinned: this.clustersPinned, clustersOthers: this.clustersOthers });
}
}
/**
@ -202,9 +192,9 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
this.clustersOthersWrapper = new PaginationWrapper({
$store,
id: 'tlm-unpinned-clusters',
onChange: () => {
onChange: async() => {
if (this.args) {
this.update(this.args);
await this.update(this.args);
}
},
enabledFor: {
@ -220,9 +210,9 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
this.provClusterWrapper = new PaginationWrapper({
$store,
id: 'tlm-prov-clusters',
onChange: () => {
onChange: async() => {
if (this.args) {
this.update(this.args);
await this.update(this.args);
}
},
enabledFor: {
@ -276,8 +266,6 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
this.clustersPinned.push(..._clustersPinned);
this.clustersOthers.push(..._clustersNotPinned);
this.cacheClusters();
}
async destroy() {
@ -390,7 +378,6 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
private async updateProvCluster(notPinned: MgmtCluster[], pinned: MgmtCluster[]): Promise<ProvCluster[]> {
return this.provClusterWrapper.request({
pagination: {
filters: [
PaginationParamFilter.createMultipleFields(
[...notPinned, ...pinned]
@ -399,7 +386,6 @@ export class TopLevelMenuHelperPagination extends BaseTopLevelMenuHelper impleme
}))
)
],
page: 1,
sort: [],
projectsOrNamespaces: []
@ -432,8 +418,6 @@ export class TopLevelMenuHelperLegacy extends BaseTopLevelMenuHelper implements
this.clustersPinned.push(..._clustersPinned);
this.clustersOthers.push(..._clustersNotPinned);
this.cacheClusters();
}
async destroy() {
@ -581,3 +565,46 @@ export class TopLevelMenuHelperLegacy extends BaseTopLevelMenuHelper implements
return sorted;
}
}
/**
* Retain state of the side nav, no matter when the TopLevelMenu component is created/deleted (on layout change)
*
* This means there's no flickering when the user changes pages and the side nav component re-renders
*
* Also it means we're not unwatching then watching the clusters
*/
class TopLevelMenuHelperService {
private _helper?: TopLevelMenuHelper;
public init($store: VuexStore) {
if (this._helper) {
return;
}
const canPagination = $store.getters[`management/paginationEnabled`]({
id: MANAGEMENT.CLUSTER,
context: 'side-bar',
}) && $store.getters[`management/paginationEnabled`]({
id: CAPI.RANCHER_CLUSTER,
context: 'side-bar',
});
this._helper = canPagination ? new TopLevelMenuHelperPagination({ $store }) : new TopLevelMenuHelperLegacy({ $store });
}
public async reset() {
await this._helper?.destroy();
delete this._helper;
}
get helper(): TopLevelMenuHelper {
if (!this._helper) {
throw new Error('Unable to use the side nav cluster helper (not initialised)');
}
return this._helper;
}
}
const instance = new TopLevelMenuHelperService();
export default instance;

View File

@ -14,7 +14,7 @@ import { SETTING } from '@shell/config/settings';
import { getProductFromRoute } from '@shell/utils/router';
import { isRancherPrime } from '@shell/config/version';
import Pinned from '@shell/components/nav/Pinned';
import { TopLevelMenuHelperPagination, TopLevelMenuHelperLegacy } from '@shell/components/nav/TopLevelMenu.helper';
import sideNavService from '@shell/components/nav/TopLevelMenu.helper';
import { debounce } from 'lodash';
import { sameContents } from '@shell/utils/array';
@ -27,6 +27,8 @@ export default {
},
data() {
sideNavService.init(this.$store);
const { displayVersion, fullVersion } = getVersionInfo(this.$store);
const hasProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER);
@ -37,7 +39,7 @@ export default {
id: CAPI.RANCHER_CLUSTER,
context: 'side-bar',
});
const helper = canPagination ? new TopLevelMenuHelperPagination({ $store: this.$store }) : new TopLevelMenuHelperLegacy({ $store: this.$store });
const helper = sideNavService.helper;
const provClusters = !canPagination && hasProvCluster ? this.$store.getters[`management/all`](CAPI.RANCHER_CLUSTER) : [];
const mgmtClusters = !canPagination ? this.$store.getters[`management/all`](MANAGEMENT.CLUSTER) : [];
@ -327,7 +329,6 @@ export default {
beforeUnmount() {
document.removeEventListener('keyup', this.handler);
this.helper?.destroy();
},
methods: {

View File

@ -3,6 +3,7 @@ import { mount, Wrapper } from '@vue/test-utils';
import { CAPI, COUNT, MANAGEMENT } from '@shell/config/types';
import { PINNED_CLUSTERS } from '@shell/store/prefs';
import { nextTick } from 'vue';
import sideNavService from '@shell/components/nav/TopLevelMenu.helper';
/**
* `clusters` doubles up as both mgmt and prov clusters (don't shoot the messenger)
@ -53,6 +54,7 @@ const waitForIt = async() => {
describe('topLevelMenu', () => {
beforeEach(() => {
jest.useFakeTimers();
sideNavService.reset();
});
afterEach(() => {

View File

@ -107,7 +107,7 @@ export function uiPluginAnnotation(chart, name) {
/**
* Parse the Rancher version string
*/
function parseRancherVersion(v) {
export function parseRancherVersion(v) {
let parsedVersion = semver.coerce(v)?.version;
const splitArr = parsedVersion?.split('.');

View File

@ -89,6 +89,7 @@ export default {
},
async fetch() {
await this.$store.dispatch(`management/find`, { type: MANAGEMENT.CLUSTER, id: this.value.mgmtClusterId });
await this.value.waitForProvisioner();
// Support for the 'provisioner' extension

View File

@ -3,12 +3,12 @@ import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { CATALOG, MANAGEMENT } from '@shell/config/types';
import { CATALOG as CATALOG_ANNOTATIONS } from '@shell/config/labels-annotations';
import { UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
import { UI_PLUGIN_NAMESPACE, isChartVersionHigher } from '@shell/config/uiplugins';
import Banner from '@components/Banner/Banner.vue';
import { SETTING } from '@shell/config/settings';
import { getPluginChartVersion, getPluginChartVersionLabel } from '@shell/utils/uiplugins';
import { getPluginChartVersionLabel } from '@shell/utils/uiplugins';
// Note: This dialog handles installation and update of a plugin
// Note: This dialog handles installation, upgrade and downgrade of a plugin
export default {
emits: ['close'],
@ -29,7 +29,14 @@ export default {
required: true
},
/**
* The action to perform (install, update, rollback)
* The pre-selected version in the dropdown
*/
initialVersion: {
type: String,
default: null
},
/**
* The action to perform (install, upgrade, downgrade)
*/
action: {
type: String,
@ -63,33 +70,32 @@ export default {
},
async fetch() {
const chartVersion = getPluginChartVersion(this.plugin);
// Default to latest version on install (this is default on the plugin)
this.version = chartVersion;
if (this.action === 'update') {
this.currentVersion = chartVersion;
// Update to latest version, so take the first version
if (this.plugin?.installableVersions?.length > 0) {
this.version = this.plugin?.installableVersions?.[0]?.version;
}
} else if (this.action === 'rollback') {
// Find the newest version once we remove the current version
const versionNames = this.plugin.installableVersions.filter((v) => v.version !== chartVersion);
this.currentVersion = chartVersion;
if (versionNames.length > 0) {
this.version = versionNames[0].version;
}
// Determine the currently installed version, if any
if (this.plugin.installed) {
this.currentVersion = this.plugin.installedVersion;
}
// Make sure we have the version available
const versionChart = this.plugin?.installableVersions?.find((v) => v.version === this.version);
// Determine the initial version to select in the dropdown
if (this.initialVersion) {
this.version = this.initialVersion;
} else if (this.action === 'upgrade') {
// Upgrade to the latest version, so take the first version
this.version = this.plugin?.installableVersions?.[0]?.version;
} else if (this.action === 'downgrade') {
const versions = this.plugin.installableVersions;
const currentIndex = versions.findIndex((v) => v.version === this.currentVersion);
if (!versionChart) {
if (currentIndex !== -1 && currentIndex < versions.length - 1) {
// Select the version just below the current version
this.version = versions[currentIndex + 1].version;
}
} else {
// Default to the latest installable version for new installs
this.version = this.plugin?.installableVersions?.[0]?.version;
}
// Fallback if no version could be determined
if (!this.version) {
this.version = this.plugin?.installableVersions?.[0]?.version;
}
@ -119,37 +125,39 @@ export default {
},
versionOptions() {
if (!this.plugin) {
if (!this.plugin?.installableVersions) {
return [];
}
// Don't allow update/rollback to current version
const versions = this.plugin?.installableVersions?.filter((v) => {
if (this.currentVersion) {
return v.version !== this.currentVersion;
}
// Don't allow upgrade/downgrade to current version by disabling the option
return this.plugin.installableVersions.map((v) => {
const isCurrent = v.version === this.currentVersion;
return true;
});
return versions.map((version) => {
return {
label: getPluginChartVersionLabel(version),
value: version.version,
label: getPluginChartVersionLabel(v) + (isCurrent ? ` (${ this.t('plugins.labels.current') })` : ''),
value: v.version,
disabled: isCurrent,
};
});
},
buttonMode() {
if (this.action === 'rollback') {
return 'rollback';
if (this.action === 'install') {
return 'install';
}
if (this.action === 'update') {
return 'update';
if (this.currentVersion && this.version) {
if (isChartVersionHigher(this.version, this.currentVersion)) {
return 'upgrade';
}
if (isChartVersionHigher(this.currentVersion, this.version)) {
return 'downgrade';
}
}
return 'install';
// Fallback for safety, though should not be reached if version is selected
return this.action;
},
chartVersionLoadsWithoutAuth() {
@ -158,6 +166,23 @@ export default {
returnFocusSelector() {
return `[data-testid="extension-card-${ this.action }-btn-${ this.plugin?.name }"]`;
},
buttonIcon() {
if (this.busy) {
return '';
}
switch (this.buttonMode) {
case 'install':
return 'icon-plus';
case 'upgrade':
return 'icon-upgrade-alt';
case 'downgrade':
return 'icon-downgrade-alt';
default:
return '';
}
}
},
@ -295,12 +320,12 @@ export default {
<template>
<div class="plugin-install-dialog">
<h4 class="mt-10">
{{ t(`plugins.${ action }.title`, { name: plugin?.label }) }}
{{ t(`plugins.${ buttonMode }.title`, { name: `"${plugin?.label}"` }, true) }}
</h4>
<div class="custom mt-10">
<div class="dialog-panel">
<p>
{{ t(`plugins.${ action }.prompt`) }}
{{ t(`plugins.${ buttonMode }.prompt`) }}
</p>
<Banner
v-if="chartVersionLoadsWithoutAuth"
@ -335,6 +360,7 @@ export default {
</button>
<AsyncButton
:mode="buttonMode"
:icon="buttonIcon"
data-testid="install-ext-modal-install-btn"
@click="install"
/>

View File

@ -103,7 +103,7 @@ export default {
<template>
<div class="plugin-install-dialog">
<h4 class="mt-10">
{{ t('plugins.uninstall.title', { name: plugin?.label }) }}
{{ t('plugins.uninstall.title', { name: `"${plugin?.label}"` }, true) }}
</h4>
<div class="mt-10 dialog-panel">
<div class="dialog-info">
@ -122,6 +122,7 @@ export default {
</button>
<AsyncButton
mode="uninstall"
:icon="busy ? '' : 'icon-delete'"
data-testid="uninstall-ext-modal-uninstall-btn"
@click="uninstall()"
/>

View File

@ -0,0 +1,111 @@
import { shallowMount, VueWrapper } from '@vue/test-utils';
import InstallExtensionDialog from '@shell/dialog/InstallExtensionDialog.vue';
jest.mock('@shell/config/uiplugins', () => ({
...jest.requireActual('@shell/config/uiplugins'),
isChartVersionHigher: jest.fn((v1: string, v2: string) => v1 > v2),
}));
const t = (key: string): string => key;
describe('component: InstallExtensionDialog', () => {
let wrapper: VueWrapper<any>;
const mountComponent = (propsData = {}) => {
const store = { dispatch: () => Promise.resolve() };
const defaultProps = {
plugin: { installableVersions: [] },
action: 'install',
updateStatus: jest.fn(),
closed: jest.fn(),
};
return shallowMount(InstallExtensionDialog, {
propsData: {
...defaultProps,
...propsData,
},
global: {
mocks: {
$store: store,
t,
},
}
});
};
describe('fetch', () => {
it('should set currentVersion if plugin is installed', async() => {
wrapper = mountComponent({ plugin: { installed: true, installedVersion: '1.0.0' } });
await wrapper.vm.$options.fetch.call(wrapper.vm);
expect(wrapper.vm.currentVersion).toBe('1.0.0');
});
it('should set version from initialVersion if provided', async() => {
wrapper = mountComponent({ initialVersion: '1.2.3', plugin: { installed: false } });
await wrapper.vm.$options.fetch.call(wrapper.vm);
expect(wrapper.vm.version).toBe('1.2.3');
});
it('should set version to latest for upgrade action', async() => {
const plugin = { installableVersions: [{ version: '1.1.0' }, { version: '1.0.0' }] };
wrapper = mountComponent({ plugin, action: 'upgrade' });
await wrapper.vm.$options.fetch.call(wrapper.vm);
expect(wrapper.vm.version).toBe('1.1.0');
});
it('should set version to next oldest for downgrade action', async() => {
const plugin = {
installed: true,
installedVersion: '1.1.0',
installableVersions: [{ version: '1.1.0' }, { version: '1.0.0' }]
};
wrapper = mountComponent({ plugin, action: 'downgrade' });
await wrapper.vm.$options.fetch.call(wrapper.vm);
expect(wrapper.vm.version).toBe('1.0.0');
});
});
describe('versionOptions', () => {
it('should include and disable the current version', () => {
const plugin = {
installableVersions: [
{ version: '1.1.0' },
{ version: '1.0.0' },
]
};
wrapper = mountComponent({ plugin });
wrapper.vm.currentVersion = '1.0.0';
const options = wrapper.vm.versionOptions;
const currentOption = options.find((o: any) => o.value === '1.0.0');
expect(currentOption).toBeDefined();
expect(currentOption.disabled).toBe(true);
expect(currentOption.label).toContain('(plugins.labels.current)');
});
});
describe('buttonMode', () => {
beforeEach(() => {
wrapper = mountComponent({ action: 'upgrade' });
wrapper.vm.currentVersion = '1.0.0';
});
it('should be "upgrade" if selected version is higher', async() => {
wrapper.vm.version = '1.1.0';
await wrapper.vm.$nextTick();
expect(wrapper.vm.buttonMode).toBe('upgrade');
});
it('should be "downgrade" if selected version is lower', async() => {
wrapper.vm.version = '0.9.0';
await wrapper.vm.$nextTick();
expect(wrapper.vm.buttonMode).toBe('downgrade');
});
});
});

View File

@ -59,11 +59,16 @@ export default {
userInfoEndpoint: null,
},
// TODO #13457: this is duplicated due wrong format
oidcScope: [],
SLO_OPTION_VALUES
oidcScope: [],
SLO_OPTION_VALUES,
addCustomClaims: false,
};
},
created() {
this.registerBeforeHook(this.willSave, 'willSave');
},
computed: {
tArgs() {
return {
@ -140,6 +145,10 @@ export default {
return this.model?.id === 'cognito';
},
isGenericOidc() {
return this.model?.id === 'genericoidc';
},
isLogoutAllSupported() {
return this.model?.logoutAllSupported;
},
@ -220,6 +229,15 @@ export default {
this.model.logoutAllForced = false;
break;
}
},
model: {
handler(newVal) {
if (newVal?.nameClaim || newVal?.groupsClaim || newVal?.emailClaim) {
this.addCustomClaims = true;
}
},
once: true
}
},
@ -248,6 +266,14 @@ export default {
updateScope() {
this.model.scope = this.oidcScope.join(' ');
},
willSave() {
if (this.isGenericOidc && !this.addCustomClaims) {
this.model.nameClaim = undefined;
this.model.groupsClaim = undefined;
this.model.emailClaim = undefined;
}
}
}
};
@ -390,21 +416,60 @@ export default {
</div>
</div>
<!-- Allow group search -->
<div
v-if="supportsGroupSearch"
class="row mb-20"
>
<div class="col span-6">
<Checkbox
v-model:value="model.groupSearchEnabled"
data-testid="input-group-search"
:label="t('authConfig.oidc.groupSearch.label')"
:tooltip="t('authConfig.oidc.groupSearch.tooltip')"
:mode="mode"
/>
<template v-if="isGenericOidc || supportsGroupSearch">
<div
class="row mb-20"
>
<div class="col span-6 checkbox-flex">
<!-- Allow group search -->
<Checkbox
v-if="supportsGroupSearch"
v-model:value="model.groupSearchEnabled"
data-testid="input-group-search"
:label="t('authConfig.oidc.groupSearch.label')"
:tooltip="t('authConfig.oidc.groupSearch.tooltip')"
:mode="mode"
/>
<Checkbox
v-if="isGenericOidc"
v-model:value="addCustomClaims"
data-testid="input-add-custom-claims"
:label="t('authConfig.oidc.customClaims.enable.label')"
:tooltip="t('authConfig.oidc.customClaims.enable.tooltip')"
:mode="mode"
/>
</div>
</div>
</div>
</template>
<template v-if="addCustomClaims">
<h4>{{ t('authConfig.oidc.customClaims.label') }}</h4>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="model.nameClaim"
:label="t(`authConfig.oidc.customClaims.nameClaim.label`)"
:mode="mode"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="model.groupsClaim"
:label="t(`authConfig.oidc.customClaims.groupsClaim.label`)"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="model.emailClaim"
:label="t(`authConfig.oidc.customClaims.emailClaim.label`)"
:mode="mode"
/>
</div>
</div>
</template>
<!-- Scopes -->
<div class="row mb-20">
@ -608,4 +673,9 @@ export default {
margin: 0 3px;
}
}
.checkbox-flex {
display: flex;
flex-direction: column;
}
</style>

View File

@ -213,7 +213,7 @@ describe('chartMixin', () => {
});
expect(wrapper.vm.action).toStrictEqual({
name: 'upgradeVersion',
name: 'upgrade',
tKey: 'upgrade',
icon: 'icon-upgrade-alt',
});

View File

@ -242,7 +242,7 @@ export default {
if (compare(this.currentVersion, this.targetVersion) < 0) {
return {
name: 'upgradeVersion', tKey: 'upgrade', icon: 'icon-upgrade-alt'
name: 'upgrade', tKey: 'upgrade', icon: 'icon-upgrade-alt'
};
}

View File

@ -391,6 +391,15 @@ export default class ProvCluster extends SteveModel {
const pCluster = this.$rootGetters['management/byId'](CAPI.RANCHER_CLUSTER, this.id);
const name = this.status?.clusterName || pCluster?.status?.clusterName;
try {
if (name) {
// Just in case we're not generically watching all mgmt clusters and...
// thus won't receive new mgmt cluster over socket...
// fire and forget a request to fetch it (this won't make multiple requests if one is already running)
this.$dispatch('find', { type: MANAGEMENT.CLUSTER, id: name });
}
} catch {}
return name && !!this.$rootGetters['management/byId'](MANAGEMENT.CLUSTER, name);
}, this.$rootGetters['i18n/t']('cluster.managementTimeout'), timeout, interval);
}

View File

@ -1,6 +1,6 @@
{
"name": "@rancher/shell",
"version": "3.0.5",
"version": "3.0.6",
"description": "Rancher Dashboard Shell",
"repository": "https://github.com/rancherlabs/dashboard",
"license": "Apache-2.0",

View File

@ -1,14 +1,18 @@
<script>
import { mapGetters } from 'vuex';
import ChartReadme from '@shell/components/ChartReadme';
import { Banner } from '@components/Banner';
import LazyImage from '@shell/components/LazyImage';
import { MANAGEMENT } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
import { useWatcherBasedSetupFocusTrapWithDestroyIncluded } from '@shell/composables/focusTrap';
import { getPluginChartVersionLabel, getPluginChartVersion } from '@shell/utils/uiplugins';
import { isChartVersionHigher } from '@shell/config/uiplugins';
import RcButton from '@components/RcButton/RcButton.vue';
import AppChartCardFooter from '@shell/pages/c/_cluster/apps/charts/AppChartCardFooter.vue';
export default {
emits: ['action'],
async fetch() {
const bannerSetting = await this.$store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.BANNERS);
const { showHeader, bannerHeader } = JSON.parse(bannerSetting.value);
@ -20,9 +24,10 @@ export default {
}
},
components: {
Banner,
ChartReadme,
LazyImage
LazyImage,
RcButton,
AppChartCardFooter
},
data() {
return {
@ -49,6 +54,60 @@ export default {
return {};
},
panelActions() {
const actions = [];
if (!this.info) {
return actions;
}
const selectedVersion = this.infoVersion;
const installedVersion = this.info.installedVersion;
if (!this.info.installed) {
if (this.info.installableVersions?.length) {
actions.push({
label: this.t('catalog.chart.chartButton.action.install'),
action: 'install',
role: 'primary',
version: selectedVersion,
icon: 'icon-plus'
});
}
} else {
if (selectedVersion && installedVersion && selectedVersion !== installedVersion) {
if (isChartVersionHigher(selectedVersion, installedVersion)) {
actions.push({
label: this.t('catalog.chart.chartButton.action.upgrade'),
action: 'upgrade',
role: 'primary',
version: selectedVersion,
icon: 'icon-upgrade-alt'
});
} else {
actions.push({
label: this.t('catalog.chart.chartButton.action.downgrade'),
action: 'downgrade',
role: 'primary',
version: selectedVersion,
icon: 'icon-downgrade-alt'
});
}
}
if (!this.info.builtin) {
actions.push({
label: this.t('plugins.uninstall.label'),
action: 'uninstall',
role: 'secondary',
icon: 'icon-delete'
});
}
}
return actions;
}
},
watch: {
showSlideIn: {
@ -65,12 +124,18 @@ export default {
}
},
methods: {
onButtonClick(button) {
this.$emit('action', { ...button, plugin: this.info });
this.hide();
},
show(info) {
this.info = info;
this.showSlideIn = true;
this.version = null;
this.versionInfo = null;
this.versionError = null;
this.infoVersion = undefined;
this.loadPluginVersionInfo();
},
@ -153,7 +218,13 @@ export default {
},
getVersionLabel(version) {
return getPluginChartVersionLabel(version);
const label = getPluginChartVersionLabel(version);
if (this.info.installed && version.version === this.info.installedVersion) {
return `${ label } (${ this.t('plugins.labels.current') })`;
}
return label;
}
}
};
@ -230,49 +301,66 @@ export default {
</div>
</div>
</div>
<div>
<Banner
v-if="info.builtin"
color="warning"
:label="t('plugins.descriptions.built-in')"
class="mt-10"
/>
<template v-else>
<Banner
v-if="!info.certified"
color="warning"
:label="t('plugins.descriptions.third-party')"
class="mt-10"
/>
<Banner
v-if="info.experimental"
color="warning"
:label="t('plugins.descriptions.experimental')"
class="mt-10"
/>
</template>
</div>
<AppChartCardFooter
v-if="info.tags && info.tags.length"
:items="info.tags"
class="plugin-tags-container"
/>
<h3 v-if="info.versions.length">
{{ t('plugins.info.versions') }}
</h3>
<div class="plugin-versions mb-10">
<div class="plugin-versions-container">
<h3>
{{ t('plugins.info.versions') }}
</h3>
<div v-if="!info.versions.length">
<div class="version-link version-active version-builtin">
{{ info.displayVersion }}
</div>
</div>
<div
v-for="v in info.versions"
:key="`${v.name}-${v.version}`"
v-else
class="plugin-versions"
>
<a
v-clean-tooltip="handleVersionBtnTooltip(v)"
class="version-link"
:class="handleVersionBtnClass(v)"
:tabindex="!v.isVersionCompatible ? -1 : 0"
role="button"
:aria-label="t('plugins.viewVersionDetails', {name: v.name, version: v.version})"
@click="loadPluginVersionInfo(v.version)"
@keyup.enter.space="loadPluginVersionInfo(v.version)"
<div
v-for="v in info.versions"
:key="`${v.name}-${v.version}`"
>
{{ getVersionLabel(v) }}
</a>
<a
v-clean-tooltip="handleVersionBtnTooltip(v)"
class="version-link"
:class="handleVersionBtnClass(v)"
:tabindex="!v.isVersionCompatible ? -1 : 0"
role="button"
:aria-label="t('plugins.viewVersionDetails', {name: v.name, version: v.version})"
@click="loadPluginVersionInfo(v.version)"
@keyup.enter.space="loadPluginVersionInfo(v.version)"
>
{{ getVersionLabel(v) }}
</a>
</div>
</div>
</div>
<div class="plugin-actions-container">
<h3>
{{ t('plugins.info.actions') }}
</h3>
<div class="plugin-actions">
<template v-if="panelActions.length">
<RcButton
v-for="btn in panelActions"
:key="btn.action"
class="mmr-3 mmb-3"
:[btn.role]="true"
@click="onButtonClick(btn)"
>
<i :class="['icon', btn.icon, 'mmr-2']" />{{ btn.label }}
</RcButton>
</template>
<div
v-else
class="no-actions"
>
{{ t('plugins.info.noActions') }}
</div>
</div>
</div>
@ -291,14 +379,6 @@ export default {
:version-info="versionInfo"
/>
</div>
<div v-if="!info.versions.length">
<h3>
{{ t('plugins.info.versions') }}
</h3>
<div class="version-link version-active version-builtin">
{{ info.displayVersion }}
</div>
</div>
</div>
</aside>
</transition>
@ -312,8 +392,6 @@ export default {
z-index: 1;
$slideout-width: 35%;
$title-height: 50px;
$padding: 5px;
$slideout-width: 35%;
--banner-top-offset: 0;
$header-height: calc(54px + var(--banner-top-offset));
@ -339,7 +417,7 @@ export default {
z-index: 10;
display: flex;
flex-direction: column;
padding: 10px;
padding: 12px;
&.active {
right: 0;
@ -373,6 +451,10 @@ export default {
flex-direction: column;
overflow: hidden;
.banner.warning {
margin-top: 0;
}
.plugin-info-detail {
overflow: auto;
}
@ -380,15 +462,16 @@ export default {
h3 {
font-size: 14px;
margin: 15px 0 10px 0;
opacity: 0.7;
margin-bottom: 12px;
color: var(--disabled-text);
text-transform: uppercase;
}
.plugin-header {
border-bottom: 1px solid var(--border);
display: flex;
padding-bottom: 20px;
padding-bottom: 16px;
margin-bottom: 16px;
.plugin-title {
flex: 1;
@ -397,8 +480,7 @@ export default {
.plugin-icon {
font-size: 40px;
margin-right:10px;
color: #888;
margin-right: 12px;
width: 44px;
height: 44px;
@ -419,11 +501,26 @@ export default {
}
}
.plugin-versions {
.plugin-tags-container {
margin-top: -8px;
}
.plugin-tags-container,
.plugin-versions-container,
.plugin-actions-container {
margin-bottom: 24px;
}
.plugin-versions,
.plugin-actions {
display: flex;
flex-wrap: wrap;
}
.no-actions {
color: var(--disabled-text);
}
.plugin-description {
font-size: 15px;
}
@ -434,7 +531,7 @@ export default {
padding: 2px 8px;
border-radius: 5px;
user-select: none;
margin: 0 5px 5px 0;
margin: 0 4px 4px 0;
display: block;
&.version-active {
@ -498,7 +595,7 @@ export default {
height: 0;
padding-bottom: 10px;
padding-bottom: 12px;
:deep() .chart-readmes {
flex: 1;

View File

@ -0,0 +1,102 @@
import { shallowMount, VueWrapper } from '@vue/test-utils';
import PluginInfoPanel from '@shell/pages/c/_cluster/uiplugins/PluginInfoPanel.vue';
jest.mock('@shell/config/uiplugins', () => ({
...jest.requireActual('@shell/config/uiplugins'),
isChartVersionHigher: jest.fn((v1: string, v2: string) => v1 > v2),
}));
jest.mock('@shell/utils/uiplugins', () => ({
getPluginChartVersionLabel: jest.fn((v) => v.version),
getPluginChartVersion: jest.fn(),
}));
const t = (key: string): string => key;
describe('component: PluginInfoPanel', () => {
let wrapper: VueWrapper<any>;
const mountComponent = () => {
return shallowMount(PluginInfoPanel, { global: { mocks: { t } } });
};
describe('panelActions', () => {
beforeEach(() => {
wrapper = mountComponent();
});
it('should be empty if no info is provided', () => {
expect(wrapper.vm.panelActions).toStrictEqual([]);
});
it('should show install action if not installed and installable versions exist', () => {
wrapper.vm.info = { installed: false, installableVersions: [{ version: '1.0.0' }] };
wrapper.vm.infoVersion = '1.0.0';
const actions = wrapper.vm.panelActions;
expect(actions).toHaveLength(1);
expect(actions[0].action).toBe('install');
});
it('should be empty if not installed and no installable versions exist', () => {
wrapper.vm.info = { installed: false, installableVersions: [] };
const actions = wrapper.vm.panelActions;
expect(actions).toHaveLength(0);
});
it('should show uninstall action if installed and not builtin', () => {
wrapper.vm.info = { installed: true, builtin: false };
const actions = wrapper.vm.panelActions;
expect(actions.some((action: any) => action.action === 'uninstall')).toBe(true);
});
it('should show upgrade action for a higher active version', () => {
wrapper.vm.info = { installed: true, installedVersion: '1.0.0' };
wrapper.vm.infoVersion = '1.1.0';
const actions = wrapper.vm.panelActions;
expect(actions.some((action: any) => action.action === 'upgrade')).toBe(true);
});
it('should show downgrade action for a lower active version', () => {
wrapper.vm.info = { installed: true, installedVersion: '1.0.0' };
wrapper.vm.infoVersion = '0.9.0';
const actions = wrapper.vm.panelActions;
expect(actions.some((action: any) => action.action === 'downgrade')).toBe(true);
});
it('should not show upgrade/downgrade if active version is same as installed', () => {
wrapper.vm.info = { installed: true, installedVersion: '1.0.0' };
wrapper.vm.infoVersion = '1.0.0';
const actions = wrapper.vm.panelActions;
expect(actions.some((action: any) => action.action === 'upgrade')).toBe(false);
expect(actions.some((action: any) => action.action === 'downgrade')).toBe(false);
});
});
describe('getVersionLabel', () => {
beforeEach(() => {
wrapper = mountComponent();
});
it('should return the version label', () => {
wrapper.vm.info = { installed: false };
const version = { version: '1.0.0' };
const label = wrapper.vm.getVersionLabel(version);
expect(label).toBe('1.0.0');
});
it('should append (current) for the installed version', () => {
wrapper.vm.info = { installed: true, installedVersion: '1.0.0' };
const version = { version: '1.0.0' };
const label = wrapper.vm.getVersionLabel(version);
expect(label).toBe('1.0.0 (plugins.labels.current)');
});
});
});

View File

@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { shallowMount, VueWrapper } from '@vue/test-utils';
import UiPluginsPage from '@shell/pages/c/_cluster/uiplugins/index.vue';
import { UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
const t = (key: string, args: Object) => {
if (args) {
@ -10,11 +11,16 @@ const t = (key: string, args: Object) => {
};
describe('page: UI plugins/Extensions', () => {
let wrapper;
let wrapper: VueWrapper<any>;
const mountComponent = (mocks = {}) => {
const store = {
getters: { 'prefs/get': jest.fn() },
getters: {
'prefs/get': jest.fn(),
'catalog/rawCharts': [],
'uiplugins/plugins': [],
'uiplugins/errors': {},
},
dispatch: () => Promise.resolve(),
};
@ -57,8 +63,7 @@ describe('page: UI plugins/Extensions', () => {
};
const actions = wrapper.vm.getPluginActions(plugin);
expect(actions).toHaveLength(1);
expect(actions[0].action).toBe('uninstall');
expect(actions.find((action: any) => action.action === 'uninstall')).toBeDefined();
});
it('should not return uninstall action for a builtin plugin', () => {
@ -70,10 +75,10 @@ describe('page: UI plugins/Extensions', () => {
};
const actions = wrapper.vm.getPluginActions(plugin);
expect(actions.some((a) => a.action === 'uninstall')).toBe(false);
expect(actions.some((action: any) => action.action === 'uninstall')).toBe(false);
});
it('should return update action for an installed plugin with an upgrade', () => {
it('should return upgrade action for an installed plugin with an upgrade', () => {
const plugin = {
installed: true,
installableVersions: [],
@ -82,33 +87,35 @@ describe('page: UI plugins/Extensions', () => {
};
const actions = wrapper.vm.getPluginActions(plugin);
expect(actions.some((a) => a.action === 'update')).toBe(true);
expect(actions.some((action: any) => action.action === 'upgrade')).toBe(true);
});
it('should return rollback action for an installed plugin with multiple installable versions and no upgrade', () => {
it('should return downgrade action for an installed plugin with older installable versions', () => {
const plugin = {
installed: true,
installableVersions: [{ version: '1.0.0' }, { version: '0.9.0' }],
builtin: false,
upgrade: null,
installedVersion: '1.0.0',
};
const actions = wrapper.vm.getPluginActions(plugin);
expect(actions.some((a) => a.action === 'rollback')).toBe(true);
expect(actions.some((action: any) => action.action === 'downgrade')).toBe(true);
});
it('should return all applicable actions', () => {
it('should return all applicable actions (upgrade, downgrade, uninstall)', () => {
const plugin = {
installed: true,
installableVersions: [{ version: '1.1.0' }, { version: '1.0.0' }],
installableVersions: [{ version: '1.1.0' }, { version: '1.0.0' }, { version: '0.9.0' }],
builtin: false,
upgrade: '1.1.0',
installedVersion: '1.0.0',
};
const actions = wrapper.vm.getPluginActions(plugin);
expect(actions.map((a) => a.action)).toContain('uninstall');
expect(actions.map((a) => a.action)).toContain('update');
expect(actions.map((a) => a.action)).not.toContain('rollback');
expect(actions.map((action: any) => action.action)).toContain('uninstall');
expect(actions.map((action: any) => action.action)).toContain('upgrade');
expect(actions.map((action: any) => action.action)).toContain('downgrade');
});
});
@ -120,43 +127,39 @@ describe('page: UI plugins/Extensions', () => {
expect(items[0].label).toBe('v1.0.0');
});
it('should include upgrade availability in tooltip', () => {
const plugin = { displayVersionLabel: 'v1.0.0', upgrade: 'v1.1.0' };
const items = wrapper.vm.getSubHeaderItems(plugin);
expect(items[0].labelTooltip).toBe('plugins.upgradeAvailableTooltip with {"version":"v1.1.0"}');
});
it('should show installing status', () => {
const plugin = { displayVersionLabel: 'v1.0.0', installing: 'install' };
const items = wrapper.vm.getSubHeaderItems(plugin);
expect(items).toHaveLength(2);
expect(items[1].label).toBe('plugins.labels.installing');
expect(items.find((item: any) => item.label === 'plugins.labels.installing')).toBeDefined();
});
it('should show uninstalling status', () => {
const plugin = { displayVersionLabel: 'v1.0.0', installing: 'uninstall' };
const items = wrapper.vm.getSubHeaderItems(plugin);
expect(items).toHaveLength(2);
expect(items[1].label).toBe('plugins.labels.uninstalling');
expect(items.find((item: any) => item.label === 'plugins.labels.uninstalling')).toBeDefined();
});
it('should show updating status', () => {
const plugin = { displayVersionLabel: 'v1.0.0', installing: 'update' };
it('should show upgrading status', () => {
const plugin = { displayVersionLabel: 'v1.0.0', installing: 'upgrade' };
const items = wrapper.vm.getSubHeaderItems(plugin);
expect(items).toHaveLength(2);
expect(items[1].label).toBe('plugins.labels.updating');
expect(items.find((item: any) => item.label === 'plugins.labels.upgrading')).toBeDefined();
});
it('should show rolling back status', () => {
const plugin = { displayVersionLabel: 'v1.0.0', installing: 'rollback' };
it('should show downgrading status', () => {
const plugin = { displayVersionLabel: 'v1.0.0', installing: 'downgrade' };
const items = wrapper.vm.getSubHeaderItems(plugin);
expect(items).toHaveLength(2);
expect(items[1].label).toBe('plugins.labels.rollingBack');
expect(items.find((item: any) => item.label === 'plugins.labels.downgrading')).toBeDefined();
});
it('should include date info', () => {
const plugin = { created: '2023-01-01T00:00:00Z' };
const items = wrapper.vm.getSubHeaderItems(plugin);
expect(items.find((item: any) => item.icon === 'icon-refresh-alt')).toBeDefined();
});
});
@ -200,13 +203,16 @@ describe('page: UI plugins/Extensions', () => {
});
describe('getStatuses', () => {
it('should return "installed" status for installed, non-builtin plugins', () => {
it('should return "installed" status with version for installed, non-builtin plugins', () => {
const plugin = {
installed: true, builtin: false, installing: false
installed: true,
builtin: false,
installing: false,
installedVersion: '1.2.3',
};
const statuses = wrapper.vm.getStatuses(plugin);
expect(statuses[0].tooltip.key).toBe('generic.installed');
expect(statuses[0].tooltip.text).toBe('generic.installed (1.2.3)');
});
it('should return "upgradeable" status for plugins with an upgrade', () => {
@ -250,14 +256,15 @@ describe('page: UI plugins/Extensions', () => {
it('should combine deprecated and other errors in tooltip', () => {
const plugin = { chart: { deprecated: true }, helmError: true };
const statuses = wrapper.vm.getStatuses(plugin);
const warningStatus = statuses.find((s) => s.icon === 'icon-alert-alt');
const warningStatus = statuses.find((status: any) => status.icon === 'icon-alert-alt');
expect(warningStatus.tooltip.text).toBe('generic.deprecated. generic.error: plugins.helmError');
});
});
describe('watch: helmOps', () => {
let wrapper;
let wrapper: VueWrapper<any>;
let updatePluginInstallStatusMock: jest.Mock;
beforeEach(() => {
const store = {
@ -265,9 +272,10 @@ describe('page: UI plugins/Extensions', () => {
'prefs/get': jest.fn(),
'catalog/rawCharts': {},
'uiplugins/plugins': [],
'uiplugins/errors': {}
'uiplugins/errors': {},
'features/get': () => true,
},
dispatch: () => Promise.resolve(),
dispatch: () => Promise.resolve(true),
};
wrapper = shallowMount(UiPluginsPage, {
@ -277,42 +285,100 @@ describe('page: UI plugins/Extensions', () => {
t,
},
stubs: { ActionMenu: { template: '<div />' } }
},
computed: {
// Override the computed property for this test suite
available: () => [
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
{ name: 'plugin4' },
],
hasMenuActions: () => true,
menuActions: () => []
}
});
updatePluginInstallStatusMock = jest.fn();
wrapper.vm.updatePluginInstallStatus = updatePluginInstallStatusMock;
// Set the 'installing' status on the component instance
wrapper.vm.installing = {
plugin1: 'install',
plugin2: 'downgrade',
plugin3: 'uninstall',
};
// Reset errors
wrapper.vm.errors = {};
});
it('should set status to "update" for an upgrade operation', async() => {
const plugin = { name: 'my-plugin' };
wrapper.vm.available = [plugin];
wrapper.vm.installing['my-plugin'] = 'update';
it('should update status for an active operation', async() => {
const helmOps = [{
metadata: { state: { transitioning: true } },
status: { releaseName: 'my-plugin', action: 'upgrade' }
namespace: UI_PLUGIN_NAMESPACE,
status: { releaseName: 'plugin1', action: 'install' },
metadata: { creationTimestamp: '1', state: { transitioning: true } }
}];
wrapper.vm.helmOps = helmOps;
await wrapper.vm.$nextTick();
expect(wrapper.vm.installing['my-plugin']).toBe('update');
expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin1', 'install');
});
it('should keep status as "rollback" during an upgrade operation if it was already rolling back', async() => {
const plugin = { name: 'my-plugin' };
wrapper.vm.available = [plugin];
wrapper.vm.installing['my-plugin'] = 'rollback';
it('should not update status for an upgrade op when a downgrade was initiated', async() => {
const helmOps = [{
metadata: { state: { transitioning: true } },
status: { releaseName: 'my-plugin', action: 'upgrade' }
namespace: UI_PLUGIN_NAMESPACE,
status: { releaseName: 'plugin2', action: 'upgrade' },
metadata: { creationTimestamp: '1', state: { transitioning: true } }
}];
wrapper.vm.helmOps = helmOps;
await wrapper.vm.$nextTick();
expect(wrapper.vm.installing['my-plugin']).toBe('rollback');
// It should not be called with 'upgrade' for plugin2
expect(updatePluginInstallStatusMock).not.toHaveBeenCalledWith('plugin2', 'upgrade');
});
it('should clear status for a completed uninstall operation', async() => {
const helmOps = [{
namespace: UI_PLUGIN_NAMESPACE,
status: { releaseName: 'plugin3', action: 'uninstall' },
metadata: { creationTimestamp: '1', state: { transitioning: false } }
}];
wrapper.vm.helmOps = helmOps;
await wrapper.vm.$nextTick();
expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin3', false);
});
it('should set error and clear status for a failed operation', async() => {
const helmOps = [{
namespace: UI_PLUGIN_NAMESPACE,
status: { releaseName: 'plugin1', action: 'install' },
metadata: { creationTimestamp: '1', state: { transitioning: false, error: true } }
}];
wrapper.vm.helmOps = helmOps;
await wrapper.vm.$nextTick();
expect(wrapper.vm.errors.plugin1).toBe(true);
expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin1', false);
});
it('should clear status for plugins with no active operation', async() => {
const helmOps = [{
namespace: UI_PLUGIN_NAMESPACE,
status: { releaseName: 'plugin1', action: 'install' },
metadata: { creationTimestamp: '1', state: { transitioning: true } }
}];
wrapper.vm.helmOps = helmOps;
await wrapper.vm.$nextTick();
// plugin4 has no op, so its status should be cleared
expect(updatePluginInstallStatusMock).toHaveBeenCalledWith('plugin4', false);
});
});
});

View File

@ -1,9 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import day from 'dayjs';
import { mapPref, PLUGIN_DEVELOPER } from '@shell/store/prefs';
import { sortBy } from '@shell/utils/sort';
import { allHash } from '@shell/utils/promise';
import { CATALOG, UI_PLUGIN, MANAGEMENT } from '@shell/config/types';
import { CATALOG, UI_PLUGIN, MANAGEMENT, ZERO_TIME } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
import { fetchOrCreateSetting } from '@shell/utils/settings';
import { getVersionData, isRancherPrime } from '@shell/config/version';
@ -40,10 +41,8 @@ const MAX_DESCRIPTION_LENGTH = 200;
const TABS_VALUES = {
INSTALLED: 'installed',
UPDATES: 'updates',
AVAILABLE: 'available',
BUILTIN: 'builtin',
ALL: 'all',
};
export default {
@ -67,7 +66,7 @@ export default {
return {
TABS_VALUES,
kubeVersion: null,
view: '',
activeTab: '',
installing: {},
errors: {},
plugins: [], // The installed plugins
@ -122,11 +121,6 @@ export default {
this.addExtensionReposBannerSetting = await fetchOrCreateSetting(this.$store, SETTING.ADD_EXTENSION_REPOS_BANNER_DISPLAY, 'true', true) || {};
// If there are no plugins installed, default to the catalog view
if (this.plugins.length === 0) {
this.$refs.tabs?.select(TABS_VALUES.AVAILABLE);
}
this.loading = false;
},
@ -207,12 +201,10 @@ export default {
// If not an extension developer, then don't include built-in extensions
const all = this.pluginDeveloper ? this.available : this.available.filter((p) => !p.builtin);
switch (this.view) {
switch (this.activeTab) {
case TABS_VALUES.INSTALLED:
// We never show built-in extensions as installed - installed are just the ones the user has installed
return all.filter((p) => !p.builtin && (!!p.installed || !!p.installing));
case TABS_VALUES.UPDATES:
return this.updates;
return all.filter((p) => !p.builtin && (!!p.installed || !!p.installing) && p.installableVersions?.length > 0);
case TABS_VALUES.AVAILABLE:
return all.filter((p) => !p.installed);
case TABS_VALUES.BUILTIN:
@ -226,14 +218,14 @@ export default {
return this.menuActions?.length > 0;
},
// Message to display when the tab view is empty (depends on the tab)
// Message to display when the active tab is empty (depends on the tab)
emptyMessage() {
// i18n-uses plugins.empty.*
return this.t(`plugins.empty.${ this.view }`);
return this.t(`plugins.empty.${ this.activeTab }`);
},
updates() {
return this.available.filter((plugin) => !!plugin.upgrade);
installed() {
return this.available.filter((p) => !p.builtin && (!!p.installed || !!p.installing));
},
pluginCards() {
@ -298,6 +290,7 @@ export default {
item.displayVersion = latestCompatible.version;
item.displayVersionLabel = getPluginChartVersionLabel(latestCompatible);
item.icon = latestCompatible.icon;
item.created = latestCompatible.created;
} else {
item.experimental = uiPluginHasAnnotation(chart, CATALOG_ANNOTATIONS.EXPERIMENTAL, 'true');
item.certified = uiPluginHasAnnotation(chart, CATALOG_ANNOTATIONS.CERTIFIED, CATALOG_ANNOTATIONS._RANCHER);
@ -305,6 +298,7 @@ export default {
item.displayVersion = item.versions?.[0]?.version;
item.displayVersionLabel = getPluginChartVersionLabel(item.versions?.[0]);
item.icon = chart.icon || latestCompatible?.annotations?.['catalog.cattle.io/ui-icon'];
item.created = item.versions?.[0]?.created;
}
// add message of extension card if there's a newer version of the extension, but it's not available to be installed
@ -353,6 +347,7 @@ export default {
displayVersion: p.metadata?.version,
displayVersionLabel: p.metadata?.version || '-',
installed: true,
installedVersion: p.metadata?.version,
builtin: !!p.builtin,
experimental: rancher?.annotations?.[CATALOG_ANNOTATIONS.EXPERIMENTAL] === 'true',
certified: rancher?.annotations?.[CATALOG_ANNOTATIONS.CERTIFIED] === CATALOG_ANNOTATIONS._RANCHER
@ -373,8 +368,7 @@ export default {
if (chart) {
chart.installed = true;
chart.uiplugin = p;
chart.displayVersion = p.version;
let displayVersionLabel = p.version;
chart.installedVersion = p.version;
// Can't do this here
chart.installing = this.installing[chart.name];
@ -389,13 +383,10 @@ export default {
if (installedVersion) {
chart.experimental = installedVersion?.annotations?.[CATALOG_ANNOTATIONS.EXPERIMENTAL] === 'true';
chart.certified = installedVersion?.annotations?.[CATALOG_ANNOTATIONS.CERTIFIED] === CATALOG_ANNOTATIONS._RANCHER;
displayVersionLabel = getPluginChartVersionLabel(installedVersion);
}
chart.upgrade = getPluginChartVersionLabel(latestInstallableVersion);
}
chart.displayVersionLabel = displayVersionLabel;
} else {
// No chart, so add a card for the plugin based on its Custom resource being present
const item = {
@ -474,10 +465,10 @@ export default {
if (active) {
// Can use the status directly, apart from upgrade, which maps to update
const status = op.status.action === 'upgrade' ? 'update' : op.status.action;
const status = op.status.action;
if (status === 'update' && this.installing[plugin.name] === 'rollback') {
// Helm op is an upgrade, but we initiated a rollback, so keep the 'rollback' status
if (status === 'upgrade' && this.installing[plugin.name] === 'downgrade') {
// Helm op is an upgrade, but we initiated a downgrade, so keep the 'downgrade' status
} else {
this.updatePluginInstallStatus(plugin.name, status);
}
@ -535,6 +526,16 @@ export default {
},
methods: {
handlePanelAction(button) {
const { action, plugin, version } = button;
if (action === 'uninstall') {
this.showUninstallDialog(plugin, {});
} else {
this.showInstallDialog(plugin, action, {}, version);
}
},
async refreshCharts(forceChartsUpdate = false) {
// we might need to force the request, so that we know at all times if what's the status of the offical and partners repos (installed or not)
// tied to the SetupUIPlugins, AddExtensionRepos checkboxes
@ -560,8 +561,8 @@ export default {
return hasFeatureFlag;
},
filterChanged(f) {
this.view = f.selectedName;
tabChanged(f) {
this.activeTab = f.selectedName;
},
// Developer Load is in the action menu
@ -623,10 +624,10 @@ export default {
});
},
showInstallDialog(plugin, action, ev) {
ev.target?.blur();
ev.preventDefault();
ev.stopPropagation();
showInstallDialog(plugin, action, ev, initialVersion = null) {
ev?.target?.blur();
ev?.preventDefault?.();
ev?.stopPropagation?.();
this.$store.dispatch('management/promptModal', {
component: 'InstallExtensionDialog',
@ -636,6 +637,7 @@ export default {
componentProps: {
plugin,
action,
initialVersion,
updateStatus: (pluginName, type) => {
this.updatePluginInstallStatus(pluginName, type);
},
@ -647,9 +649,9 @@ export default {
},
showUninstallDialog(plugin, ev) {
ev.target?.blur();
ev.preventDefault();
ev.stopPropagation();
ev?.target?.blur();
ev?.preventDefault?.();
ev?.stopPropagation?.();
this.$store.dispatch('management/promptModal', {
component: 'UninstallExtensionDialog',
@ -683,7 +685,7 @@ export default {
didInstall(plugin) {
if (plugin) {
// Change the view to installed if we started installing a plugin
// Change the activeTab to installed if we started installing a plugin
this.$refs.tabs?.select(TABS_VALUES.INSTALLED);
// Clear the load error, if there was one previously
@ -692,7 +694,9 @@ export default {
},
showPluginDetail(plugin) {
this.$refs.infoPanel.show(plugin);
const tags = this.getFooterItems(plugin);
this.$refs.infoPanel.show({ ...plugin, tags });
},
updatePluginInstallStatus(name, status) {
@ -741,42 +745,46 @@ export default {
getPluginActions(plugin) {
const actions = [];
const canInstall = !plugin.installed && plugin.installableVersions?.length;
const canUninstall = plugin.installed && !plugin.builtin;
const canUpdate = plugin.installed && plugin.upgrade;
const canRollback = plugin.installed && !plugin.upgrade && plugin.installableVersions?.length > 1;
if (plugin.installed) {
const canUpdate = !!plugin.upgrade;
const canDowngrade = plugin.installableVersions?.some((v) => isChartVersionHigher(plugin.installedVersion, v.version));
const canUninstall = !plugin.builtin;
if (canUninstall) {
actions.push({
label: this.t('plugins.uninstall.label'),
action: 'uninstall',
icon: 'icon-delete',
});
}
if (canUpdate) {
actions.push({
label: this.t('plugins.upgrade.label'),
action: 'upgrade',
icon: 'icon-upgrade-alt',
});
}
if (canUpdate) {
actions.push({
label: this.t('plugins.update.label'),
action: 'update',
icon: 'icon-edit',
});
}
if (canDowngrade) {
actions.push({
label: this.t('plugins.downgrade.label'),
action: 'downgrade',
icon: 'icon-downgrade-alt',
});
}
if (canRollback) {
actions.push({
label: this.t('plugins.rollback.label'),
action: 'rollback',
icon: 'icon-history',
});
}
if (canInstall) {
actions.push({
label: this.t('plugins.install.label'),
action: 'install',
icon: 'icon-plus',
enabled: true,
});
if (canUninstall) {
if (actions.length > 0) {
actions.push({ divider: true });
}
actions.push({
label: this.t('plugins.uninstall.label'),
action: 'uninstall',
icon: 'icon-delete',
});
}
} else {
if (plugin.installableVersions?.length) {
actions.push({
label: this.t('plugins.install.label'),
action: 'install',
icon: 'icon-plus',
enabled: true,
});
}
}
return actions;
@ -784,12 +792,25 @@ export default {
getSubHeaderItems(plugin) {
const items = [{
icon: 'icon-version-alt',
iconTooltip: { key: 'tableHeaders.version' },
label: plugin.displayVersionLabel,
labelTooltip: plugin.upgrade ? this.t('plugins.upgradeAvailableTooltip', { version: plugin.upgrade }) : ''
icon: 'icon-version-alt',
iconTooltip: { key: 'tableHeaders.version' },
label: plugin.displayVersionLabel,
}];
if (plugin.created) {
const hasZeroTime = plugin.created === ZERO_TIME;
const lastUpdatedItem = {
icon: 'icon-refresh-alt',
iconTooltip: { key: 'tableHeaders.lastUpdated' },
label: hasZeroTime ? this.t('generic.na') : day(plugin.created).format('MMM D, YYYY')
};
if (hasZeroTime) {
lastUpdatedItem.labelTooltip = this.t('catalog.charts.appChartCard.subHeaderItem.missingVersionDate');
}
items.push(lastUpdatedItem);
}
if (plugin.installing) {
let label;
@ -800,11 +821,11 @@ export default {
case 'uninstall':
label = this.t('plugins.labels.uninstalling');
break;
case 'update':
label = this.t('plugins.labels.updating');
case 'upgrade':
label = this.t('plugins.labels.upgrading');
break;
case 'rollback':
label = this.t('plugins.labels.rollingBack');
case 'downgrade':
label = this.t('plugins.labels.downgrading');
break;
default:
label = this.t('plugins.labels.installing');
@ -874,7 +895,7 @@ export default {
if (plugin.installed && !plugin.builtin && !plugin.installing) {
statuses.push({
icon: 'icon-confirmation-alt', color: 'success', tooltip: { key: 'generic.installed' }
icon: 'icon-confirmation-alt', color: 'success', tooltip: { text: `${ this.t('generic.installed') } (${ plugin.installedVersion })` }
});
}
@ -958,7 +979,10 @@ export default {
</div>
<!-- extensions slide-in panel -->
<PluginInfoPanel ref="infoPanel" />
<PluginInfoPanel
ref="infoPanel"
@action="handlePanelAction"
/>
<!-- extensions not enabled by feature flag -->
<div v-if="!hasFeatureFlag">
@ -1008,15 +1032,18 @@ export default {
</Banner>
<Tabbed
v-if="!loading"
ref="tabs"
:tabs-only="true"
data-testid="extension-tabs"
@changed="filterChanged"
@changed="tabChanged"
>
<Tab
v-if="installed.length"
:name="TABS_VALUES.INSTALLED"
data-testid="extension-tab-installed"
label-key="plugins.tabs.installed"
:badge="installed.length"
:weight="20"
/>
<Tab
@ -1025,23 +1052,12 @@ export default {
label-key="plugins.tabs.available"
:weight="19"
/>
<Tab
:name="TABS_VALUES.UPDATES"
label-key="plugins.tabs.updates"
:weight="18"
:badge="updates.length"
/>
<Tab
v-if="pluginDeveloper"
:name="TABS_VALUES.BUILTIN"
label-key="plugins.tabs.builtin"
:weight="17"
/>
<Tab
:name="TABS_VALUES.ALL"
label-key="plugins.tabs.all"
:weight="16"
/>
</Tabbed>
<div
v-if="loading"
@ -1079,8 +1095,8 @@ export default {
:clickable="true"
@card-click="showPluginDetail(card.plugin)"
@uninstall="({event}) => showUninstallDialog(card.plugin, event)"
@update="({event}) => showInstallDialog(card.plugin, 'update', event)"
@rollback="({event}) => showInstallDialog(card.plugin, 'rollback', event)"
@upgrade="({event}) => showInstallDialog(card.plugin, 'upgrade', event)"
@downgrade="({event}) => showInstallDialog(card.plugin, 'downgrade', event)"
@install="({event}) => showInstallDialog(card.plugin, 'install', event)"
>
<template #item-card-sub-header>

View File

@ -0,0 +1,194 @@
import { SteveWatchEventListenerManager } from '@shell/plugins/subscribe-events';
import { STEVE_WATCH_EVENT_TYPES } from '@shell/types/store/subscribe.types';
// Mock function to create a consistent key for testing
const mockKeyForSubscribe = jest.fn(({
params: {
type, name, id, selector, mode
}
}) => {
return `${ type }-${ name }-${ id }-${ selector }-${ mode }`;
});
// Mock parameters and callbacks
const mockParams1 = {
type: 'pods', name: 'my-pod', id: 'abc-123', selector: 'app=test'
};
const mockCallback1 = jest.fn();
const mockCallback2 = jest.fn();
// The class under test
let manager: SteveWatchEventListenerManager;
describe('steveWatchEventListenerManager', () => {
beforeEach(() => {
// Reset the manager and mocks before each test
manager = new SteveWatchEventListenerManager();
jest.clearAllMocks();
// Replace the internal keyForSubscribe with our mock
(manager as any).keyForSubscribe = mockKeyForSubscribe;
});
describe('initialization and Properties', () => {
it('should be created successfully', () => {
expect(manager).toBeInstanceOf(SteveWatchEventListenerManager);
});
it('should have a supportedEventTypes array with STEVE_WATCH_EVENT_TYPES.CHANGES', () => {
expect(manager.supportedEventTypes).toStrictEqual([STEVE_WATCH_EVENT_TYPES.CHANGES]);
});
it('should correctly identify a supported event type', () => {
const isSupported = manager.isSupportedEventType(STEVE_WATCH_EVENT_TYPES.CHANGES);
expect(isSupported).toBe(true);
});
it('should correctly identify an unsupported event type', () => {
const isSupported = manager.isSupportedEventType('some.other.event' as STEVE_WATCH_EVENT_TYPES);
expect(isSupported).toBe(false);
});
});
describe('watch Management', () => {
it('should return undefined when getting a non-existent watch', () => {
const watch = manager.getWatch({ params: mockParams1 });
expect(watch).toBeUndefined();
});
it('should create a watch when setStandardWatch is called with standardWatch true and no watch exists', () => {
manager.setStandardWatch({ standardWatch: true, args: { params: mockParams1 } });
const watch = (manager as any).watches[mockKeyForSubscribe({ params: mockParams1 })];
expect(watch).toBeDefined();
expect(watch.hasStandardWatch).toBe(true);
expect(watch.listeners).toStrictEqual([]);
});
it('should not create a watch when setStandardWatch is called with standardWatch false and no watch exists', () => {
manager.setStandardWatch({ standardWatch: false, args: { params: mockParams1 } });
const watch = (manager as any).watches[mockKeyForSubscribe({ params: mockParams1 })];
expect(watch).toBeUndefined();
});
it('should delete a watch when hasStandardWatch becomes false and there are no listeners', () => {
manager.setStandardWatch({ standardWatch: true, args: { params: mockParams1 } });
manager.setStandardWatch({ standardWatch: false, args: { params: mockParams1 } });
const watch = (manager as any).watches[mockKeyForSubscribe({ params: mockParams1 })];
expect(watch).toBeUndefined();
});
});
describe('listener and Callback Management', () => {
it('should add a new listener and a callback', () => {
const listener = manager.addEventListenerCallback({
callback: mockCallback1,
args: {
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-1'
}
});
expect(listener).toBeDefined();
expect(listener.event).toBe(STEVE_WATCH_EVENT_TYPES.CHANGES);
expect(listener.callbacks['cb-1']).toBe(mockCallback1);
const watch = manager.getWatch({ params: mockParams1 });
expect(watch?.listeners.length).toBe(1);
});
it('should add a second callback to an existing listener', () => {
manager.addEventListenerCallback({
callback: mockCallback1,
args: {
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-1'
}
});
manager.addEventListenerCallback({
callback: mockCallback2,
args: {
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-2'
}
});
const listener = manager.getEventListener({ args: { event: STEVE_WATCH_EVENT_TYPES.CHANGES, params: mockParams1 } });
expect(Object.keys(listener?.callbacks || {})).toHaveLength(2);
expect(listener?.callbacks['cb-2']).toBe(mockCallback2);
});
it('should trigger a specific event listener and its callbacks', () => {
manager.addEventListenerCallback({
callback: mockCallback1,
args: {
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-1'
}
});
manager.addEventListenerCallback({
callback: mockCallback2,
args: {
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-2'
}
});
manager.triggerEventListener({ event: STEVE_WATCH_EVENT_TYPES.CHANGES, params: mockParams1 });
expect(mockCallback1).toHaveBeenCalledTimes(1);
expect(mockCallback2).toHaveBeenCalledTimes(1);
});
it('should remove a specific callback from a listener', () => {
manager.addEventListenerCallback({
callback: mockCallback1,
args: {
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-1'
}
});
manager.removeEventListenerCallback({
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-1'
});
const listener = manager.getEventListener({ args: { event: STEVE_WATCH_EVENT_TYPES.CHANGES, params: mockParams1 } });
expect(listener?.callbacks['cb-1']).toBeUndefined();
});
it('should trigger all callbacks for a given watch', () => {
manager.addEventListenerCallback({
callback: mockCallback1,
args: {
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
params: mockParams1,
id: 'cb-1'
}
});
manager.addEventListenerCallback({
callback: mockCallback2,
args: {
event: 'another.event' as STEVE_WATCH_EVENT_TYPES,
params: mockParams1,
id: 'cb-2'
}
});
manager.triggerAllEventListeners({ params: mockParams1 });
expect(mockCallback1).toHaveBeenCalledTimes(1);
expect(mockCallback2).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -220,6 +220,9 @@ export default {
)
) {
if (opt.watch !== false ) {
// Note - Empty revision here seems broken
// - list page (watch all) --> detail page (stop watch all, watch one) --> list page (watch all - no revision)
// - the missing revision means watch start from now... instead of the point the clusters were last monitored (cache contains stale data)
const args = {
type,
revision: '',

View File

@ -1,4 +1,7 @@
import { NORMAN_NAME } from '@shell/config/labels-annotations';
import { getVersionData } from '@shell/config/version';
import { parseRancherVersion } from '@shell/config/uiplugins';
import semver from 'semver';
import {
_CLONE,
_CONFIG,
@ -59,7 +62,7 @@ const DEFAULT_COLOR = 'warning';
const DEFAULT_ICON = 'x';
const DEFAULT_WAIT_INTERVAL = 1000;
const DEFAULT_WAIT_TMIMEOUT = 30000;
const DEFAULT_WAIT_TIMEOUT = 30000;
export const STATES_ENUM = {
IN_USE: 'in-use',
@ -803,7 +806,7 @@ export default class Resource {
// ------------------------------------------------------------------
waitForTestFn(fn, msg, timeoutMs, intervalMs) {
return waitFor(() => fn.apply(this), msg, timeoutMs || DEFAULT_WAIT_TMIMEOUT, intervalMs || DEFAULT_WAIT_INTERVAL, true);
return waitFor(() => fn.apply(this), msg, timeoutMs || DEFAULT_WAIT_TIMEOUT, intervalMs || DEFAULT_WAIT_INTERVAL, true);
}
waitForState(state, timeout, interval) {
@ -852,7 +855,7 @@ export default class Resource {
return (entry.status || '').toLowerCase() === `${ withStatus }`.toLowerCase();
}
waitForCondition(name, withStatus = 'True', timeoutMs = DEFAULT_WAIT_TMIMEOUT, intervalMs = DEFAULT_WAIT_INTERVAL) {
waitForCondition(name, withStatus = 'True', timeoutMs = DEFAULT_WAIT_TIMEOUT, intervalMs = DEFAULT_WAIT_INTERVAL) {
return this.waitForTestFn(() => {
return this.isCondition(name, withStatus);
}, `condition ${ name }=${ withStatus }`, timeoutMs, intervalMs);
@ -929,12 +932,20 @@ export default class Resource {
const currentRoute = this.currentRouter().currentRoute.value;
const extensionMenuActions = getApplicableExtensionEnhancements(this.$rootState, ExtensionPoint.ACTION, ActionLocation.TABLE, currentRoute, this);
const currRancherVersionData = getVersionData();
const parsedRancherVersion = parseRancherVersion(currRancherVersionData.Version);
// "showConfiguration" table action is only compatible with Rancher 2.13 and onwards
// defence against extension issue https://github.com/rancher/dashboard/issues/15564
// where mostly likely extension CRD model is extending from resource-class
const isResourceDetailDrawerCompatibleWithRancherSystem = semver.satisfies(parsedRancherVersion, '>= 2.13.0');
const all = [
{
action: 'showConfiguration',
label: this.t('action.showConfiguration'),
icon: 'icon icon-document',
enabled: this.disableResourceDetailDrawer !== true && (this.canCustomEdit || this.canYaml), // If the resource can't show an edit or a yaml we don't want to show the configuration drawer
enabled: isResourceDetailDrawerCompatibleWithRancherSystem && this.disableResourceDetailDrawer !== true && (this.canCustomEdit || this.canYaml), // If the resource can't show an edit or a yaml we don't want to show the configuration drawer
},
{ divider: true },
{

View File

@ -1,17 +1,19 @@
import { actions, getters, mutations } from '../subscribe';
import { REVISION_TOO_OLD } from '../../../utils/socket';
import { STEVE_WATCH_EVENT } from '../../../types/store/subscribe.types';
import { STEVE_WATCH_MODE } from '../../../types/store/subscribe.types';
import backOff from '../../../utils/back-off';
import { SteveWatchEventListenerManager } from '../../subscribe-events';
describe('steve: subscribe', () => {
describe('actions', () => {
describe('watch', () => {
const state = {};
const state = { listenerManager: new SteveWatchEventListenerManager() };
const getters = {
normalizeType: (type: string) => type,
schemaFor: () => null,
inError: () => false,
watchStarted: () => false,
normalizeType: (type: string) => type,
schemaFor: () => null,
inError: () => false,
watchStarted: () => false,
listenerManager: state.listenerManager
};
const rootGetters = {
'type-map/isSpoofed': () => false,
@ -211,15 +213,11 @@ describe('steve: subscribe', () => {
}, {
...obj,
revision,
mode: STEVE_WATCH_EVENT.CHANGES,
mode: STEVE_WATCH_MODE.RESOURCE_CHANGES,
force: true,
});
expect(dispatch).toHaveBeenNthCalledWith(1, 'unwatchIncompatible', {
id: undefined, mode: STEVE_WATCH_EVENT.CHANGES, namespace: undefined, selector: undefined, type: obj.type
});
expect(dispatch).toHaveBeenNthCalledWith(2, 'send', {
expect(dispatch).toHaveBeenNthCalledWith(1, 'send', {
debounceMs: 4000,
mode: 'resource.changes',
resourceType: obj.type,
@ -231,7 +229,7 @@ describe('steve: subscribe', () => {
state, dispatch, getters, commit
}, { ...msg });
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledTimes(1);
dispatch.mockClear();
};
@ -252,7 +250,7 @@ describe('steve: subscribe', () => {
});
expect(state.inError).toStrictEqual(
{
'type=abc,namespace=,id=,selector=': {
'type=abc,namespace=,id=,selector=,mode=resource.changes': {
obj: {
type: msg.resourceType,
mode: msg.mode,
@ -268,7 +266,7 @@ describe('steve: subscribe', () => {
}, { ...msg });
// stop tries to watch again, however we're in error so will be ignored
expect(dispatch).toHaveBeenNthCalledWith(1, 'watch', {
id: undefined, mode: STEVE_WATCH_EVENT.CHANGES, namespace: undefined, selector: undefined, type: obj.type
id: undefined, mode: STEVE_WATCH_MODE.RESOURCE_CHANGES, namespace: undefined, selector: undefined, standardWatch: true, type: obj.type
});
dispatch.mockClear();
@ -349,19 +347,24 @@ describe('steve: subscribe', () => {
const obj = { type: 'abc' };
const msg = {
resourceType: obj.type,
mode: STEVE_WATCH_EVENT.CHANGES,
mode: STEVE_WATCH_MODE.RESOURCE_CHANGES,
};
const initStore = () => {
const state = { started: [], inError: {} };
const state = {
started: [],
inError: {},
listenerManager: new SteveWatchEventListenerManager()
};
const _getters = {
normalizeType: (type: string) => type,
schemaFor: () => ({}),
storeName: 'test',
inError: (...args) => getters.inError(state)(...args),
watchStarted: (...args) => getters.watchStarted(state)(...args),
backOffId: (...args) => getters.backOffId()(...args),
canBackoff: () => true,
normalizeType: (type: string) => type,
schemaFor: () => ({}),
storeName: 'test',
inError: (...args) => getters.inError(state)(...args),
watchStarted: (...args) => getters.watchStarted(state)(...args),
backOffId: (...args) => getters.backOffId()(...args),
canBackoff: () => true,
listenerManager: state.listenerManager
};
const commit = (type, ...args) => mutations[type](state, ...args);

View File

@ -9,6 +9,8 @@ import {
import getters, { STEVE_MODEL_TYPES } from './getters';
import mutations from './mutations';
import actions from './actions';
import { SteveWatchEventListenerManager } from '@shell/plugins/subscribe-events';
import { markRaw } from 'vue';
export function SteveFactory(namespace, baseUrl) {
return {
@ -17,16 +19,22 @@ export function SteveFactory(namespace, baseUrl) {
state() {
return {
...coreStoreState(namespace, baseUrl),
socket: null,
queue: [], // For change event coalescing
wantSocket: false,
debugSocket: false,
allowStreaming: true,
pendingFrames: [],
deferredRequests: {},
started: [],
inError: {},
podsByNamespace: {}, // Cache of pods by namespace
socket: null,
queue: [], // For change event coalescing
wantSocket: false,
debugSocket: false,
allowStreaming: true,
pendingFrames: [],
deferredRequests: {},
started: [],
inError: {},
/**
* Socket listener manager for this store
*
* Instance of @SteveWatchEventListenerManager . See it's description for more info
*/
socketListenerManager: markRaw(new SteveWatchEventListenerManager()),
podsByNamespace: {}, // Cache of pods by namespace
};
},

View File

@ -164,10 +164,10 @@ export default {
},
reset(state) {
// Reset generic store things.... then steve specific things
// 1. Reset generic store things
resetStore(state, this.commit);
// 2. Reset steve specific store things
this.commit(`${ state.config.namespace }/resetSubscriptions`);
// Clear the podsByNamespace cache

View File

@ -40,10 +40,10 @@ export const WATCH_STATUSES = {
* Create a unique key for a specific resource watch's params
*/
export const keyForSubscribe = ({
resourceType, type, namespace, id, selector
resourceType, type, namespace, id, selector, mode
} = {}) => {
const keyMap = {
type: resourceType || type, namespace, id, selector
type: resourceType || type, namespace, id, selector, mode
};
return Object.entries(keyMap)

View File

@ -615,7 +615,6 @@ class StevePaginationUtils extends NamespaceProjectFilters {
res.push(`filter=!${ labelKey }`);
break;
case 'Gt':
// Currently broken - see https://github.com/rancher/rancher/issues/50057
// Only applicable to node affinity (atm) - https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#operators
if (typeof exp.values !== 'string') {
@ -628,7 +627,6 @@ class StevePaginationUtils extends NamespaceProjectFilters {
res.push(`filter=${ labelKey } > (${ exp.values })`);
break;
case 'Lt':
// Currently broken - see https://github.com/rancher/rancher/issues/50057
// Only applicable to node affinity (atm) - https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#operators
if (typeof exp.values !== 'string') {
console.error(`Skipping labelSelector to API filter param conversion for ${ exp.key }(Lt) as no value was supplied`); // eslint-disable-line no-console
@ -673,8 +671,8 @@ export const PAGINATION_SETTINGS_STORE_DEFAULTS: PaginationSettingsStores = {
enableAll: false,
enableSome: {
enabled: [
// { resource: CAPI.RANCHER_CLUSTER, context: ['home', 'side-bar'] }, // Disabled due to https://github.com/rancher/dashboard/issues/14493
// { resource: MANAGEMENT.CLUSTER, context: ['side-bar'] }, // Disabled due to https://github.com/rancher/dashboard/issues/14493
// { resource: CAPI.RANCHER_CLUSTER, context: ['home', 'side-bar'] },
// { resource: MANAGEMENT.CLUSTER, context: ['side-bar'] },
{ resource: CATALOG.APP, context: ['branding'] },
SECRET
],

View File

@ -23,11 +23,13 @@
* Successfully flow - watch
* 1. UI --> Rancher: _watch_ request
* 2. Rancher --> UI: `resource.start`. UI sets watch as started
* ...
* 3. Rancher --> UI: `resource.change` (contains data). UI caches data
*
* Successful flow - watch - new mode
* 1. UI --> Rancher: _watch_ request
* 2. Rancher --> UI: `resource.start`. UI sets watch as started
* ...
* 3. Rancher --> UI: `resource.changes` (contains no data). UI makes a HTTP request to fetch data
*
* Successful flow - unwatch
@ -84,9 +86,10 @@ import { WORKER_MODES } from './worker';
import acceptOrRejectSocketMessage from './accept-or-reject-socket-message';
import { BLANK_CLUSTER, STORE } from '@shell/store/store-types.js';
import { _MERGE } from '@shell/plugins/dashboard-store/actions';
import { STEVE_WATCH_EVENT, STEVE_WATCH_MODE } from '@shell/types/store/subscribe.types';
import { STEVE_WATCH_EVENT_TYPES, STEVE_WATCH_MODE } from '@shell/types/store/subscribe.types';
import paginationUtils from '@shell/utils/pagination-utils';
import backOff from '@shell/utils/back-off';
import { SteveWatchEventListenerManager } from '@shell/plugins/subscribe-events';
// minimum length of time a disconnect notification is shown
const MINIMUM_TIME_NOTIFIED = 3000;
@ -312,24 +315,6 @@ function growlsDisabled(rootGetters) {
return getPerformanceSetting(rootGetters)?.disableWebsocketNotification;
}
/**
* Supported events are listed
*
* of type { [key: STEVE_WATCH_EVENT]: STEVE_WATCH_EVENT_LISTENER[]}
*/
const listeners = { [STEVE_WATCH_EVENT.CHANGES]: [] };
/**
* Given a started or error entry, is it compatible with the given change in mode?
*/
const shouldUnwatchIncompatible = (messageMeta, mode) => {
if (messageMeta.mode === STEVE_WATCH_EVENT.CHANGES) {
return mode !== STEVE_WATCH_EVENT.CHANGES;
}
return mode === STEVE_WATCH_EVENT.CHANGES;
};
/**
* clear the provided error, but also ensure any backoff request associated with it is cleared as well
*/
@ -452,7 +437,7 @@ const sharedActions = {
* @param {STEVE_WATCH_EVENT_PARAMS} event
*/
watchEvent(ctx, {
event = STEVE_WATCH_EVENT.CHANGES,
event = STEVE_WATCH_EVENT_TYPES.CHANGES,
id,
callback,
/**
@ -460,26 +445,27 @@ const sharedActions = {
*/
params
}) {
if (!listeners[event]) {
console.error(`Unknown event type "${ event }", only ${ Object.keys(listeners).join(',') } are supported`); // eslint-disable-line no-console
if (!ctx.getters.listenerManager.isSupportedEventType(event)) {
console.error(`Unknown event type "${ event }", only ${ Object.keys(ctx.getters.listenerManager.supportedEventTypes).join(',') } are supported`); // eslint-disable-line no-console
return;
}
// STEVE_WATCH_EVENT_LISTENER | undefined
let listener = listeners[event].find((l) => equivalentWatch(l.params, params));
ctx.getters.listenerManager.addEventListenerCallback({
callback,
args: {
event, params, id
}
});
if (!listener) {
listener = {
params,
callbacks: { }
};
listeners[event].push(listener);
}
const hasStandardWatch = ctx.getters.listenerManager.hasStandardWatch({ params });
if (!listener.callbacks[id]) {
listener.callbacks[id] = callback;
ctx.dispatch('watch', params);
if (!hasStandardWatch) {
// If there's nothing to piggy back on... start a watch to do so.
ctx.dispatch('watch', {
...params,
standardWatch: false // Ensure that we don't treat this as a standard watch
});
}
},
@ -488,24 +474,26 @@ const sharedActions = {
* @param {STEVE_UNWATCH_EVENT_PARAMS} event
*/
unwatchEvent(ctx, {
event = STEVE_WATCH_EVENT.CHANGES,
event = STEVE_WATCH_EVENT_TYPES.CHANGES,
id,
/**
* of type @STEVE_WATCH_PARAMS
*/
params
}) {
if (!listeners[event]) {
if (!ctx.getters.listenerManager.isSupportedEventType(event)) {
console.info(`Attempted to unwatch for an event "${ event }" but it had no watchers`); // eslint-disable-line no-console
return;
}
const existing = listeners[event].find((l) => equivalentWatch(l.params, params));
ctx.getters.listenerManager.removeEventListenerCallback({
event, params, id
});
if (existing) {
delete existing.callbacks[id];
}
// Unwatch the underlying standard watch
// Note - If we were piggybacking on a watch that previously existed we won't unwatch it
ctx.dispatch('unwatch', params);
},
/**
@ -517,7 +505,7 @@ const sharedActions = {
state.debugSocket && console.info(`Watch Request [${ getters.storeName }]`, JSON.stringify(params)); // eslint-disable-line no-console
let {
// eslint-disable-next-line prefer-const
type, selector, id, revision, namespace, stop, force, mode
type, selector, id, revision, namespace, stop, force, mode, standardWatch = true
} = params;
namespace = acceptOrRejectSocketMessage.subscribeNamespace(namespace);
@ -562,10 +550,6 @@ const sharedActions = {
return;
}
if (!stop) {
dispatch('unwatchIncompatible', messageMeta);
}
// Watch errors mean we make a http request to get latest revision (which is still missing) and try to re-watch with it...
// etc
if (typeof revision === 'undefined') {
@ -618,6 +602,12 @@ const sharedActions = {
return;
}
if (!stop && standardWatch) {
// Track that this watch is just a normal one, not one kicked off by listeners
// This helps us keep the watch going (for listeners) instead of in unwatch just stopping it
getters.listenerManager.setStandardWatch({ standardWatch: true, args: { event: msg.mode, params: msg } });
}
return dispatch('send', msg);
},
@ -642,6 +632,21 @@ const sharedActions = {
};
const unwatch = (obj) => {
// Has this normal watch got listeners? If so
const hasStandardWatch = ctx.getters.listenerManager.hasStandardWatch({ params: obj });
const watchHasListeners = ctx.getters.listenerManager.hasEventListeners({ params: obj });
if (hasStandardWatch) {
// If we have listeners for this watch... make sure it knows there's now no root standard watch
ctx.getters.listenerManager.setStandardWatch({ standardWatch: false, args: { params: obj } });
}
if (watchHasListeners) {
// Does this watch have listeners? if so we shouldn't stop it (they still need it)
return;
}
if (getters['watchStarted'](obj)) {
// Set that we don't want to watch this type
// Otherwise, the dispatch to unwatch below will just cause a re-watch when we
@ -651,45 +656,28 @@ const sharedActions = {
// Make sure anything in the pending queue for the type is removed, since we've now removed the type
commit('clearFromQueue', type);
}
// Ensure anything pinging in the background is stopped
backOff.resetPrefix(getters.backOffId(obj));
};
const objKey = keyForSubscribe(obj);
const reset = [];
if (isAdvancedWorker(ctx)) {
dispatch('watch', obj); // Ask the backend to stop watching the type
} else if (all) {
getters['watchesOfType'](type).forEach((obj) => {
unwatch({ ...obj, stop: true });
});
reset.push(...getters['watchesOfType'](type));
} else if (getters['watchStarted'](obj)) {
unwatch(obj);
reset.push(obj);
}
}
},
/**
* Unwatch watches that are incompatible with the new type
*/
unwatchIncompatible({
state, dispatch, getters, commit
}, messageMeta) {
// Step 1 - Clear incompatible watches that have STARTED
const watchesOfType = getters.watchesOfType(messageMeta.type);
watchesOfType
.filter((entry) => shouldUnwatchIncompatible(messageMeta, entry.mode))
.forEach((entry) => {
dispatch('unwatch', entry);
reset.forEach((obj) => {
unwatch(obj);
// Ensure anything pinging in the background is stopped
dispatch('resetWatchBackOff', {
type,
compareWatches: (entry) => objKey === keyForSubscribe(entry)
});
});
// Step 2 - Clear inError state for incompatible watches (these won't appear in watchesOfType / state.started)
// (important for the backoff case... for example backoff request to find would overwrite findPage res if executed after nav from detail to list)
const inErrorOfType = Object.values(state.inError || {})
.filter((error) => error.obj.type === messageMeta.type);
inErrorOfType
.filter((error) => shouldUnwatchIncompatible(messageMeta, error.obj.mode))
.forEach((error) => clearInError({ getters, commit }, error));
}
},
/**
@ -704,7 +692,7 @@ const sharedActions = {
if (resetStarted && state.started?.length) {
let entries = state.started;
if (type) { // Filter out ones for types we're no interested in
if (type || compareWatches) { // Filter out ones for types we're no interested in
entries = entries
.filter((obj) => compareWatches ? compareWatches(obj) : obj.type === type);
}
@ -718,7 +706,7 @@ const sharedActions = {
// however resource.stop clears `started` and we need the settings to persist over start-->error-->stop-->start cycles
let entries = Object.values(state.inError || {});
if (type) { // Filter out ones for types we're no interested in
if (type || compareWatches) { // Filter out ones for types we're no interested in
entries = entries
.filter((error) => compareWatches ? compareWatches(error.obj) : error.obj.type === type);
}
@ -817,6 +805,7 @@ const defaultActions = {
if ( getters.schemaFor(entry.type) ) {
commit('setWatchStopped', entry);
// Delete the cached socket revision, forcing the watch to get latest revision from cached resources instead
delete entry.revision;
promises.push(dispatch('watch', entry));
}
@ -901,11 +890,7 @@ const defaultActions = {
}
// Should any listeners be notified of this request for them to kick off their own event handling?
const listener = listeners[STEVE_WATCH_MODE.RESOURCE_CHANGES].find((sl) => equivalentWatch(sl.params, params));
if (listener) {
Object.values(listener.callbacks).forEach((cb) => cb());
}
getters.listenerManager.triggerEventListener({ event: STEVE_WATCH_MODE.RESOURCE_CHANGES, params });
} else {
have = getters['all'](resourceType).slice();
@ -1086,10 +1071,16 @@ const defaultActions = {
mode: msg.mode,
};
// Unwatch watches that are incompatible with the new type
// This is mainly to prevent the cache being polluted with resources that aren't compatible with it's aim
// For instance if the store/cache for pods contains a namespace X and we watch another namespace Y... we don't want ns X resources added to cache
// Unwatch incompatible watches
state.started.filter((entry) => {
if (
entry.type === newWatch.type &&
entry.namespace !== newWatch.namespace
(entry.type === newWatch.type) &&
(entry.namespace !== newWatch.namespace) &&
(!entry.mode && !newWatch.mode) // mode watches will be handled when they become an issue
) {
return true;
}
@ -1178,7 +1169,25 @@ const defaultActions = {
commit('setWatchStopped', obj);
}
dispatch('watch', obj);
// Now re-watch
const hasEventListeners = getters.listenerManager.hasEventListeners({ params: obj });
const hasStandardWatch = getters.listenerManager.hasStandardWatch({ params: obj });
dispatch('watch', {
...obj,
// hasEventListeners && !hasStandardWatch ? false : true
// if this watch isn't associated with a normal watch... (there are no listeners, or there are listeners but also a normal watch)
standardWatch: !(hasEventListeners && !hasStandardWatch)
});
if (hasEventListeners) {
// If there's event listeners always kick them off
// - The re-watch associated with normal watches will watch from a revision from it's own cache
// - The revision in that cache might be ahead of the state the listeners have, so the watch won't ping something for the listeners to trigger on
// - so to work around this whenever we start the watches again trigger off the changes for it
// Improvement - we only do one event here (currently the only one supported), could expand to others
getters.listenerManager.triggerEventListener({ event: STEVE_WATCH_EVENT_TYPES.CHANGES, params: obj });
}
}
},
@ -1210,6 +1219,15 @@ const defaultActions = {
}
}
const havePage = ctx.getters['havePage'](type);
if (havePage) {
console.warn(`Prevented watch \`resource.change\` data from polluting the cache for type "${ type }" (currently represents a page). To prevent any further issues the watch has been stopped.`, data); // eslint-disable-line no-console
ctx.dispatch('unwatch', data);
return;
}
queueChange(ctx, msg, true, 'Change');
const typeOption = ctx.rootGetters['type-map/optionsFor'](type);
@ -1344,6 +1362,7 @@ const defaultMutations = {
clearTimeout(state.queueTimer);
state.deferredRequests = {};
state.queueTimer = null;
state.socketListenerManager = new SteveWatchEventListenerManager(state.config.namespace);
},
clearFromQueue(state, type) {
@ -1444,6 +1463,15 @@ const defaultGetters = {
return revision || null;
},
/**
* Get the watch listener manager for this store
*
* Instance of @SteveWatchEventListenerManager . See it's description for more info
*/
listenerManager: (state) => {
return state.socketListenerManager;
},
};
export const actions = {

View File

@ -0,0 +1,211 @@
import { keyForSubscribe } from '@shell/plugins/steve/resourceWatcher';
import {
SubscribeEventListener, SubscribeEventCallbackArgs, SubscribeEventListenerArgs, SubscribeEventWatch, SubscribeEventWatchArgs,
STEVE_WATCH_EVENT_LISTENER_CALLBACK
} from '@shell/types/store/subscribe-events.types';
import { STEVE_WATCH_EVENT_TYPES, STEVE_WATCH_PARAMS } from '@shell/types/store/subscribe.types';
type SubscribeEventWatches = { [socketId: string]: SubscribeEventWatch};
/**
* For a specific resource watch, listen for a specific event type and trigger callback when received
*
* For example, listen for provisioning.cattle.io clusters messages of type resource.changes and trigger callback when received
*
* Watch - UI is watching a resource type restricted by nothing/id/namespace/selector. For example
* - watch all pods
* - watch specific pod
* - watch pods with specific labels
* Event - Rancher socket messages TO the ui. For example
* - resource.started
* - resource.change
* - resource.changes
* Listener - listen to events, trigger when received. For example
* - listen for resource.changes messages for the all pods watch
* Callback - triggered when a listener has heard something
* - watch for all pods receives a resource.changes message, it has a listener, listener executes it's callback
*
* Watch 0:M Events 0:M Listeners 0:M Callbacks
*/
export class SteveWatchEventListenerManager {
private keyForSubscribe({ params }: {params: STEVE_WATCH_PARAMS}): string {
return keyForSubscribe(params);
}
/**
* collection of ui --> rancher watches. we keep state specific to this class here
*/
private watches: SubscribeEventWatches = {};
/**
* Not all event types can be listened to are supported, only these
*/
public readonly supportedEventTypes: STEVE_WATCH_EVENT_TYPES[] = [STEVE_WATCH_EVENT_TYPES.CHANGES];
/**
* Not all event types can be listened to are supported, check if one is
*/
public isSupportedEventType(type: STEVE_WATCH_EVENT_TYPES): boolean {
return !!this.supportedEventTypes.includes(type);
}
/** **** Watches ***********************/
public getWatch({ params } : SubscribeEventWatchArgs): SubscribeEventWatch {
const socketId = this.keyForSubscribe({ params });
return this.watches[socketId];
}
private initialiseWatch({ params }: SubscribeEventWatchArgs): SubscribeEventWatch {
const socketId = this.keyForSubscribe({ params });
this.watches[socketId] = {
hasStandardWatch: false,
listeners: []
};
return this.watches[socketId];
}
/**
* This is just tidying the entry
*
* All watches associated with this type should be unwatched
*/
private deleteWatch({ params } : SubscribeEventWatchArgs) {
const socketId = this.keyForSubscribe({ params });
delete this.watches[socketId];
}
/**
* Is there a standard non-listener watch for this this type
*/
public hasStandardWatch({ params } : SubscribeEventWatchArgs): boolean {
const socketId = this.keyForSubscribe({ params });
return this.watches[socketId]?.hasStandardWatch;
}
/**
* Set if this type has a standard non-listener watch associated with it
*/
public setStandardWatch({ standardWatch, args }: { standardWatch: boolean, args: SubscribeEventWatchArgs}) {
const { params } = args;
let watch = this.getWatch({ params });
if (!watch) {
if (!standardWatch) {
// no point setting a non-existent watch as not started
return;
}
watch = this.initialiseWatch({ params });
}
watch.hasStandardWatch = standardWatch;
// if we've just set this to false and there's no listeners, tidy up the entry
if (!watch.hasStandardWatch && watch.listeners.length === 0) {
this.deleteWatch({ params });
}
}
/** **** Listeners ***********************/
public hasEventListeners({ params }: SubscribeEventWatchArgs): boolean {
const socketId = this.keyForSubscribe({ params });
const watch = this.watches[socketId];
const listener = watch?.listeners.find((l) => Object.values(l.callbacks).length > 0);
return !!listener;
}
public getEventListener({ entryOnly, args }: { entryOnly?: boolean, args: SubscribeEventListenerArgs}): SubscribeEventListener | null {
const { params, event } = args;
const socketId = this.keyForSubscribe({ params });
const watch = this.watches[socketId];
if (watch) {
const listener = watch.listeners.find((w) => w.event === event);
if (listener && (entryOnly || !!Object.keys(listener?.callbacks || {}).length)) {
return listener;
}
}
return null;
}
public addEventListener({ event, params }: SubscribeEventListenerArgs): SubscribeEventListener {
if (!event) {
throw new Error(`Cannot add a socket watch event listener if there's no event to listen to`);
}
let watch = this.getWatch({ params });
if (!watch) {
watch = this.initialiseWatch({ params });
}
let listener = this.getEventListener({ entryOnly: true, args: { event, params } });
if (!listener) {
listener = {
event,
callbacks: { },
};
watch.listeners.push(listener);
}
return listener;
}
public triggerEventListener({ event, params }: SubscribeEventListenerArgs) {
const eventWatcher = this.getEventListener({ entryOnly: false, args: { event, params } });
if (eventWatcher) {
Object.values(eventWatcher.callbacks).forEach((cb) => {
cb();
});
}
}
public triggerAllEventListeners({ params }: SubscribeEventWatchArgs) {
const watch = this.getWatch({ params });
watch.listeners.forEach((l) => {
Object.values(l.callbacks || {}).forEach((cb) => cb());
});
}
/** **** Callbacks ***********************/
public addEventListenerCallback({ callback, args }: {
callback: STEVE_WATCH_EVENT_LISTENER_CALLBACK,
args: SubscribeEventCallbackArgs
}): SubscribeEventListener {
const { params, event, id } = args;
const eventWatcher = this.addEventListener({ event, params });
if (!eventWatcher.callbacks[id]) {
eventWatcher.callbacks[id] = callback;
}
return eventWatcher;
}
/**
* This is just tidying the entry
*
* All watches associated with this type should be unwatched
*/
public removeEventListenerCallback({ event, params, id }: SubscribeEventCallbackArgs) {
const existing = this.getEventListener({ args: { event, params } });
if (existing) {
delete existing.callbacks[id];
}
}
}

View File

@ -40,6 +40,7 @@ import { isDevBuild } from '@shell/utils/version';
import { markRaw } from 'vue';
import paginationUtils from '@shell/utils/pagination-utils';
import { addReleaseNotesNotification } from '@shell/utils/release-notes';
import sideNavService from '@shell/components/nav/TopLevelMenu.helper';
// Disables strict mode for all store instances to prevent warning about changing state outside of mutations
// because it's more efficient to do that sometimes.
@ -260,11 +261,8 @@ export const state = () => {
$router: markRaw({}),
$route: markRaw({}),
$plugin: markRaw({}),
/**
* Cache state of side nav clusters. This avoids flickering when the user changes pages and the side nav component re-renders
*/
sideNavCache: undefined,
showWorkspaceSwitcher: true,
};
};
@ -629,10 +627,6 @@ export const getters = {
return `${ base }/latest`;
},
sideNavCache(state) {
return state.sideNavCache;
},
...gcGetters
};
@ -773,10 +767,6 @@ export const mutations = {
state.$plugin = markRaw(pluginDefinition || {});
},
setSideNavCache(state, sideNavCache) {
state.sideNavCache = sideNavCache;
},
showWorkspaceSwitcher(state, value) {
state.showWorkspaceSwitcher = value;
},
@ -1189,6 +1179,8 @@ export const actions = {
}
});
sideNavService.reset();
await dispatch('management/unsubscribe');
commit('managementChanged', { ready: false });
commit('management/reset');
@ -1310,10 +1302,6 @@ export const actions = {
});
},
setSideNavCache({ commit }, sideNavCache) {
commit('setSideNavCache', sideNavCache);
},
showWorkspaceSwitcher({ commit }, value) {
commit('showWorkspaceSwitcher', value);
},

View File

@ -0,0 +1,70 @@
import { STEVE_WATCH_EVENT_TYPES, STEVE_WATCH_EVENT_TYPES_NAMES, STEVE_WATCH_PARAMS } from '@shell/types/store/subscribe.types';
/**
* Common params used when a watcher adds or removes a listener to a watch
*/
export interface STEVE_WATCH_EVENT_PARAMS_COMMON {
event: STEVE_WATCH_EVENT_TYPES,
id: string,
/**
* of type @STEVE_WATCH_PARAMS
*/
params: STEVE_WATCH_PARAMS,
}
/**
* Executes when a watch event has a listener and it's triggered
*/
export type STEVE_WATCH_EVENT_LISTENER_CALLBACK = () => void
/**
* Common params used when a watcher adds a listener to a watch
*/
export interface STEVE_WATCH_EVENT_PARAMS extends STEVE_WATCH_EVENT_PARAMS_COMMON {
callback: STEVE_WATCH_EVENT_LISTENER_CALLBACK,
}
/**
* Common params used when a watcher removes a listener from a watch
*/
export type STEVE_UNWATCH_EVENT_PARAMS = STEVE_WATCH_EVENT_PARAMS_COMMON
/**
* Common properties for identifying a subscribe watcher
*/
export interface SubscribeEventWatchArgs {
params: STEVE_WATCH_PARAMS,
}
/**
* Common properties for identifying a subscribe watcher's listeners
*/
export interface SubscribeEventListenerArgs extends SubscribeEventWatchArgs {
event: STEVE_WATCH_EVENT_TYPES_NAMES,
}
/**
* Common properties for identifying a subscribe watcher's listener
*/
export interface SubscribeEventCallbackArgs extends SubscribeEventListenerArgs {
id: string,
}
/**
* Represents a subscribe watcher listener
*/
export interface SubscribeEventListener {
event: STEVE_WATCH_EVENT_TYPES_NAMES,
callbacks: { [id: string]: STEVE_WATCH_EVENT_LISTENER_CALLBACK},
}
/**
* Represents a subscribe watcher
*/
export type SubscribeEventWatch = {
/**
* is there a standard non-listener watch for this type (i.e. vanilla watch)
*/
hasStandardWatch: boolean,
listeners: SubscribeEventListener[],
}

View File

@ -6,7 +6,7 @@ export enum STEVE_WATCH_MODE {
/* eslint-enable no-unused-vars */
/* eslint-disable no-unused-vars */
export enum STEVE_WATCH_EVENT {
export enum STEVE_WATCH_EVENT_TYPES {
START = 'resource.start',
CREATE = 'resource.create',
CHANGE = 'resource.change',
@ -17,6 +17,11 @@ export enum STEVE_WATCH_EVENT {
}
/* eslint-enable no-unused-vars */
export type STEVE_WATCH_EVENT_TYPES_NAMES = `${ STEVE_WATCH_EVENT_TYPES }`;
/**
* The content of the web socket messages sent (and partially received back from) steve
*/
export interface STEVE_WATCH_PARAMS {
type: string,
selector?: string,
@ -27,24 +32,3 @@ export interface STEVE_WATCH_PARAMS {
force?: boolean,
mode?: STEVE_WATCH_MODE
}
export type STEVE_WATCH_EVENT_LISTENER_CALLBACK = () => void
export interface STEVE_WATCH_EVENT_LISTENER {
params: STEVE_WATCH_PARAMS,
callbacks: { [id: string]: STEVE_WATCH_EVENT_LISTENER_CALLBACK},
}
export interface STEVE_WATCH_EVENT_PARAMS_COMMON {
event: STEVE_WATCH_EVENT,
id: string,
/**
* of type @STEVE_WATCH_PARAMS
*/
params: STEVE_WATCH_PARAMS,
}
export interface STEVE_WATCH_EVENT_PARAMS extends STEVE_WATCH_EVENT_PARAMS_COMMON {
callback: STEVE_WATCH_EVENT_LISTENER_CALLBACK,
}
export type STEVE_UNWATCH_EVENT_PARAMS = STEVE_WATCH_EVENT_PARAMS_COMMON

View File

@ -2,10 +2,9 @@ import paginationUtils from '@shell/utils/pagination-utils';
import { PaginationArgs, PaginationResourceContext } from '@shell/types/store/pagination.types';
import { VuexStore } from '@shell/types/store/vuex';
import { ActionFindPageArgs, ActionFindPageTransientResult } from '@shell/types/store/dashboard-store.types';
import {
STEVE_WATCH_EVENT_LISTENER_CALLBACK, STEVE_UNWATCH_EVENT_PARAMS, STEVE_WATCH_EVENT, STEVE_WATCH_EVENT_PARAMS, STEVE_WATCH_EVENT_PARAMS_COMMON, STEVE_WATCH_MODE
} from '@shell/types/store/subscribe.types';
import { STEVE_WATCH_EVENT_TYPES, STEVE_WATCH_MODE } from '@shell/types/store/subscribe.types';
import { Reactive, reactive } from 'vue';
import { STEVE_UNWATCH_EVENT_PARAMS, STEVE_WATCH_EVENT_LISTENER_CALLBACK, STEVE_WATCH_EVENT_PARAMS, STEVE_WATCH_EVENT_PARAMS_COMMON } from '@shell/types/store/subscribe-events.types';
interface Args {
$store: VuexStore,
@ -92,7 +91,7 @@ class PaginationWrapper<T extends object> {
// Watch
if (this.onChange && !this.steveWatchParams) {
this.steveWatchParams = {
event: STEVE_WATCH_EVENT.CHANGES,
event: STEVE_WATCH_EVENT_TYPES.CHANGES,
id: this.id,
params: {
type: this.enabledFor.resource?.id as string,
@ -126,7 +125,7 @@ class PaginationWrapper<T extends object> {
}
const watchParams: STEVE_WATCH_EVENT_PARAMS = {
...this.steveWatchParams,
callback: this.onChange as STEVE_WATCH_EVENT_LISTENER_CALLBACK, // we must have it by now
callback: this.onChange as STEVE_WATCH_EVENT_LISTENER_CALLBACK, // we must have onChange by now
};
await this.$store.dispatch(`${ this.enabledFor.store }/watchEvent`, watchParams);
@ -134,8 +133,7 @@ class PaginationWrapper<T extends object> {
private async unWatch() {
if (!this.steveWatchParams) {
console.error('Calling unWatch but no watch params created'); // eslint-disable-line no-console
// We're unwatching before we've made the initial request
return;
}