From b6fdcc7a443139675c9e58b734e041678167565e Mon Sep 17 00:00:00 2001 From: PeterYurkovich Date: Tue, 25 Aug 2026 16:16:25 -0400 Subject: [PATCH 1/2] feat: remove perses /permissions api call for direct RBAC checks --- devspace.yaml | 10 +- scripts/build-image.sh | 2 +- .../support/commands/coo-install-commands.ts | 144 +++------- .../perses/99.coo_rbac_perses_user1.cy.ts | 28 +- .../perses/99.coo_rbac_perses_user2.cy.ts | 59 ++-- .../perses/99.coo_rbac_perses_user3.cy.ts | 12 +- .../perses/99.coo_rbac_perses_user4.cy.ts | 43 ++- .../perses/99.coo_rbac_perses_user6.cy.ts | 24 +- .../perses-dashboards-create-dashboard.ts | 30 +- .../perses-dashboards-import-dashboard.ts | 18 +- .../perses-dashboards-list-dashboards.ts | 52 ++-- web/eslint-rules/file-naming.spec.ts | 27 +- web/eslint-rules/file-naming.ts | 9 +- web/locales/en/plugin__monitoring-plugin.json | 16 +- .../components/DashboardActionModals.tsx | 109 +++---- .../components/DashboardActionsMenu.tsx | 19 +- .../components/DashboardCreateDialog.tsx | 137 +++++---- .../components/DashboardDialogHelpers.tsx | 138 ++++----- .../components/DashboardImportDialog.tsx | 196 ++++++------- .../components/project/ProjectBar.tsx | 33 --- .../components/project/ProjectDropdown.tsx | 265 ------------------ .../components/project/ProjectMenuToggle.tsx | 97 ------- .../components/project/utils.ts | 14 - .../hooks/useDashboardsData.ts | 20 +- .../hooks/useEditableProjects.ts | 116 -------- .../perses-dashboards/hooks/usePerses.ts | 4 - .../hooks/usePersesDashboardAccess.ts | 16 ++ .../hooks/usePersesEditPermissions.ts | 29 -- .../dashboard-list-page/DashboardList.tsx | 91 +++--- .../DashboardListFrame.tsx | 19 +- .../pages/dashboard-page/DashboardFrame.tsx | 15 +- .../pages/dashboard-page/DashboardToolbar.tsx | 40 +-- .../perses-dashboards/utils/perses-client.ts | 41 --- web/src/shared/constants/data-test.ts | 3 + 34 files changed, 660 insertions(+), 1216 deletions(-) delete mode 100644 web/src/features/perses-dashboards/components/project/ProjectBar.tsx delete mode 100644 web/src/features/perses-dashboards/components/project/ProjectDropdown.tsx delete mode 100644 web/src/features/perses-dashboards/components/project/ProjectMenuToggle.tsx delete mode 100644 web/src/features/perses-dashboards/components/project/utils.ts delete mode 100644 web/src/features/perses-dashboards/hooks/useEditableProjects.ts create mode 100644 web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts delete mode 100644 web/src/features/perses-dashboards/hooks/usePersesEditPermissions.ts diff --git a/devspace.yaml b/devspace.yaml index bec2eba8b..d800cd936 100644 --- a/devspace.yaml +++ b/devspace.yaml @@ -45,8 +45,9 @@ dev: sync: - path: ./web/dist:/opt/app-root/web/dist startContainer: true - command: ["make"] - args: ["start-devspace-backend"] + command: ["sh", "-c"] + args: + - until test -f /opt/app-root/web/dist/plugin-manifest.json; do sleep 1; done; exec make start-devspace-backend ssh: enabled: true @@ -63,7 +64,8 @@ dev: sync: - path: ./web/dist:/opt/app-root/web/dist startContainer: true - command: ["make"] - args: ["start-devspace-mcp-backend"] + command: ["sh", "-c"] + args: + - until test -f /opt/app-root/web/dist/plugin-manifest.json; do sleep 1; done; exec make start-devspace-mcp-backend ssh: enabled: true diff --git a/scripts/build-image.sh b/scripts/build-image.sh index 93abee039..2563d75e2 100755 --- a/scripts/build-image.sh +++ b/scripts/build-image.sh @@ -46,7 +46,7 @@ echo_vars if [[ $INTERACTIVE == 1 ]]; then # Prompt use it check env vars before proceeding to build - read -r -p "Are the environmental variables correct [y/N] " response + read -r -p "Are the environmental variables correct [Y/n] " response if [[ "${response:0:1}" =~ ^([nN])$ ]]; then exit 0 fi diff --git a/web/cypress/support/commands/coo-install-commands.ts b/web/cypress/support/commands/coo-install-commands.ts index c410d907e..dd4866aba 100644 --- a/web/cypress/support/commands/coo-install-commands.ts +++ b/web/cypress/support/commands/coo-install-commands.ts @@ -355,127 +355,47 @@ export const cooInstallUtils = { } }); - const checkIntervalMs = 15000; - const startTime = Date.now(); - const maxWaitTimeMs = 600000; - - const checkStatus = () => { - const elapsed = Date.now() - startTime; - - if (elapsed > maxWaitTimeMs) { - cy.log( - `${elapsed}ms - Timeout reached (${maxWaitTimeMs / 60000}m). Namespace ${ - CLUSTER_OBSERVABILITY_OPERATOR.namespace - } still terminating. Attempting force-delete.`, - ); - return cy + cy.waitUntil( + () => + cy .exec( - `./cypress/fixtures/coo/force_delete_ns.sh ` + - `${CLUSTER_OBSERVABILITY_OPERATOR.namespace} "${Cypress.env('KUBECONFIG_PATH')}"`, - { failOnNonZeroExit: false, timeout: installTimeoutMilliseconds }, + `oc get namespace ${CLUSTER_OBSERVABILITY_OPERATOR.namespace}` + + ` --kubeconfig "${Cypress.env('KUBECONFIG_PATH')}"`, + { failOnNonZeroExit: false }, ) .then((result) => { - cy.log(`${elapsed}ms - Force delete output: ${result.stdout}`); if (result.code !== 0) { - cy.log(`Force delete failed with exit code ${result.code}: ${result.stderr}`); + Cypress.log({ + name: 'cleanupCOONamespace', + message: `${CLUSTER_OBSERVABILITY_OPERATOR.namespace} is successfully deleted.`, + }); + return cy.wrap(true, { log: false }); } - }); - } - - cy.exec( - `oc get ns ${CLUSTER_OBSERVABILITY_OPERATOR.namespace} --kubeconfig "${Cypress.env( - 'KUBECONFIG_PATH', - )}" -o jsonpath='{.status.phase}'`, - { failOnNonZeroExit: false }, - ).then((result) => { - if (result.code !== 0) { - cy.log( - `${elapsed}ms - ${CLUSTER_OBSERVABILITY_OPERATOR.namespace} is ` + - `successfully deleted.`, - ); - return; - } - const status = result.stdout.trim(); - if (status === 'Terminating') { - cy.log( - `${elapsed}ms - ${CLUSTER_OBSERVABILITY_OPERATOR.namespace} is still ` + - `'Terminating'. Retrying in ${ - checkIntervalMs / 1000 - }s. Elapsed: ${Math.round(elapsed / 1000)}s`, - ); - cy.exec( - `./cypress/fixtures/coo/force_delete_ns.sh ` + - `${CLUSTER_OBSERVABILITY_OPERATOR.namespace} "${Cypress.env('KUBECONFIG_PATH')}"`, - { failOnNonZeroExit: false, timeout: installTimeoutMilliseconds }, - ).then((forceResult) => { - cy.log(`${elapsed}ms - Force delete output: ${forceResult.stdout}`); - if (forceResult.code !== 0) { - cy.log( - `Force delete failed with exit code ${forceResult.code}: ${forceResult.stderr}`, - ); - } - }); - cy.wait(checkIntervalMs).then(checkStatus); - } else { - cy.log( - `${elapsed}ms - ${CLUSTER_OBSERVABILITY_OPERATOR.namespace} changed to ` + - `unexpected state: ${status}. Stopping monitoring.`, - ); - } - }); - }; - - checkStatus(); - - cy.then(() => { - cooInstallUtils.waitForPodsDeleted(CLUSTER_OBSERVABILITY_OPERATOR.namespace, 300000); - }); + return cy + .exec( + `./cypress/fixtures/coo/force_delete_ns.sh ${ + CLUSTER_OBSERVABILITY_OPERATOR.namespace + } "${Cypress.env('KUBECONFIG_PATH')}"`, + { failOnNonZeroExit: false, timeout: installTimeoutMilliseconds }, + ) + .then((forceResult) => { + Cypress.log({ + name: 'cleanupCOONamespace', + message: `Force delete output: ${forceResult.stdout}`, + }); + return false; + }); + }), + { + timeout: 600000, + interval: 15000, + errorMsg: `Timed out deleting namespace ${CLUSTER_OBSERVABILITY_OPERATOR.namespace}`, + }, + ); } else { cy.log('Namespace does not exist, skipping deletion'); } }); }, - - waitForPodsDeleted(namespace: string, maxWaitMs: number = 120000): void { - const kubeconfigPath = Cypress.env('KUBECONFIG_PATH') as string; - const checkIntervalMs = 5000; - const startTime = Date.now(); - const podPatterns = 'monitoring|perses|perses-0|health-analyzer|troubleshooting-panel|korrel8r'; - - const checkPods = () => { - const elapsed = Date.now() - startTime; - - if (elapsed > maxWaitMs) { - throw new Error(`Timeout: Pods still exist after ${maxWaitMs / 1000}s`); - } - - cy.exec(`oc get pods -n ${namespace} --kubeconfig ${kubeconfigPath} -o name`, { - failOnNonZeroExit: false, - }).then((result) => { - if (result.code !== 0) { - if (result.stderr.includes('not found')) { - cy.log(`All target pods deleted after ${elapsed}ms (namespace gone)`); - } else { - cy.log(`${elapsed}ms - oc get pods failed: ${result.stderr}, retrying...`); - cy.wait(checkIntervalMs).then(checkPods); - } - return; - } - - const matchingPods = result.stdout - .split('\n') - .filter((line) => new RegExp(podPatterns).test(line)); - - if (matchingPods.length === 0) { - cy.log(`All target pods deleted after ${elapsed}ms`); - } else { - cy.log(`${elapsed}ms - ${matchingPods.length} pod(s) still exist, retrying...`); - cy.wait(checkIntervalMs).then(checkPods); - } - }); - }; - - checkPods(); - }, }; diff --git a/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts b/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts index a9873bbe0..23a742819 100644 --- a/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts +++ b/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts @@ -260,11 +260,14 @@ export function testCOORBACPersesTestsDevUser1( listPersesDashboardsPage.assertCreateButtonIsEnabled(); listPersesDashboardsPage.clickCreateButton(); persesCreateDashboardsPage.createDashboardShouldBeLoaded(); - persesCreateDashboardsPage.assertProjectNotExistsInDropdown('observ-test'); + // Non-viewable projects are absent from the dropdown. persesCreateDashboardsPage.assertProjectNotExistsInDropdown('perses-dev'); - persesCreateDashboardsPage.assertProjectNotExistsInDropdown('openshift-monitoring'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace3'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace4'); + // Viewable but non-creatable projects are listed but creation is denied. + persesCreateDashboardsPage.assertCreateAccessDenied('observ-test'); + persesCreateDashboardsPage.assertCreateAccessDenied('openshift-monitoring'); + // Editable project is selectable and creatable. persesCreateDashboardsPage.assertProjectDropdown('openshift-cluster-observability-operator'); persesCreateDashboardsPage.createDashboardDialogCancelButton(); @@ -428,11 +431,11 @@ export function testCOORBACPersesTestsDevUser1( cy.log(`6.2. Change namespace to observ-test`); cy.changeNamespace('observ-test'); - cy.log(`6.3. Assert Kebab icon is disabled`); + cy.log(`6.3. Assert Rename/Delete row actions are disabled`); listPersesDashboardsPage.filter.byName( persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0], ); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); cy.log(`6.4. Change namespace to openshift-cluster-observability-operator`); cy.changeNamespace('openshift-cluster-observability-operator'); @@ -458,8 +461,7 @@ export function testCOORBACPersesTestsDevUser1( persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0], ); listPersesDashboardsPage.countDashboards('1'); - listPersesDashboardsPage.clickKebabIcon(); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); listPersesDashboardsPage.clearAllFilters(); cy.log(`6.8. Filter by Project and Name`); @@ -562,11 +564,14 @@ export function testCOORBACPersesTestsDevUser1( listPersesDashboardsPage.clickDuplicateOption(); cy.log(`8.5. Assert project dropdown options`); - listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('observ-test'); + // Non-viewable projects are absent from the dropdown. listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('perses-dev'); listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('empty-namespace3'); listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('empty-namespace4'); - listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('openshift-monitoring'); + // Viewable but non-creatable projects are listed but duplication is denied. + listPersesDashboardsPage.assertDuplicateProjectDenied('observ-test'); + listPersesDashboardsPage.assertDuplicateProjectDenied('openshift-monitoring'); + // Editable project is selectable and creatable. listPersesDashboardsPage.assertDuplicateProjectDropdownExists( 'openshift-cluster-observability-operator', ); @@ -642,11 +647,14 @@ export function testCOORBACPersesTestsDevUser1( persesImportDashboardsPage.assertPersesDashboardDetected(); cy.log(`10.4. Verify project dropdown options`); - persesImportDashboardsPage.assertProjectNotExistsInDropdown('observ-test'); + // Non-viewable projects are absent from the dropdown. persesImportDashboardsPage.assertProjectNotExistsInDropdown('perses-dev'); - persesImportDashboardsPage.assertProjectNotExistsInDropdown('openshift-monitoring'); persesImportDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace3'); persesImportDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace4'); + // Viewable but non-creatable projects are listed but import is denied. + persesImportDashboardsPage.assertImportAccessDenied('observ-test'); + persesImportDashboardsPage.assertImportAccessDenied('openshift-monitoring'); + // Editable project is selectable and creatable. persesImportDashboardsPage.assertProjectDropdown('openshift-cluster-observability-operator'); persesImportDashboardsPage.clickCancelButton(); diff --git a/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts b/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts index 9057ff441..8372caaa5 100644 --- a/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts +++ b/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts @@ -1,5 +1,7 @@ import { persesDashboardsPage } from '../../views/perses-dashboards'; import { listPersesDashboardsPage } from '../../views/perses-dashboards-list-dashboards'; +import { persesCreateDashboardsPage } from '../../views/perses-dashboards-create-dashboard'; +import { persesImportDashboardsPage } from '../../views/perses-dashboards-import-dashboard'; import { persesDashboardsDashboardDropdownCOO, persesDashboardsDashboardDropdownPersesDev, @@ -114,67 +116,68 @@ export function testCOORBACPersesTestsDevUser2( persesDashboardsPage.assertEditButtonIsDisabled(); }); - it(`3.${perspectiveName} perspective - Create button validation - Disabled`, () => { + it(`3.${perspectiveName} perspective - Create button validation - Access denied`, () => { cy.log(`3.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.shouldBeLoaded(dashboardsPageName); - cy.log(`3.2. Verify Create button is disabled`); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); - - cy.log(`3.3 change namespace to perses-dev`); + cy.log(`3.2 change namespace to perses-dev`); cy.changeNamespace('perses-dev'); - cy.log(`3.4. Verify Create button is disabled`); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); - - cy.log(`3.5. Change namespace to openshift-monitoring`); - cy.changeNamespace('openshift-monitoring'); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + cy.log(`3.3. Verify Create button is enabled but creation is denied for perses-dev`); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); + listPersesDashboardsPage.clickCreateButton(); + persesCreateDashboardsPage.createDashboardShouldBeLoaded(); + persesCreateDashboardsPage.assertCreateAccessDenied('perses-dev'); + persesCreateDashboardsPage.assertCreateAccessDenied('openshift-monitoring'); + persesCreateDashboardsPage.createDashboardDialogCancelButton(); }); - it(`4.${perspectiveName} perspective - Kebab icon - Disabled`, () => { + it(`4.${perspectiveName} perspective - Kebab icon - Row actions denied`, () => { cy.log(`4.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.shouldBeLoaded(dashboardsPageName); cy.log(`4.2. Change namespace to perses-dev`); cy.changeNamespace('perses-dev'); - cy.log(`4.3. Assert Kebab icon is disabled`); + cy.log(`4.3. Assert Rename/Delete row actions are disabled`); listPersesDashboardsPage.filter.byName( persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0], ); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); + + cy.log(`4.4. Assert Duplicate is blocked by access-denied in the modal`); + listPersesDashboardsPage.assertDuplicateAccessDenied('perses-dev'); listPersesDashboardsPage.clearAllFilters(); - cy.log(`4.4. Change namespace to All Projects`); + cy.log(`4.5. Change namespace to All Projects`); cy.changeNamespace('All Projects'); - cy.log(`4.5. Assert Kebab icon is disabled`); + cy.log(`4.6. Assert Rename/Delete row actions are disabled`); listPersesDashboardsPage.filter.byProject('perses-dev'); listPersesDashboardsPage.filter.byName( persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0], ); listPersesDashboardsPage.countDashboards('1'); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); listPersesDashboardsPage.clearAllFilters(); }); - it(`5.${perspectiveName} perspective - Import button validation - Disabled`, () => { + it(`5.${perspectiveName} perspective - Import button validation - Access denied`, () => { cy.log(`5.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.shouldBeLoaded(dashboardsPageName); cy.log(`5.2. Change namespace to perses-dev`); cy.changeNamespace('perses-dev'); - cy.log(`5.3. Verify Import button is disabled`); - listPersesDashboardsPage.assertImportButtonIsDisabled(); - - cy.log(`5.5. Change namespace to openshift-monitoring`); - cy.changeNamespace('openshift-monitoring'); - listPersesDashboardsPage.assertImportButtonIsDisabled(); - - cy.log(`5.6. Change namespace to All Projects`); - cy.changeNamespace('All Projects'); - listPersesDashboardsPage.assertImportButtonIsDisabled(); + cy.log(`5.3. Verify Import button is enabled but import is denied for perses-dev`); + listPersesDashboardsPage.assertImportButtonIsEnabled(); + listPersesDashboardsPage.clickImportButton(); + persesImportDashboardsPage.importDashboardShouldBeLoaded(); + persesImportDashboardsPage.uploadFile( + './cypress/fixtures/coo/coo140_perses/import/testing-perses-dashboard.json', + ); + persesImportDashboardsPage.assertPersesDashboardDetected(); + persesImportDashboardsPage.assertImportAccessDenied('perses-dev'); + persesImportDashboardsPage.clickCancelButton(); }); } diff --git a/web/cypress/support/perses/99.coo_rbac_perses_user3.cy.ts b/web/cypress/support/perses/99.coo_rbac_perses_user3.cy.ts index 02dc43239..6e310893a 100644 --- a/web/cypress/support/perses/99.coo_rbac_perses_user3.cy.ts +++ b/web/cypress/support/perses/99.coo_rbac_perses_user3.cy.ts @@ -56,7 +56,7 @@ export function testCOORBACPersesTestsDevUser3( ); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('observ-test'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('perses-dev'); - persesCreateDashboardsPage.assertProjectNotExistsInDropdown('openshift-monitoring'); + persesCreateDashboardsPage.assertCreateAccessDenied('openshift-monitoring'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace4'); persesCreateDashboardsPage.assertProjectDropdown('empty-namespace3'); persesCreateDashboardsPage.createDashboardDialogCancelButton(); @@ -72,7 +72,7 @@ export function testCOORBACPersesTestsDevUser3( ); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('observ-test'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('perses-dev'); - persesCreateDashboardsPage.assertProjectNotExistsInDropdown('openshift-monitoring'); + persesCreateDashboardsPage.assertCreateAccessDenied('openshift-monitoring'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace4'); persesCreateDashboardsPage.assertProjectDropdown('empty-namespace3'); @@ -89,7 +89,7 @@ export function testCOORBACPersesTestsDevUser3( ); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('observ-test'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('perses-dev'); - persesCreateDashboardsPage.assertProjectNotExistsInDropdown('openshift-monitoring'); + persesCreateDashboardsPage.assertCreateAccessDenied('openshift-monitoring'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace4'); persesCreateDashboardsPage.assertProjectDropdown('empty-namespace3'); persesCreateDashboardsPage.createDashboardDialogCancelButton(); @@ -113,7 +113,7 @@ export function testCOORBACPersesTestsDevUser3( persesCreateDashboardsPage.createDashboardShouldBeLoaded(); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('observ-test'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('perses-dev'); - persesCreateDashboardsPage.assertProjectNotExistsInDropdown('openshift-monitoring'); + persesCreateDashboardsPage.assertCreateAccessDenied('openshift-monitoring'); persesCreateDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace4'); persesCreateDashboardsPage.assertProjectDropdown('empty-namespace3'); persesCreateDashboardsPage.createDashboardDialogCancelButton(); @@ -372,10 +372,10 @@ export function testCOORBACPersesTestsDevUser3( listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists( 'openshift-cluster-observability-operator', ); - listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('openshift-monitoring'); listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('observ-test'); listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('perses-dev'); listPersesDashboardsPage.assertDuplicateProjectDropdownNotExists('empty-namespace4'); + listPersesDashboardsPage.assertDuplicateProjectDenied('openshift-monitoring'); listPersesDashboardsPage.assertDuplicateProjectDropdownExists('empty-namespace3'); cy.log(`6.6. Enter new dashboard name`); @@ -452,8 +452,8 @@ export function testCOORBACPersesTestsDevUser3( ); persesImportDashboardsPage.assertProjectNotExistsInDropdown('observ-test'); persesImportDashboardsPage.assertProjectNotExistsInDropdown('perses-dev'); - persesImportDashboardsPage.assertProjectNotExistsInDropdown('openshift-monitoring'); persesImportDashboardsPage.assertProjectNotExistsInDropdown('empty-namespace4'); + persesImportDashboardsPage.assertImportAccessDenied('openshift-monitoring'); persesImportDashboardsPage.assertProjectDropdown('empty-namespace3'); persesImportDashboardsPage.clickCancelButton(); diff --git a/web/cypress/support/perses/99.coo_rbac_perses_user4.cy.ts b/web/cypress/support/perses/99.coo_rbac_perses_user4.cy.ts index d8d6b7a28..82b2a8f26 100644 --- a/web/cypress/support/perses/99.coo_rbac_perses_user4.cy.ts +++ b/web/cypress/support/perses/99.coo_rbac_perses_user4.cy.ts @@ -1,5 +1,7 @@ import { listPersesDashboardsPage } from '../../views/perses-dashboards-list-dashboards'; import type { CustomerPerspective } from '@/shared/constants/perspective'; +import { persesCreateDashboardsPage } from '../../views/perses-dashboards-create-dashboard'; +import { persesImportDashboardsPage } from '../../views/perses-dashboards-import-dashboard'; /** * User4 has access to: @@ -26,38 +28,51 @@ export function testCOORBACPersesTestsDevUser4(perspectiveName: CustomerPerspect cy.log(`1.2. All Projects validation - Dashboard search - empty state`); cy.changeNamespace('All Projects'); listPersesDashboardsPage.noDashboardsFoundState(); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); cy.log(`1.3. empty-namespace4 validation - Dashboard search - empty state`); cy.changeNamespace('empty-namespace4'); listPersesDashboardsPage.noDashboardsFoundState(); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); cy.log(`1.4. openshift-monitoring validation - Dashboard search - empty state`); cy.changeNamespace('openshift-monitoring'); listPersesDashboardsPage.noDashboardsFoundState(); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); }, ); - it(`2.${perspectiveName} perspective - Import button validation - Disabled`, () => { + it(`2.${perspectiveName} perspective - Create button validation - Access denied`, () => { cy.log(`2.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.noDashboardsFoundState(); cy.log(`2.2 change namespace to empty-namespace4`); cy.changeNamespace('empty-namespace4'); - cy.log(`2.3. Verify Import button is disabled`); - listPersesDashboardsPage.assertImportButtonIsDisabled(); + cy.log(`2.3. Verify Create button is enabled but creation is denied for empty-namespace4`); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); + listPersesDashboardsPage.clickCreateButton(); + persesCreateDashboardsPage.createDashboardShouldBeLoaded(); + persesCreateDashboardsPage.assertCreateAccessDenied('empty-namespace4'); + persesCreateDashboardsPage.createDashboardDialogCancelButton(); + }); + + it(`3.${perspectiveName} perspective - Import button validation - Access denied`, () => { + cy.log(`3.1. use sidebar nav to go to Observe > Dashboards (Perses)`); + listPersesDashboardsPage.noDashboardsFoundState(); - cy.log(`2.4. Change namespace to openshift-monitoring`); - cy.changeNamespace('openshift-monitoring'); - cy.log(`2.5. Verify Import button is disabled`); - listPersesDashboardsPage.assertImportButtonIsDisabled(); + cy.log(`3.2 change namespace to empty-namespace4`); + cy.changeNamespace('empty-namespace4'); - cy.log(`2.6. Change namespace to All Projects`); - cy.changeNamespace('All Projects'); - cy.log(`2.7. Verify Import button is disabled`); - listPersesDashboardsPage.assertImportButtonIsDisabled(); + cy.log(`3.3. Verify Import button is enabled but import is denied for empty-namespace4`); + listPersesDashboardsPage.assertImportButtonIsEnabled(); + listPersesDashboardsPage.clickImportButton(); + persesImportDashboardsPage.importDashboardShouldBeLoaded(); + persesImportDashboardsPage.uploadFile( + './cypress/fixtures/coo/coo140_perses/import/testing-perses-dashboard.json', + ); + persesImportDashboardsPage.assertPersesDashboardDetected(); + persesImportDashboardsPage.assertImportAccessDenied('empty-namespace4'); + persesImportDashboardsPage.clickCancelButton(); }); } diff --git a/web/cypress/support/perses/99.coo_rbac_perses_user6.cy.ts b/web/cypress/support/perses/99.coo_rbac_perses_user6.cy.ts index 30df7580c..d32f7e2eb 100644 --- a/web/cypress/support/perses/99.coo_rbac_perses_user6.cy.ts +++ b/web/cypress/support/perses/99.coo_rbac_perses_user6.cy.ts @@ -1,5 +1,7 @@ import { listPersesDashboardsPage } from '../../views/perses-dashboards-list-dashboards'; import type { CustomerPerspective } from '@/shared/constants/perspective'; +import { persesCreateDashboardsPage } from '../../views/perses-dashboards-create-dashboard'; +import { persesImportDashboardsPage } from '../../views/perses-dashboards-import-dashboard'; /** * User6 has access to: @@ -20,16 +22,28 @@ export function testCOORBACPersesTestsDevUser6(perspectiveName: CustomerPerspect cy.assertNamespace('empty-namespace3', false); cy.assertNamespace('empty-namespace4', false); - cy.log(`1.2. Create button validation`); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + cy.log(`1.2. Create button is enabled but no project is selectable, so Create is disabled`); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); + listPersesDashboardsPage.clickCreateButton(); + persesCreateDashboardsPage.createDashboardShouldBeLoaded(); + cy.byPFRole('dialog').find('button').contains('Create').should('be.disabled'); + persesCreateDashboardsPage.createDashboardDialogCancelButton(); }, ); - it(`2.${perspectiveName} perspective - Import button validation - Disabled`, () => { + it(`2.${perspectiveName} perspective - Import button validation - Access denied`, () => { cy.log(`2.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.noDashboardsFoundState(); - cy.log(`2.2. Verify Import button is disabled`); - listPersesDashboardsPage.assertImportButtonIsDisabled(); + cy.log(`2.2. Verify Import button is enabled but import is disabled without a project`); + listPersesDashboardsPage.assertImportButtonIsEnabled(); + listPersesDashboardsPage.clickImportButton(); + persesImportDashboardsPage.importDashboardShouldBeLoaded(); + persesImportDashboardsPage.uploadFile( + './cypress/fixtures/coo/coo140_perses/import/testing-perses-dashboard.json', + ); + persesImportDashboardsPage.assertPersesDashboardDetected(); + cy.byPFRole('dialog').find('button').contains('Import').should('be.disabled'); + persesImportDashboardsPage.clickCancelButton(); }); } diff --git a/web/cypress/views/perses-dashboards-create-dashboard.ts b/web/cypress/views/perses-dashboards-create-dashboard.ts index b3c549672..123b312f2 100644 --- a/web/cypress/views/perses-dashboards-create-dashboard.ts +++ b/web/cypress/views/perses-dashboards-create-dashboard.ts @@ -1,4 +1,9 @@ -import { Classes, IDs, persesAriaLabels } from '@/shared/constants/data-test'; +import { + Classes, + IDs, + persesAriaLabels, + persesDashboardDataTestIDs, +} from '@/shared/constants/data-test'; import { persesCreateDashboard, persesDashboardsModalTitles } from '../fixtures/perses/constants'; export const persesCreateDashboardsPage = { @@ -44,6 +49,23 @@ export const persesCreateDashboardsPage = { cy.byPFRole('dialog').find(Classes.PersesCreateDashboardProjectDropdown).click({ force: true }); }, + /** + * Selects a project the user can view but cannot create dashboards in and asserts the create + * access-denied helper text is shown and the Create button is disabled. + */ + assertCreateAccessDenied: (project: string) => { + cy.log('persesCreateDashboardsPage.assertCreateAccessDenied'); + cy.get('#' + IDs.persesDashboardCreateDashboardName) + .should('be.visible') + .clear() + .type('access-denied-check'); + cy.byPFRole('dialog').find(Classes.PersesCreateDashboardProjectDropdown).click({ force: true }); + cy.byAriaLabel(persesAriaLabels.dialogProjectInput).clear().type(project); + cy.byPFRole('option').contains(project).should('be.visible').click({ force: true }); + cy.byTestID(persesDashboardDataTestIDs.createAccessDeniedHelperText).should('be.visible'); + cy.byPFRole('dialog').find('button').contains('Create').should('be.disabled'); + }, + enterDashboardName: (name: string) => { cy.log('persesCreateDashboardsPage.enterDashboardName'); cy.get('#' + IDs.persesDashboardCreateDashboardName) @@ -72,9 +94,9 @@ export const persesCreateDashboardsPage = { assertDuplicatedNameValidation: () => { cy.log('persesCreateDashboardsPage.assertDuplicatedNameValidation'); - cy.byPFRole('list') - .find('h4') - .should('contain.text', persesCreateDashboard.DIALOG_CREATE_NAME_BKD_VALIDATION) + cy.byPFRole('dialog') + .find('.pf-m-error') + .should('contain.text', persesCreateDashboard.DIALOG_DUPLICATED_NAME_PF_VALIDATION_SUFFIX) .should('be.visible'); }, diff --git a/web/cypress/views/perses-dashboards-import-dashboard.ts b/web/cypress/views/perses-dashboards-import-dashboard.ts index 9c6a89eb6..8958f707b 100644 --- a/web/cypress/views/perses-dashboards-import-dashboard.ts +++ b/web/cypress/views/perses-dashboards-import-dashboard.ts @@ -1,4 +1,9 @@ -import { Classes, IDs, persesAriaLabels } from '@/shared/constants/data-test'; +import { + Classes, + IDs, + persesAriaLabels, + persesDashboardDataTestIDs, +} from '@/shared/constants/data-test'; import { persesDashboardsImportDashboard, persesDashboardsModalTitles, @@ -140,6 +145,17 @@ export const persesImportDashboardsPage = { .click({ force: true }); }, + assertImportAccessDenied: (project: string) => { + cy.log('persesImportDashboardsPage.assertImportAccessDenied'); + cy.byAriaLabel(persesAriaLabels.importDashboardProjectInputButton) + .should('be.visible') + .click({ force: true }); + cy.byAriaLabel(persesAriaLabels.dialogProjectInput).clear().type(project); + cy.byPFRole('option').contains(project).should('be.visible').click({ force: true }); + cy.byTestID(persesDashboardDataTestIDs.createAccessDeniedHelperText).should('be.visible'); + cy.byPFRole('dialog').find('button').contains('Import').should('be.disabled'); + }, + assertFailedToMigrateGrafanaDashboard: () => { cy.log('persesImportDashboardsPage.assertFailedToMigrateGrafanaDashboard'); cy.get('h4') diff --git a/web/cypress/views/perses-dashboards-list-dashboards.ts b/web/cypress/views/perses-dashboards-list-dashboards.ts index 02c40af2b..f485002b9 100644 --- a/web/cypress/views/perses-dashboards-list-dashboards.ts +++ b/web/cypress/views/perses-dashboards-list-dashboards.ts @@ -7,6 +7,7 @@ import { listPersesDashboardsDataTestIDs, listPersesDashboardsOUIAIDs, persesAriaLabels, + persesDashboardDataTestIDs, } from '@/shared/constants/data-test'; import { listPersesDashboardsEmptyState, @@ -181,14 +182,6 @@ export const listPersesDashboardsPage = { .should('not.have.attr', 'disabled'); }, - assertCreateButtonIsDisabled: () => { - cy.log('persesDashboardsPage.assertCreateButtonIsDisabled'); - cy.byTestID(DataTestIDs.PersesCreateDashboardButton) - .scrollIntoView() - .should('be.visible') - .should('have.attr', 'disabled'); - }, - clickKebabIcon: (index?: number) => { const idx = index !== undefined ? index : 0; cy.log('persesDashboardsPage.clickKebabIcon'); @@ -207,12 +200,35 @@ export const listPersesDashboardsPage = { cy.byPFRole('menuitem').contains('Delete dashboard').should('be.visible'); }, - assertKebabIconDisabled: () => { - cy.log('persesDashboardsPage.assertKebabIconDisabled'); - cy.byAriaLabel(persesAriaLabels.persesDashboardKebabIcon) - .scrollIntoView() - .should('be.visible') - .should('have.attr', 'disabled'); + assertKebabRowActionsDisabled: (index?: number) => { + cy.log('persesDashboardsPage.assertKebabRowActionsDisabled'); + listPersesDashboardsPage.clickKebabIcon(index); + cy.byPFRole('menuitem') + .contains('Rename dashboard') + .should('have.attr', 'aria-disabled', 'true'); + cy.byPFRole('menuitem') + .contains('Delete dashboard') + .should('have.attr', 'aria-disabled', 'true'); + cy.byPFRole('menuitem') + .contains('Duplicate dashboard') + .should('not.have.attr', 'aria-disabled', 'true'); + listPersesDashboardsPage.clickKebabIcon(index); + }, + + assertDuplicateProjectDenied: (project: string) => { + listPersesDashboardsPage.duplicateDashboardEnterName('access-denied-check'); + cy.log('persesDashboardsPage.assertDuplicateProjectDenied'); + listPersesDashboardsPage.duplicateDashboardSelectProjectDropdown(project); + cy.byTestID(persesDashboardDataTestIDs.createAccessDeniedHelperText).should('be.visible'); + cy.byPFRole('dialog').find('button').contains('Duplicate').should('be.disabled'); + }, + + assertDuplicateAccessDenied: (project: string) => { + cy.log('persesDashboardsPage.assertDuplicateAccessDenied'); + listPersesDashboardsPage.clickKebabIcon(); + listPersesDashboardsPage.clickDuplicateOption(); + listPersesDashboardsPage.assertDuplicateProjectDenied(project); + listPersesDashboardsPage.duplicateDashboardCancelButton(); }, clickRenameDashboardOption: () => { @@ -411,12 +427,4 @@ export const listPersesDashboardsPage = { .should('be.visible') .should('not.have.attr', 'disabled'); }, - - assertImportButtonIsDisabled: () => { - cy.log('listPersesDashboardsPage.assertImportButtonIsDisabled'); - cy.byAriaLabel(persesAriaLabels.dashboardActionsMenu) - .scrollIntoView() - .should('be.visible') - .should('have.attr', 'disabled'); - }, }; diff --git a/web/eslint-rules/file-naming.spec.ts b/web/eslint-rules/file-naming.spec.ts index 95ad47355..ada94bf67 100644 --- a/web/eslint-rules/file-naming.spec.ts +++ b/web/eslint-rules/file-naming.spec.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { RuleTester } from 'eslint'; import { fileNaming } from './file-naming'; @@ -5,6 +8,18 @@ const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 2021, sourceType: 'module' }, }); +// Create a fixture directory so the mirrored-sibling check can hit real files. +const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-naming-')); +const fixture = (name: string) => { + const filePath = path.join(fixtureDir, name); + fs.writeFileSync(filePath, ''); + return filePath; +}; + +// Siblings that spec files legitimately mirror. +fixture('DashboardDialogHelpers.tsx'); +fixture('myUtils.ts'); + ruleTester.run('file-naming', fileNaming, { valid: [ { code: '', filename: '/src/features/alerts/MyComponent.tsx' }, @@ -14,6 +29,11 @@ ruleTester.run('file-naming', fileNaming, { { code: '', filename: '/src/shared/hooks/useMyHook.spec.tsx' }, { code: '', filename: '/src/features/alerts/MyComponent.spec.tsx' }, { code: '', filename: '/src/shared/utils/my-utils.spec.ts' }, + // Spec files may mirror a PascalCase sibling (DashboardDialogHelpers.tsx exists) + { code: '', filename: fixture('DashboardDialogHelpers.spec.ts') }, + { code: '', filename: fixture('DashboardDialogHelpers.test.ts') }, + // Spec files may mirror a camelCase sibling (myUtils.ts exists) + { code: '', filename: fixture('myUtils.spec.ts') }, ], invalid: [ { @@ -28,7 +48,12 @@ ruleTester.run('file-naming', fileNaming, { }, { code: '', - filename: '/src/shared/utils/MyUtils.spec.ts', + filename: '/src/shared/utils/myUtils.ts', + errors: [{ messageId: 'invalidTsName' }], + }, + { + code: '', + filename: path.join(fixtureDir, 'NoSibling.spec.ts'), errors: [{ messageId: 'invalidTsName' }], }, ], diff --git a/web/eslint-rules/file-naming.ts b/web/eslint-rules/file-naming.ts index db85c1999..fda3b8252 100644 --- a/web/eslint-rules/file-naming.ts +++ b/web/eslint-rules/file-naming.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import type { Rule } from 'eslint'; @@ -5,6 +6,9 @@ const PASCAL_CASE = /^[A-Z][a-zA-Z0-9]*$/; const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; const CAMEL_USE = /^use[A-Z][a-zA-Z0-9]*$/; +const hasSibling = (dir: string, baseStem: string): boolean => + ['.ts', '.tsx'].some((siblingExt) => fs.existsSync(path.join(dir, `${baseStem}${siblingExt}`))); + export const fileNaming: Rule.RuleModule = { meta: { type: 'problem', @@ -26,6 +30,7 @@ export const fileNaming: Rule.RuleModule = { const stem = path.basename(filename, ext); // Strip spec/test suffix before checking const baseStem = stem.replace(/\.(spec|test)$/, ''); + const isSpec = /\.(spec|test)$/.test(stem); if (ext === '.tsx') { if (!PASCAL_CASE.test(baseStem) && !CAMEL_USE.test(baseStem)) { @@ -36,7 +41,9 @@ export const fileNaming: Rule.RuleModule = { }); } } else if (ext === '.ts') { - const valid = KEBAB_CASE.test(baseStem) || CAMEL_USE.test(baseStem); + // Unit tests may mirror the casing of any sibling + const specMirror = isSpec && hasSibling(path.dirname(filename), baseStem); + const valid = KEBAB_CASE.test(baseStem) || CAMEL_USE.test(baseStem) || specMirror; if (!valid) { context.report({ loc: { line: 1, column: 0 }, diff --git a/web/locales/en/plugin__monitoring-plugin.json b/web/locales/en/plugin__monitoring-plugin.json index 6610e26ce..1081bd7c8 100644 --- a/web/locales/en/plugin__monitoring-plugin.json +++ b/web/locales/en/plugin__monitoring-plugin.json @@ -46,7 +46,6 @@ "Alerts": "Alerts", "Alerts Timeline": "Alerts Timeline", "Alerts with labels that match these selectors will be silenced instead of firing. Label values can be matched exactly or with a <2>": "Alerts with labels that match these selectors will be silenced instead of firing. Label values can be matched exactly or with a <2>", - "All Projects": "All Projects", "Always review AI-generated content prior to use.": "Always review AI-generated content prior to use.", "An error occurred": "An error occurred", "An error occurred while loading the custom data source.": "An error occurred while loading the custom data source.", @@ -58,10 +57,8 @@ "Bytes Decimal Per Second (kB/s, MB/s)": "Bytes Decimal Per Second (kB/s, MB/s)", "Cancel": "Cancel", "Check to show gaps for missing data": "Check to show gaps for missing data", - "Checking permissions...": "Checking permissions...", "Clear": "Clear", "Clear all filters": "Clear all filters", - "Clear filters": "Clear filters", "Clear input value": "Clear input value", "Clear query": "Clear query", "Cluster": "Cluster", @@ -147,7 +144,6 @@ "Failed to create project \"{{project}}\". Please try again.": "Failed to create project \"{{project}}\". Please try again.", "Failed to import dashboard. Please try again.": "Failed to import dashboard. Please try again.", "Failed to load metrics list.": "Failed to load metrics list.", - "Failed to load project permissions. Please refresh the page and try again.": "Failed to load project permissions. Please refresh the page and try again.", "File size exceeds maximum allowed size of 5MB": "File size exceeds maximum allowed size of 5MB", "Filter {{label}}...": "Filter {{label}}...", "Filter by Cluster": "Filter by Cluster", @@ -246,10 +242,8 @@ "No namespace found for \"{{filter}}\"": "No namespace found for \"{{filter}}\"", "No Perses dashboards are currently available in this project.": "No Perses dashboards are currently available in this project.", "No Perses Project Available": "No Perses Project Available", - "No projects found": "No projects found", "No query entered": "No query entered", "No results found": "No results found", - "No results match the filter criteria.": "No results match the filter criteria.", "No results match the filter criteria. Clear filters to show results.": "No results match the filter criteria. Clear filters to show results.", "No silences found": "No silences found", "No Units": "No Units", @@ -275,7 +269,6 @@ "Project": "Project", "Project \"{{project}}\" created successfully": "Project \"{{project}}\" created successfully", "Project is required for fetching project dashboards": "Project is required for fetching project dashboards", - "Projects": "Projects", "Queries": "Queries", "query browser chart": "query browser chart", "Query must be enabled": "Query must be enabled", @@ -309,7 +302,6 @@ "Select all that apply:": "Select all that apply:", "Select namespace": "Select namespace", "Select project": "Select project", - "Select project...": "Select project...", "Select query": "Select query", "Select the negative matcher option to update the label value to a not equals matcher.": "Select the negative matcher option to update the label value to a not equals matcher.", "Severity": "Severity", @@ -362,7 +354,6 @@ "This dropdown only formats results.": "This dropdown only formats results.", "Time range": "Time range", "To": "To", - "To create dashboards, contact your cluster administrator for permission.": "To create dashboards, contact your cluster administrator for permission.", "To explore data, create a dashboard for this project": "To explore data, create a dashboard for this project", "To explore data, create a Perses Project": "To explore data, create a Perses Project", "To get started add something to your dashboard": "To get started add something to your dashboard", @@ -388,7 +379,8 @@ "Warning": "Warning", "When changes are saved, the currently existing silence will be expired and a new silence with the new configuration will take its place.": "When changes are saved, the currently existing silence will be expired and a new silence with the new configuration will take its place.", "You can also create custom severity definitions for user workload alerts.": "You can also create custom severity definitions for user workload alerts.", - "You don't have access to this section due to cluster policy": "You don't have access to this section due to cluster policy", - "You don't have permission to edit this dashboard": "You don't have permission to edit this dashboard", - "You don't have permissions for dashboard actions": "You don't have permissions for dashboard actions" + "You do not have permission to create dashboards in this project.": "You do not have permission to create dashboards in this project.", + "You do not have permission to delete dashboards in this project.": "You do not have permission to delete dashboards in this project.", + "You do not have permission to edit dashboards in this project.": "You do not have permission to edit dashboards in this project.", + "You don't have access to this section due to cluster policy": "You don't have access to this section due to cluster policy" } diff --git a/web/src/features/perses-dashboards/components/DashboardActionModals.tsx b/web/src/features/perses-dashboards/components/DashboardActionModals.tsx index 8a17590e5..e09a003b1 100644 --- a/web/src/features/perses-dashboards/components/DashboardActionModals.tsx +++ b/web/src/features/perses-dashboards/components/DashboardActionModals.tsx @@ -12,7 +12,6 @@ import { ModalFooter, ModalHeader, ModalVariant, - Spinner, Stack, StackItem, TextInput, @@ -20,17 +19,23 @@ import { } from '@patternfly/react-core'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates'; -import { t_global_font_weight_200, t_global_spacer_200 } from '@patternfly/react-tokens'; import { DashboardResource } from '@perses-dev/client'; import { getResourceExtendedDisplayName } from '@perses-dev/components'; -import { CSSProperties, useEffect, useMemo } from 'react'; -import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form'; +import { useEffect, useMemo } from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import type { SubmitHandler } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; +import { + DashboardDeniedHelperText, + formGroupStyle, + LabelSpacer, + useDashboardProjects, +} from '@/features/perses-dashboards/components/DashboardDialogHelpers'; import { useToast } from '@/features/perses-dashboards/components/ToastProvider'; -import { useEditableProjects } from '@/features/perses-dashboards/hooks/useEditableProjects'; -import { usePerses } from '@/features/perses-dashboards/hooks/usePerses'; +import { useOcpProjects } from '@/features/perses-dashboards/hooks/useOcpProjects'; +import { usePersesDashboardAccess } from '@/features/perses-dashboards/hooks/usePersesDashboardAccess'; import { createDashboardDialogValidationSchema, CreateDashboardValidationType, @@ -47,14 +52,6 @@ import { import { generateMetadataName } from '@/features/perses-dashboards/utils/dashboard-utils'; import { getDashboardUrl, usePerspective } from '@/shared/hooks/usePerspective'; -export const formGroupStyle = { - fontWeight: t_global_font_weight_200.value, -} as CSSProperties; - -export const LabelSpacer = () => { - return
; -}; - interface ActionModalProps { dashboard: DashboardResource; isOpen: boolean; @@ -73,6 +70,12 @@ export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro }); const updateDashboardMutation = useUpdateDashboardMutation(); + const [canUpdate, updateChecking] = usePersesDashboardAccess( + 'update', + dashboard?.metadata?.project ?? null, + isOpen && !!dashboard?.metadata?.project, + ); + const updateDenied = !updateChecking && !canUpdate; if (!dashboard) { return null; @@ -154,6 +157,7 @@ export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro )} /> + - - - - - ); -}; - -/* ****************************************** */ - -export const Filter: FC<{ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - filterRef: Ref; - onFilterChange: (filterText: string) => void; - filterText: string; -}> = ({ filterText, filterRef, onFilterChange }) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - return ( - - - onFilterChange(value)} - ref={filterRef} - /> - - - ); -}; - -/* ****************************************** */ - -export const ProjectGroup: FC<{ - options: { key: string; title: string }[]; - selectedKey: string; -}> = ({ options, selectedKey }) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - - return options.length === 0 ? null : ( - <> - - - - {options.map((option) => { - return ( - - {option.title} - - ); - })} - - - - ); -}; - -/* ****************************************** */ - -const ProjectMenu: FC<{ - setOpen: (isOpen: boolean) => void; - onSelect: (event: ReactMouseEvent, itemId: string) => void; - selected?: string; - menuRef: MutableRefObject; -}> = ({ setOpen, onSelect, selected, menuRef }) => { - const filterRef = useRef(null); - const { t } = useTranslation(process.env.I18N_NAMESPACE); - - const [filterText, setFilterText] = useState(''); - - const { allProjects } = useEditableProjects(); - - const optionItems = useMemo(() => { - const items = - allProjects?.map((projectName) => { - return { title: projectName, key: projectName }; - }) || []; - - items.sort((a, b) => alphanumericCompare(a.title, b.title)); - items.unshift({ title: t('All Projects'), key: ALL_NAMESPACES_KEY }); - if (selected && !items.some((option) => option.key === selected)) { - items.unshift({ title: selected, key: selected }); // Add current project if it isn't included - } - - return items; - }, [allProjects, selected, t]); - - const isOptionShown = useCallback( - (option) => { - return fuzzysearch(filterText.toLowerCase(), option.title.toLowerCase()); - }, - [filterText], - ); - - const { filteredOptions } = useMemo( - () => - optionItems.reduce( - (filtered, option) => { - if (isOptionShown(option)) { - filtered.filteredOptions.push(option); - } - return filtered; - }, - { filteredOptions: [] }, - ), - [isOptionShown, optionItems], - ); - - return ( - { - setOpen(false); - onSelect(event, itemId); - }} - activeItemId={selected} - data-test="project-dropdown-menu" - isScrollable - > - - - {filteredOptions.length === 0 ? ( - { - event.preventDefault(); - event.stopPropagation(); - setFilterText(''); - filterRef.current?.focus(); - }} - /> - ) : null} - - - - ); -}; - -/* ****************************************** */ - -const ProjectDropdown: FC = ({ disabled, onSelect, selected, shortCut }) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - const menuRef = useRef(null); - const [isOpen, setOpen] = useState(false); - const { allProjects, permissionsLoading, permissionsError } = useEditableProjects(); - - const menuProps = { - setOpen, - onSelect, - selected, - menuRef, - }; - - let title = t('All Projects'); - // While loading permissions, or if there is a permission error fallback to the "selected" value - // 'All Projects' is the user friendly ALL_NAMESPACES_KEY - if ( - selected && - (allProjects?.includes(selected) || permissionsLoading || !!permissionsError) && - selected !== ALL_NAMESPACES_KEY - ) { - title = selected; - } - - const toggle = ( - } - menuRef={menuRef} - isOpen={isOpen} - title={`${t('Project')}: ${title}`} - onToggle={(menuState) => { - setOpen(menuState); - }} - shortCut={shortCut} - /> - ); - - return ( -
- {permissionsLoading ? ( - - {toggle} - - ) : permissionsError ? ( - - {toggle} - - ) : ( - toggle - )} -
- ); -}; - -type ProjectDropdownProps = { - disabled?: boolean; - onSelect?: (event: ReactMouseEvent | ChangeEvent, value: string) => void; - shortCut?: string; - selected?: string; -}; - -export default ProjectDropdown; diff --git a/web/src/features/perses-dashboards/components/project/ProjectMenuToggle.tsx b/web/src/features/perses-dashboards/components/project/ProjectMenuToggle.tsx deleted file mode 100644 index 6c073bf84..000000000 --- a/web/src/features/perses-dashboards/components/project/ProjectMenuToggle.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { MenuToggle, Popper } from '@patternfly/react-core'; -import classNames from 'classnames'; -import type { ReactElement, RefObject } from 'react'; -import { useEffect, useRef } from 'react'; - -const ProjectMenuToggle = (props: { - disabled: boolean; - menu: ReactElement; - menuRef: RefObject; - isOpen: boolean; - shortCut?: string; - title: string; - onToggle: (state: boolean) => void; - className?: string; -}) => { - const { menu, isOpen, shortCut, title, onToggle, disabled, menuRef, className } = props; - - const toggleRef = useRef(null); - const containerRef = useRef(null); - - const handleMenuKeys = (event) => { - if ( - shortCut && - event.key === shortCut && - event.target.nodeName !== 'INPUT' && - event.target.nodeName !== 'TEXTAREA' && - event.target.role !== 'textbox' && - event.target.role !== 'code' - ) { - onToggle(true); - event.stopPropagation(); - event.preventDefault(); - } - - if (menuRef.current) { - if (event.key === 'Escape') { - onToggle(false); - toggleRef.current.focus(); - } - if (!menuRef.current?.contains(event.target) && event.key === 'Tab') { - onToggle(false); - } - } - }; - - const handleMenuClick = (event) => { - if ( - menuRef.current && - !menuRef.current?.contains(event.target) && - // Checking to see if user clicked on a favorite icon. This is needed because - // if unfavoriting a item, PF removes the item from the DOM before - // the click event is registered - !event.target.closest?.('.pf-m-favorite') && - !toggleRef.current.contains(event.target) - ) { - onToggle(false); - } - }; - - useEffect(() => { - window.addEventListener('keyup', handleMenuKeys); - window.addEventListener('click', handleMenuClick); - return () => { - window.removeEventListener('keyup', handleMenuKeys); - window.removeEventListener('click', handleMenuClick); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // This needs to be run only on component mount/unmount - - const toggle = ( - onToggle(!isOpen)} - isExpanded={isOpen} - disabled={disabled} - className={classNames('co-namespace-dropdown__menu-toggle', className)} - > - {title} - - ); - - return ( -
- -
- ); -}; - -export default ProjectMenuToggle; diff --git a/web/src/features/perses-dashboards/components/project/utils.ts b/web/src/features/perses-dashboards/components/project/utils.ts deleted file mode 100644 index 4a0352ea7..000000000 --- a/web/src/features/perses-dashboards/components/project/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const alphanumericCompare = (a: string, b: string): number => { - const safeA = a || ''; - const safeB = b || ''; - - return safeA.localeCompare(safeB, undefined, { - numeric: true, - sensitivity: 'base', - }); -}; - -// Common shortcuts than span pages. -export const KEYBOARD_SHORTCUTS = Object.freeze({ - focusNamespaceDropdown: 'n', -}); diff --git a/web/src/features/perses-dashboards/hooks/useDashboardsData.ts b/web/src/features/perses-dashboards/hooks/useDashboardsData.ts index ca7532b9f..a71c71b4a 100644 --- a/web/src/features/perses-dashboards/hooks/useDashboardsData.ts +++ b/web/src/features/perses-dashboards/hooks/useDashboardsData.ts @@ -5,6 +5,7 @@ import { useNavigate, useSearchParams } from 'react-router'; import { StringParam, useQueryParam } from 'use-query-params'; import { useActiveProject } from '@/features/perses-dashboards/components/project/useActiveProject'; +import { useOcpProjects } from '@/features/perses-dashboards/hooks//useOcpProjects'; import { usePerses } from '@/features/perses-dashboards/hooks/usePerses'; import type { DashboardMetadata } from '@/features/perses-dashboards/types/types'; import { QueryParams } from '@/shared/constants/query-params'; @@ -12,12 +13,13 @@ import { useBoolean } from '@/shared/hooks/useBoolean'; import { getDashboardUrl, usePerspective } from '@/shared/hooks/usePerspective'; import { ALL_NAMESPACES_KEY } from '@/shared/utils/utils'; -// This hook syncs with mutliple external API's, redux, and URL state. Its a lot, but needs to all +// This hook syncs with multiple external API's, redux, and URL state. Its a lot, but needs to all // be in a single location export const useDashboardsData = () => { const navigate = useNavigate(); const { perspective } = usePerspective(); const { activeProject, setActiveProject } = useActiveProject(); + const { ocpProjectsLoaded } = useOcpProjects(); const [queryParams] = useSearchParams(); // track initial page load to prevent a full page loading state when swapping dashboards @@ -25,9 +27,7 @@ export const useDashboardsData = () => { const [initialPageLoad, , , setInitialPageLoadFalse] = useBoolean(true); // Retrieve perses dashboard information - const { persesProjects, persesProjectsLoading, persesDashboards, persesDashboardsLoading } = - usePerses(); - const persesAvailable = !persesProjectsLoading && persesProjects; + const { persesDashboards, persesDashboardsLoading, persesProjectsLoading } = usePerses(); const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam); // Determine when to stop having the full page loader be used @@ -35,12 +35,18 @@ export const useDashboardsData = () => { if (!initialPageLoad) { return false; } - if (!(persesProjectsLoading || persesDashboardsLoading)) { + if (ocpProjectsLoaded && !persesDashboardsLoading && !persesProjectsLoading) { setInitialPageLoadFalse(); return false; } return true; - }, [persesProjectsLoading, persesDashboardsLoading, initialPageLoad, setInitialPageLoadFalse]); + }, [ + ocpProjectsLoaded, + persesDashboardsLoading, + initialPageLoad, + setInitialPageLoadFalse, + persesProjectsLoading, + ]); const prevDashboardsRef = useRef([]); const prevMetadataRef = useRef([]); @@ -133,8 +139,6 @@ export const useDashboardsData = () => { ); return { - persesAvailable, - persesProjectsLoading, persesDashboards, dashboardName, changeBoard, diff --git a/web/src/features/perses-dashboards/hooks/useEditableProjects.ts b/web/src/features/perses-dashboards/hooks/useEditableProjects.ts deleted file mode 100644 index c610c7ade..000000000 --- a/web/src/features/perses-dashboards/hooks/useEditableProjects.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; - -import { useOcpProjects } from '@/features/perses-dashboards/hooks/useOcpProjects'; -import { - PersesUserPermissions, - useFetchPersesPermissions, -} from '@/features/perses-dashboards/utils/perses-client'; - -interface Projects { - editableProjects: string[] | undefined; - allProjects: string[] | undefined; -} - -const useUsername = (): string => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getUser = (state: any) => state.sdkCore?.user; - const user = useSelector(getUser); - return user?.metadata?.name || user?.username; -}; - -const combinePersesAndOcpProjects = ( - persesUserPermissions: PersesUserPermissions, - ocpProjects: K8sResourceKind[], -): string[] => { - const persesProjectNames = Object.keys(persesUserPermissions).filter((name) => name !== '*'); - const allAvailableProjects = new Set([...persesProjectNames]); - ocpProjects.forEach((project) => { - if (project.metadata?.name) { - allAvailableProjects.add(project.metadata.name); - } - }); - return Array.from(allAvailableProjects); -}; - -const getEditableProjects = ( - persesUserPermissions: PersesUserPermissions, - allAvailableProjects: string[], -): string[] => { - const editableProjectNames = new Set(); - Object.entries(persesUserPermissions).forEach(([projectName, permissions]) => { - const hasDashboardPermissions = permissions.some((permission) => { - const allActions = permission.actions.includes('*'); - const individualActions = - permission.actions.includes('create') && - permission.actions.includes('update') && - permission.actions.includes('delete'); - const hasPermission = - permission.scopes.includes('Dashboard') && (individualActions || allActions); - return hasPermission; - }); - - if (hasDashboardPermissions) { - if (projectName === '*') { - allAvailableProjects.forEach((p) => editableProjectNames.add(p)); - } else { - editableProjectNames.add(projectName); - } - } - }); - return Array.from(editableProjectNames); -}; - -export const useEditableProjects = () => { - const username = useUsername(); - const { ocpProjects } = useOcpProjects(); - - const { persesUserPermissions, persesPermissionsLoading, persesPermissionsError } = - useFetchPersesPermissions(username); - - const { editableProjects, allProjects }: Projects = useMemo(() => { - if (persesPermissionsLoading) { - return { - editableProjects: undefined, - allProjects: undefined, - }; - } - if (!persesUserPermissions) { - return { - editableProjects: undefined, - allProjects: undefined, - }; - } - if (persesPermissionsError) { - return { - editableProjects: undefined, - allProjects: undefined, - }; - } - - const allAvailableProjects = combinePersesAndOcpProjects(persesUserPermissions, ocpProjects); - const editableProjectNames = getEditableProjects(persesUserPermissions, allAvailableProjects); - - // Sort projects alphabetically - const sortedEditableProjects = editableProjectNames.sort((a, b) => a.localeCompare(b)); - const sortedProjects = allAvailableProjects.sort((a, b) => a.localeCompare(b)); - - return { - editableProjects: sortedEditableProjects, - allProjects: sortedProjects, - }; - }, [persesPermissionsLoading, persesUserPermissions, persesPermissionsError, ocpProjects]); - - const hasEditableProject = useMemo(() => { - return editableProjects ? editableProjects.length > 0 : false; - }, [editableProjects]); - - return { - editableProjects, - allProjects, - hasEditableProject, - permissionsLoading: persesPermissionsLoading, - permissionsError: persesPermissionsError, - }; -}; diff --git a/web/src/features/perses-dashboards/hooks/usePerses.ts b/web/src/features/perses-dashboards/hooks/usePerses.ts index 06a01f536..921ab926d 100644 --- a/web/src/features/perses-dashboards/hooks/usePerses.ts +++ b/web/src/features/perses-dashboards/hooks/usePerses.ts @@ -61,9 +61,5 @@ export const usePerses = (project?: string | number) => { persesProjectsLoading, persesProjects: persesProjects ?? [], persesProjectsError, - // Dashboards of a given project - persesProjectDashboards: persesProjectDashboards ?? [], - persesProjectDashboardsError, - persesProjectDashboardsLoading, }; }; diff --git a/web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts b/web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts new file mode 100644 index 000000000..9b57a0d1d --- /dev/null +++ b/web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts @@ -0,0 +1,16 @@ +import { useAccessReview } from '@openshift-console/dynamic-plugin-sdk'; +import type { AccessReviewResourceAttributes } from '@openshift-console/dynamic-plugin-sdk'; + +export type DashboardVerb = 'create' | 'update' | 'delete'; + +export const usePersesDashboardAccess = ( + verb: DashboardVerb, + namespace: string | null = null, + enabled = true, +): [boolean, boolean] => { + // set to {} when not enabled to prevent fetching access + const resourceAttributes: AccessReviewResourceAttributes = enabled + ? { group: 'perses.dev', resource: 'persesdashboards', verb, namespace } + : {}; + return useAccessReview(resourceAttributes, undefined, true); +}; diff --git a/web/src/features/perses-dashboards/hooks/usePersesEditPermissions.ts b/web/src/features/perses-dashboards/hooks/usePersesEditPermissions.ts deleted file mode 100644 index acfd8a875..000000000 --- a/web/src/features/perses-dashboards/hooks/usePersesEditPermissions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useAccessReview } from '@openshift-console/dynamic-plugin-sdk'; - -export const usePersesEditPermissions = (namespace: string | null = null) => { - const [canCreate, createLoading] = useAccessReview({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'create', - namespace, - }); - - const [canUpdate, updateLoading] = useAccessReview({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'update', - namespace, - }); - - const [canDelete, deleteLoading] = useAccessReview({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'delete', - namespace, - }); - - const loading = createLoading || updateLoading || deleteLoading; - const canEdit = canUpdate && canCreate && canDelete; - - return { canEdit, loading }; -}; diff --git a/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx b/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx index d52321ccc..4c39685b6 100644 --- a/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx +++ b/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx @@ -6,8 +6,8 @@ import { Flex, FlexItem, Label, + MenuToggle, Title, - Tooltip, } from '@patternfly/react-core'; import DataView from '@patternfly/react-data-view/dist/dynamic/DataView'; import { @@ -16,6 +16,7 @@ import { } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; import { useDataViewSort } from '@patternfly/react-data-view/dist/dynamic/Hooks'; +import RhUiEllipsisVerticalFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-ellipsis-vertical-fill-icon'; import { ActionsColumn } from '@patternfly/react-table'; import type { DashboardResource } from '@perses-dev/client'; import { type FC, memo, type ReactNode, useCallback, useMemo, useState } from 'react'; @@ -28,8 +29,7 @@ import { RenameActionModal, } from '@/features/perses-dashboards/components/DashboardActionModals'; import { useDashboardsData } from '@/features/perses-dashboards/hooks/useDashboardsData'; -import { useEditableProjects } from '@/features/perses-dashboards/hooks/useEditableProjects'; -import { usePersesEditPermissions } from '@/features/perses-dashboards/hooks/usePersesEditPermissions'; +import { usePersesDashboardAccess } from '@/features/perses-dashboards/hooks/usePersesDashboardAccess'; import { DashboardListFrame } from '@/features/perses-dashboards/pages/dashboard-list-page/DashboardListFrame'; import { useTableColumns } from '@/shared/components/table/hooks/useTableColumns'; import { rowFilter, useTableFilters } from '@/shared/components/table/hooks/useTableFilters'; @@ -54,26 +54,29 @@ const DashboardActionsCell = memo( onRename, onDuplicate, onDelete, - emptyActions, }: { project: string; dashboard: DashboardResource; onRename: (dashboard: DashboardResource) => void; onDuplicate: (dashboard: DashboardResource) => void; onDelete: (dashboard: DashboardResource) => void; - emptyActions: { title: string; onClick: () => void }[]; }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); + const [checkAccess, setCheckAccess] = useState(false); - const { permissionsLoading } = useEditableProjects(); - const { canEdit } = usePersesEditPermissions(project); - const disabled = !canEdit; + const [canUpdate, updateChecking] = usePersesDashboardAccess('update', project, checkAccess); + const [canDelete, deleteChecking] = usePersesDashboardAccess('delete', project, checkAccess); const rowSpecificActions = useMemo( () => [ { title: t('Rename dashboard'), onClick: () => onRename(dashboard), + isAriaDisabled: updateChecking || !canUpdate, + tooltipProps: + !updateChecking && !canUpdate + ? { content: t('You do not have permission to edit dashboards in this project.') } + : undefined, }, { title: t('Duplicate dashboard'), @@ -82,31 +85,47 @@ const DashboardActionsCell = memo( { title: t('Delete dashboard'), onClick: () => onDelete(dashboard), + isAriaDisabled: deleteChecking || !canDelete, + tooltipProps: + !deleteChecking && !canDelete + ? { content: t('You do not have permission to delete dashboards in this project.') } + : undefined, }, ], - [dashboard, onRename, onDuplicate, onDelete, t], + [ + dashboard, + onRename, + onDuplicate, + onDelete, + t, + canUpdate, + updateChecking, + canDelete, + deleteChecking, + ], ); - if (disabled) { - return ( - -
- -
-
- ); - } - if (permissionsLoading) { - return ( - -
- -
-
- ); - } - - return ; + return ( + ( + { + if (!isOpen) { + setCheckAccess(true); + } + onToggle(event); + }} + isExpanded={isOpen} + isDisabled={isDisabled} + variant="plain" + icon={} + /> + )} + /> + ); }, ); @@ -309,16 +328,6 @@ const DashboardsTable: FC = ({ setTargetedDashboard(undefined); }, []); - const emptyRowActions = useMemo( - () => [ - { - title: t("You don't have permissions for dashboard actions"), - onClick: () => {}, - }, - ], - [t], - ); - const pageRows: DataViewTr[] = useMemo(() => { return sortedAndFilteredData .slice((page - 1) * perPage, (page - 1) * perPage + perPage) @@ -336,7 +345,6 @@ const DashboardsTable: FC = ({ onRename={handleRenameModalOpen} onDuplicate={handleDuplicateModalOpen} onDelete={handleDeleteModalOpen} - emptyActions={emptyRowActions} /> ), props: { isActionCell: true }, @@ -346,7 +354,6 @@ const DashboardsTable: FC = ({ sortedAndFilteredData, page, perPage, - emptyRowActions, handleRenameModalOpen, handleDuplicateModalOpen, handleDeleteModalOpen, @@ -466,7 +473,7 @@ export const DashboardList: FC = () => { const { activeProject, persesDashboards, combinedInitialLoad } = useDashboardsData(); return ( - + = ({ activeProject, children }) => { +export const DashboardListFrame: FC = ({ children }) => { + const { perspective } = usePerspective(); + const navigate = useNavigate(); + return ( <> - + { + const url = `${getDashboardsListUrl(perspective)}`; + navigate(url); + }} + /> {children} ); diff --git a/web/src/features/perses-dashboards/pages/dashboard-page/DashboardFrame.tsx b/web/src/features/perses-dashboards/pages/dashboard-page/DashboardFrame.tsx index 0c3513bc6..23b68be31 100644 --- a/web/src/features/perses-dashboards/pages/dashboard-page/DashboardFrame.tsx +++ b/web/src/features/perses-dashboards/pages/dashboard-page/DashboardFrame.tsx @@ -1,12 +1,14 @@ -import { FC, ReactNode } from 'react'; +import { NamespaceBar } from '@openshift-console/dynamic-plugin-sdk'; +import type { FC, ReactNode } from 'react'; +import { useNavigate } from 'react-router'; import { DashboardHeader } from '@/features/perses-dashboards/components/DashboardHeader'; import { DashboardEmptyState } from '@/features/perses-dashboards/components/emptystates/DashboardEmptyState'; import { PersesWrapper } from '@/features/perses-dashboards/components/PersesWrapper'; -import { ProjectBar } from '@/features/perses-dashboards/components/project/ProjectBar'; import { ToastProvider } from '@/features/perses-dashboards/components/ToastProvider'; import { PagePadding } from '@/features/perses-dashboards/pages/dashboard-page/DashboardPagePadding'; import type { DashboardMetadata } from '@/features/perses-dashboards/types/types'; +import { getDashboardsListUrl, usePerspective } from '@/shared/hooks/usePerspective'; interface DashboardFrameProps { activeProject: string | null; @@ -21,9 +23,16 @@ export const DashboardFrame: FC = ({ dashboardDisplayName, children, }) => { + const { perspective } = usePerspective(); + const navigate = useNavigate(); return ( <> - + { + const url = `${getDashboardsListUrl(perspective)}`; + navigate(url); + }} + /> {activeProjectDashboardsMetadata?.length === 0 ? ( diff --git a/web/src/features/perses-dashboards/pages/dashboard-page/DashboardToolbar.tsx b/web/src/features/perses-dashboards/pages/dashboard-page/DashboardToolbar.tsx index fa8a0599f..63501e478 100644 --- a/web/src/features/perses-dashboards/pages/dashboard-page/DashboardToolbar.tsx +++ b/web/src/features/perses-dashboards/pages/dashboard-page/DashboardToolbar.tsx @@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next'; import { DashboardDropdown } from '@/features/perses-dashboards/components/DashboardDropdown'; import { useDashboardsData } from '@/features/perses-dashboards/hooks/useDashboardsData'; -import { usePersesEditPermissions } from '@/features/perses-dashboards/hooks/usePersesEditPermissions'; +import { usePersesDashboardAccess } from '@/features/perses-dashboards/hooks/usePersesDashboardAccess'; import { persesDashboardDataTestIDs } from '@/shared/constants/data-test'; export interface DashboardToolbarProps { @@ -38,41 +38,13 @@ export interface DashboardToolbarProps { } export interface EditButtonProps { - /** - * The label used inside the button. - */ - label?: string; - - /** - * Handler that puts the dashboard into editing mode. - */ onClick: () => void; - - /** - * Whether the button is disabled. - */ - disabled?: boolean; - - /** - * Tooltip text to show when button is disabled. - */ - disabledTooltip?: string; - - /** - * Whether permissions are still loading. - */ - loading?: boolean; - - /** - * The active project/namespace for permissions check. - */ activeProject?: string | null; } export const EditButton = ({ onClick, activeProject }: EditButtonProps): ReactElement => { const { t } = useTranslation(process.env.I18N_NAMESPACE); - const { canEdit, loading } = usePersesEditPermissions(activeProject); - const disabled = !canEdit; + const [canUpdate, updateLoading] = usePersesDashboardAccess('update', activeProject); const button = ( ); - if (disabled && !loading) { + if (!updateLoading && !canUpdate) { return ( - + {button} ); diff --git a/web/src/features/perses-dashboards/utils/perses-client.ts b/web/src/features/perses-dashboards/utils/perses-client.ts index c221f0a87..5c180a7e9 100644 --- a/web/src/features/perses-dashboards/utils/perses-client.ts +++ b/web/src/features/perses-dashboards/utils/perses-client.ts @@ -28,22 +28,6 @@ export const fetchPersesProjects = (): Promise => { return consoleFetchJSON(persesURL); }; -export interface PersesPermission { - scopes: string[]; - actions: string[]; -} - -export type PersesUserPermissions = { - [projectName: string]: PersesPermission[]; -}; - -export const fetchPersesUserPermissions = (username: string): Promise => { - const userPermissionsURL = `/api/v1/users/${encodeURIComponent(username)}/permissions`; - const persesURL = `${PERSES_PROXY_BASE_PATH}${userPermissionsURL}`; - - return consoleFetchJSON(persesURL); -}; - export const fetchPersesDashboard = async ( project: string, dashboardName: string, @@ -74,28 +58,3 @@ export const useFetchPersesDashboard = (project: string, dashboardName: string) persesDashboardLoading, }; }; - -export const useFetchPersesPermissions = (username: string) => { - const { - isLoading: persesPermissionsLoading, - error: persesPermissionsError, - data: persesUserPermissions, - } = useQuery({ - queryKey: ['perses-user-permissions', username], - queryFn: () => fetchPersesUserPermissions(username), - enabled: !!username, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - refetchOnWindowFocus: true, - retry: 2, - onError: (error) => { - // eslint-disable-next-line no-console - console.warn('Failed to fetch Perses user permissions:', error); - }, - }); - - return { - persesUserPermissions, - persesPermissionsError, - persesPermissionsLoading, - }; -}; diff --git a/web/src/shared/constants/data-test.ts b/web/src/shared/constants/data-test.ts index 74f14e9f3..b27826ccd 100644 --- a/web/src/shared/constants/data-test.ts +++ b/web/src/shared/constants/data-test.ts @@ -325,6 +325,9 @@ export const persesDashboardDataTestIDs = { importDashboardButtonToolbar: 'import-dashboard-button-list-page', editDashboardButtonToolbar: 'edit-dashboard-button-toolbar', cancelButtonToolbar: 'cancel-button-toolbar', + createAccessDeniedHelperText: 'perses-create-access-denied-helper-text', + updateAccessDeniedHelperText: 'perses-update-access-denied-helper-text', + deleteAccessDeniedHelperText: 'perses-delete-access-denied-helper-text', }; export const listPersesDashboardsDataTestIDs = { From 9d126e891cdcbdf9ce5c3d12e31c28a9044e1985 Mon Sep 17 00:00:00 2001 From: PeterYurkovich Date: Fri, 4 Sep 2026 16:40:21 -0400 Subject: [PATCH 2/2] fix: add a useAccessCheck which accounts for changes in the resource --- .../hooks/useAccessReview.ts | 29 +++++++++++++++++++ .../hooks/usePersesDashboardAccess.ts | 15 ++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 web/src/features/perses-dashboards/hooks/useAccessReview.ts diff --git a/web/src/features/perses-dashboards/hooks/useAccessReview.ts b/web/src/features/perses-dashboards/hooks/useAccessReview.ts new file mode 100644 index 000000000..bc415e028 --- /dev/null +++ b/web/src/features/perses-dashboards/hooks/useAccessReview.ts @@ -0,0 +1,29 @@ +import { + type AccessReviewResourceAttributes, + checkAccess, +} from '@openshift-console/dynamic-plugin-sdk'; +import { useQuery } from '@tanstack/react-query'; + +// reimplementation of useAccessReview from the sdk to allow for changes in resourceAttributes +// which it doesn't track +export const useAccessReview = ( + resourceAttributes: AccessReviewResourceAttributes, + enabled = true, +): [boolean, boolean] => { + const { + group = '', + resource = '', + subresource = '', + verb = '', + name = '', + namespace = '', + } = resourceAttributes; + const { data, isError, isLoading } = useQuery({ + queryKey: ['access-review', group, resource, subresource, verb, name, namespace], + queryFn: () => checkAccess(resourceAttributes).then((result) => result.status.allowed), + enabled, + retry: false, + }); + + return [isError || data === true, enabled && isLoading]; +}; diff --git a/web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts b/web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts index 9b57a0d1d..d9b87f989 100644 --- a/web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts +++ b/web/src/features/perses-dashboards/hooks/usePersesDashboardAccess.ts @@ -1,6 +1,7 @@ -import { useAccessReview } from '@openshift-console/dynamic-plugin-sdk'; import type { AccessReviewResourceAttributes } from '@openshift-console/dynamic-plugin-sdk'; +import { useAccessReview } from '@/features/perses-dashboards/hooks/useAccessReview'; + export type DashboardVerb = 'create' | 'update' | 'delete'; export const usePersesDashboardAccess = ( @@ -8,9 +9,11 @@ export const usePersesDashboardAccess = ( namespace: string | null = null, enabled = true, ): [boolean, boolean] => { - // set to {} when not enabled to prevent fetching access - const resourceAttributes: AccessReviewResourceAttributes = enabled - ? { group: 'perses.dev', resource: 'persesdashboards', verb, namespace } - : {}; - return useAccessReview(resourceAttributes, undefined, true); + const resourceAttributes: AccessReviewResourceAttributes = { + group: 'perses.dev', + resource: 'persesdashboards', + verb, + namespace, + }; + return useAccessReview(resourceAttributes, enabled && !!namespace); };