From 2ecc076bbfca5b9b252de35889d4885a4c199478 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Wed, 13 Aug 2025 03:08:53 -0400 Subject: [PATCH] Provisioned Resource Read Only: When repo is read only, disable action buttons and display badge (#109494) * When repo is read only, disable action buttons and display badge * browse dashboards page, disable checkbox if repo is read only * clean up * clean up * i18n * added read only status to repository page * i18n * fix * readonly tooltip added local provisioning message * i18n --- .../provisioning/v0alpha1/endpoints.gen.ts | 5 ++- .../NestedFolderPicker/FolderRepo.tsx | 31 +++++++++++--- .../BrowseDashboardsPage.tsx | 7 +++- .../useSelectionRepoValidation.ts | 20 +++++++++ .../BulkDeleteProvisionedResource.test.tsx | 1 + .../components/CheckboxCell.tsx | 15 ++++++- .../components/CheckboxHeaderCell.tsx | 8 +++- .../components/CreateNewButton.test.tsx | 10 +++-- .../components/CreateNewButton.tsx | 18 +++++++- .../components/FolderActionsButton.tsx | 12 +++++- .../app/features/browse-dashboards/types.ts | 1 + .../scene/NavToolbarActions.tsx | 41 ++++++++++++++++++- .../Repository/RepositoryActions.tsx | 7 +++- .../Repository/RepositoryCard.tsx | 9 +++- .../hooks/useGetResourceRepositoryView.ts | 4 ++ .../features/provisioning/utils/repository.ts | 24 +++++++++-- public/locales/en-US/grafana.json | 7 +++- 17 files changed, 192 insertions(+), 28 deletions(-) diff --git a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts index 33ee7549eb7..bc262a42bae 100644 --- a/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts +++ b/public/app/api/clients/provisioning/v0alpha1/endpoints.gen.ts @@ -1013,7 +1013,7 @@ export type RepositorySpec = { - `"local"` */ type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local'; /** UI driven Workflow that allow changes to the contends of the repository. The order is relevant for defining the precedence of the workflows. When empty, the repository does not support any edits (eg, readonly) */ - workflows: ('branch' | 'write')[]; + workflows: RepoWorkflows; }; export type HealthStatus = { /** When the health was checked last time */ @@ -1258,6 +1258,7 @@ export type WebhookResponse = { /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ kind?: string; }; +export type RepoWorkflows = ('branch' | 'write')[] export type RepositoryView = { /** For git, this is the target branch */ branch?: string; @@ -1281,7 +1282,7 @@ export type RepositoryView = { - `"local"` */ type: 'bitbucket' | 'git' | 'github' | 'gitlab' | 'local'; /** The supported workflows */ - workflows: ('branch' | 'write')[]; + workflows: RepoWorkflows; }; export type RepositoryViewList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index b4d8c2bcf26..be50e30b5be 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -1,6 +1,7 @@ import { t } from '@grafana/i18n'; -import { Badge } from '@grafana/ui'; -import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { Badge, Stack } from '@grafana/ui'; +import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { NestedFolderDTO } from 'app/features/search/service/types'; import { FolderDTO, FolderListItemDTO } from 'app/types/folders'; @@ -9,11 +10,31 @@ export interface Props { } export function FolderRepo({ folder }: Props) { - const isProvisionedInstance = useIsProvisionedInstance(); + // skip rendering if: + // folder is not present + // folder have parentUID + // folder is not managed + const skipRender = !folder || ('parentUID' in folder && folder.parentUID) || !folder.managedBy; - if (!folder || ('parentUID' in folder && folder.parentUID) || !folder.managedBy || isProvisionedInstance) { + const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ + folderName: skipRender ? undefined : folder?.uid, + }); + + if (skipRender) { return null; } - return ; + return ( + // badge with text and icon only has different height, we will need to adjust the layout using stretch + + {isReadOnlyRepo && ( + + )} + + + ); } diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 1574433972d..038a32d1af6 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -16,6 +16,7 @@ import { FolderRepo } from '../../core/components/NestedFolderPicker/FolderRepo' import { contextSrv } from '../../core/services/context_srv'; import { ManagerKind } from '../apiserver/types'; import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; +import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView'; import { useSearchStateManager } from '../search/state/SearchStateManager'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; @@ -41,6 +42,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record new URLSearchParams(location.search), [location.search]); + const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: folderUID }); useEffect(() => { stateManager.initStateFromUrl(folderUID); @@ -109,6 +111,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record { if (folderDTO) { @@ -160,12 +163,14 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: RecordRecently deleted )} - {folderDTO && } + {folderDTO && } {(canCreateDashboards || canCreateFolders) && ( )} diff --git a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts index 30ee0957c17..cde7e9d56e5 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts +++ b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionRepoValidation.ts @@ -1,3 +1,8 @@ +import { skipToken } from '@reduxjs/toolkit/query'; + +import { config } from '@grafana/runtime'; +import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; +import { getIsReadOnlyRepo } from 'app/features/provisioning/utils/repository'; import { useSelector } from 'app/types/store'; import { useChildrenByParentUIDState, rootItemsSelector } from '../../state/hooks'; @@ -7,9 +12,19 @@ import { getItemRepositoryUid } from '../utils'; // This hook is responsible for validating if all selected resources (dashboard folders and dashboards) are in the same repository export function useSelectionRepoValidation(selectedItems: Omit) { + const provisioningEnabled = config.featureToggles.provisioning; const childrenByParentUID = useChildrenByParentUIDState(); const rootItems = useSelector(rootItemsSelector)?.items ?? []; + const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled ? skipToken : undefined); + // Function to grab repository configuration by UID + const getRepositoryByUid = (repoUid: string) => { + if (!settingsData?.items || repoUid === 'non_provisioned') { + return undefined; + } + return settingsData.items.find((repo) => repo.name === repoUid); + }; + const getRepoUid = (uid: string) => { const item = findItem(rootItems, childrenByParentUID, uid); return item ? getItemRepositoryUid(item, rootItems, childrenByParentUID) : 'non_provisioned'; @@ -26,10 +41,15 @@ export function useSelectionRepoValidation(selectedItems: Omit 1; const isInLockedRepo = (uid: string) => !selectedItemsRepoUID || getRepoUid(uid) === selectedItemsRepoUID; + const isUidInReadOnlyRepo = (uid: string) => { + const repo = getRepositoryByUid(getRepoUid(uid)); + return repo ? getIsReadOnlyRepo(repo) : false; + }; return { selectedItemsRepoUID, isInLockedRepo, isCrossRepo, // true if items are from different repositories + isUidInReadOnlyRepo, }; } diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx index 65b6686ef14..2a5be2aa309 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx +++ b/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx @@ -118,6 +118,7 @@ describe('BulkDeleteProvisionedResource', () => { selectedItemsRepoUID: 'test-folder', isInLockedRepo: jest.fn().mockReturnValue(false), isCrossRepo: false, + isUidInReadOnlyRepo: jest.fn().mockReturnValue(false), }); }); diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 7867510d940..736e98bbf44 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -5,6 +5,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { Checkbox, Tooltip, useStyles2 } from '@grafana/ui'; import { ManagerKind } from 'app/features/apiserver/types'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { useSelector } from 'app/types/store'; import { DashboardsTreeCellProps, SelectionState } from '../types'; @@ -22,7 +23,7 @@ export default function CheckboxCell({ // Get current selection state for repository validation const selectedItems = useSelector((state) => state.browseDashboards.selectedItems); - const { selectedItemsRepoUID, isInLockedRepo } = useSelectionRepoValidation(selectedItems); + const { selectedItemsRepoUID, isInLockedRepo, isUidInReadOnlyRepo } = useSelectionRepoValidation(selectedItems); // Early returns for cases where we should show a spacer instead of checkbox if (!isSelected) { @@ -46,11 +47,23 @@ export default function CheckboxCell({ return ; } + if ((permissions && permissions.isReadOnlyRepo) || isUidInReadOnlyRepo(item.uid)) { + // When the folder is read-only (inherited from repository), disable checkbox with tooltip + return ( + + + + + + ); + } + // Check if user can edit this specific item type if (permissions && !canEditItemType(item.kind, permissions)) { return ; } + // check if current item uid has different repo uid than selected items if (selectedItemsRepoUID && !isInLockedRepo(item.uid)) { return ( ) { } async function renderAndOpen(folder?: FolderDTO) { - render(); + render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); } @@ -42,7 +42,9 @@ describe('NewActionsButton', () => { }); it('clicking the "New folder" button opens the drawer', async () => { - render(); + render( + + ); const newButton = screen.getByText('New'); await userEvent.click(newButton); @@ -55,7 +57,7 @@ describe('NewActionsButton', () => { }); it('should only render dashboard items when folder creation is disabled', async () => { - render(); + render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); @@ -65,7 +67,7 @@ describe('NewActionsButton', () => { }); it('should only render folder item when dashboard creation is disabled', async () => { - render(); + render(); const newButton = screen.getByText('New'); await userEvent.click(newButton); diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index 027916de85e..bc8b896b8ae 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -5,7 +5,9 @@ import { locationUtil } from '@grafana/data'; import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; +import { RepoType } from 'app/features/provisioning/Wizard/types'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { getImportPhrase, getNewDashboardPhrase, @@ -24,9 +26,17 @@ interface Props { parentFolder?: FolderDTO; canCreateFolder: boolean; canCreateDashboard: boolean; + isReadOnlyRepo: boolean; + repoType?: RepoType; } -export default function CreateNewButton({ parentFolder, canCreateDashboard, canCreateFolder }: Props) { +export default function CreateNewButton({ + parentFolder, + canCreateDashboard, + canCreateFolder, + isReadOnlyRepo, + repoType, +}: Props) { const [isOpen, setIsOpen] = useState(false); const location = useLocation(); const [newFolder] = useNewFolderMutation(); @@ -94,7 +104,11 @@ export default function CreateNewButton({ parentFolder, canCreateDashboard, canC return ( <> - diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index b986d541f7f..c58b310e0e3 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -6,6 +6,8 @@ import { locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; import { Permissions } from 'app/core/components/AccessControl'; import { appEvents } from 'app/core/core'; +import { RepoType } from 'app/features/provisioning/Wizard/types'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { ShowModalReactEvent } from 'app/types/events'; import { FolderDTO } from 'app/types/folders'; @@ -20,9 +22,11 @@ import { DeleteProvisionedFolderForm } from './DeleteProvisionedFolderForm'; interface Props { folder: FolderDTO; + isReadOnlyRepo?: boolean; + repoType?: RepoType; } -export function FolderActionsButton({ folder }: Props) { +export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) { const [isOpen, setIsOpen] = useState(false); const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false); @@ -137,7 +141,11 @@ export function FolderActionsButton({ folder }: Props) { return ( <> - diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 795d3bb794d..5aece134d5e 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -67,4 +67,5 @@ export interface BrowseDashboardsPermissions { canEditDashboards: boolean; canDeleteFolders?: boolean; canDeleteDashboards?: boolean; + isReadOnlyRepo?: boolean; } diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 00dbd0943a3..47fec9cf54a 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -5,7 +5,17 @@ import { GrafanaTheme2, store } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { config, locationService } from '@grafana/runtime'; -import { Button, ButtonGroup, Dropdown, Icon, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui'; +import { + Badge, + Button, + ButtonGroup, + Dropdown, + Icon, + Menu, + ToolbarButton, + ToolbarButtonRow, + useStyles2, +} from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; import grafanaConfig from 'app/core/config'; @@ -13,6 +23,8 @@ import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { contextSrv } from 'app/core/core'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; +import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { useSelector } from 'app/types/store'; import { shareDashboardType } from '../../dashboard/components/ShareModal/utils'; @@ -75,6 +87,10 @@ export function ToolbarActions({ dashboard }: Props) { const isEditingAndShowingDashboard = isEditing && isShowingDashboard; const folderRepo = useSelector((state) => selectFolderRepository()(state, meta.folderUid)); const isManaged = Boolean(dashboard.isManagedRepository() || folderRepo); + // Get the repository for the dashboard's folder + const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ + folderName: meta.folderUid, + }); // Internal only; // allows viewer editing without ability to save @@ -118,6 +134,22 @@ export function ToolbarActions({ dashboard }: Props) { }, }); + if (isReadOnlyRepo) { + toolbarActions.push({ + group: 'icon-actions', + condition: true, + render: () => { + return ( + + ); + }, + }); + } + if (dashboard.isManaged() && meta.canEdit) { toolbarActions.push({ group: 'icon-actions', @@ -325,12 +357,17 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.onEnterEditMode(); }} - tooltip={t('dashboard.toolbar.edit.tooltip', 'Enter edit mode')} + tooltip={ + isReadOnlyRepo + ? getReadOnlyTooltipText({ isLocal: repoType === 'local' }) + : t('dashboard.toolbar.edit.tooltip', 'Enter edit mode') + } key="edit" className={styles.buttonWithExtraMargin} variant={config.featureToggles.newDashboardSharingComponent ? 'secondary' : 'primary'} size="sm" data-testid={selectors.components.NavToolbar.editDashboard.editButton} + disabled={isReadOnlyRepo} > Edit diff --git a/public/app/features/provisioning/Repository/RepositoryActions.tsx b/public/app/features/provisioning/Repository/RepositoryActions.tsx index d1b710ee2b2..1eb7ffd0649 100644 --- a/public/app/features/provisioning/Repository/RepositoryActions.tsx +++ b/public/app/features/provisioning/Repository/RepositoryActions.tsx @@ -1,10 +1,11 @@ -import { Trans } from '@grafana/i18n'; -import { Button, LinkButton, Stack } from '@grafana/ui'; +import { t, Trans } from '@grafana/i18n'; +import { Badge, Button, LinkButton, Stack } from '@grafana/ui'; import { Repository } from 'app/api/clients/provisioning/v0alpha1'; import { StatusBadge } from '../Shared/StatusBadge'; import { PROVISIONING_URL } from '../constants'; import { getRepoHrefForProvider } from '../utils/git'; +import { getIsReadOnlyWorkflows } from '../utils/repository'; import { getRepositoryTypeConfig } from '../utils/repositoryTypes'; import { DeleteRepositoryButton } from './DeleteRepositoryButton'; @@ -21,9 +22,11 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) { const repoType = repository.spec?.type; const repoConfig = repoType ? getRepositoryTypeConfig(repoType) : undefined; const providerIcon = repoConfig?.icon || 'external-link-alt'; + const isReadOnlyRepo = getIsReadOnlyWorkflows(repository.spec?.workflows); return ( + {isReadOnlyRepo && } {repoHref && (