diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index e392e6a3f53..f37973f7401 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -97,7 +97,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record { if (folderDTO) { const result = await saveFolder({ @@ -171,7 +171,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record {hasSelection ? ( - + ) : (
diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 3dd60457c77..de3e281fae0 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -1,11 +1,13 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; -import { Button, Stack, Tooltip } from '@grafana/ui'; +import { Button, Drawer, Stack, Tooltip } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +import { ManagerKind } from 'app/features/apiserver/types'; import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; import { ShowModalReactEvent } from 'app/types/events'; +import { FolderDTO } from 'app/types/folders'; import { useDispatch } from 'app/types/store'; import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDashboardsAPI'; @@ -15,15 +17,27 @@ import { DashboardTreeSelection } from '../../types'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; +import { SelectedMixResourcesMsgModal } from './SelectedMixResourcesMsgModal'; +import { useSelectionProvisioningStatus } from './useSelectionProvisioningStatus'; -export interface Props {} +export interface Props { + folderDTO?: FolderDTO; +} + +export function BrowseActions({ folderDTO }: Props) { + const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false); -export function BrowseActions() { const dispatch = useDispatch(); const selectedItems = useActionSelectionState(); const [deleteItems] = useDeleteItemsMutation(); const [moveItems] = useMoveItemsMutation(); const [, stateManager] = useSearchStateManager(); + const provisioningEnabled = config.featureToggles.provisioning; + + const { hasProvisioned, hasNonProvisioned } = useSelectionProvisioningStatus( + selectedItems, + folderDTO?.managedBy === ManagerKind.Repo + ); // Folders can only be moved if nested folders is enabled const moveIsInvalid = useMemo( @@ -67,15 +81,29 @@ export function BrowseActions() { }; const showDeleteModal = () => { - appEvents.publish( - new ShowModalReactEvent({ - component: DeleteModal, - props: { - selectedItems, - onConfirm: onDelete, - }, - }) - ); + if (hasProvisioned && hasNonProvisioned && provisioningEnabled) { + // Mixed selection + appEvents.publish( + new ShowModalReactEvent({ + component: SelectedMixResourcesMsgModal, + props: {}, + }) + ); + } else if (hasProvisioned && provisioningEnabled) { + // Only provisioned items + setShowBulkDeleteProvisionedResource(true); + } else { + // Only non-provisioned items + appEvents.publish( + new ShowModalReactEvent({ + component: DeleteModal, + props: { + selectedItems, + onConfirm: onDelete, + }, + }) + ); + } }; const moveButton = ( @@ -85,19 +113,33 @@ export function BrowseActions() { ); return ( - - {moveIsInvalid ? ( - - {moveButton} - - ) : ( - moveButton - )} + <> + + {moveIsInvalid ? ( + + {moveButton} + + ) : ( + moveButton + )} - - + + + {showBulkDeleteProvisionedResource && ( + setShowBulkDeleteProvisionedResource(false)} + size="md" + > + {/* TODO: Implement bulk delete for provisioned resources, PR will merge soon https://github.com/grafana/grafana/pull/107800 */} + + Bulk delete for provisioned resources is not implemented yet. + + + )} + ); } diff --git a/public/app/features/browse-dashboards/components/BrowseActions/SelectedMixResourcesMsgModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/SelectedMixResourcesMsgModal.tsx new file mode 100644 index 00000000000..6bcef8b29fb --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/SelectedMixResourcesMsgModal.tsx @@ -0,0 +1,30 @@ +import { css } from '@emotion/css'; + +import { Trans, t } from '@grafana/i18n'; +import { Modal, useStyles2 } from '@grafana/ui'; + +export interface Props {} + +export const SelectedMixResourcesMsgModal = ({ onDismiss }: { onDismiss: () => void }) => { + const styles = useStyles2(getStyles); + return ( + + + You have selected both provisioned and non-provisioned resources. These cannot be processed together. Please + select only provisioned resources or only non-provisioned resources and try again. + + + ); +}; + +const getStyles = () => ({ + modal: css({ + label: 'RowOptionsModal', + width: '500px', + }), +}); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/useSelectionProvisioningStatus.ts b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionProvisioningStatus.ts new file mode 100644 index 00000000000..cc4724b9ded --- /dev/null +++ b/public/app/features/browse-dashboards/components/BrowseActions/useSelectionProvisioningStatus.ts @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { config } from '@grafana/runtime'; +import { ScopedResourceClient } from 'app/features/apiserver/client'; +import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types'; +import { isProvisionedDashboard as isProvisionedDashboardFromMeta } from 'app/features/browse-dashboards/api/isProvisioned'; +import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; +import { useSelector } from 'app/types/store'; + +import { findItem } from '../../state/utils'; +import { DashboardTreeSelection } from '../../types'; + +export function useSelectionProvisioningStatus( + selectedItems: Omit, + isParentProvisioned: boolean +) { + const browseState = useSelector((state) => state.browseDashboards); + const isProvisionedInstance = useIsProvisionedInstance(); + const [, stateManager] = useSearchStateManager(); + const isSearching = stateManager.hasSearchFilters(); + const provisioningEnabled = config.featureToggles.provisioning; + + const [status, setStatus] = useState({ hasProvisioned: false, hasNonProvisioned: false }); + + const [folderCache, setFolderCache] = useState>({}); + const [dashboardCache, setDashboardCache] = useState>({}); + + // Create folder resource client for k8s API + const folderClient = useMemo( + () => + new ScopedResourceClient({ + group: 'folder.grafana.app', + version: 'v1beta1', + resource: 'folders', + }), + [] + ); + + const findItemInState = useCallback( + (uid: string) => { + const item = findItem(browseState.rootItems?.items || [], browseState.childrenByParentUID, uid); + return item ? { parentUID: item.parentUID, managedBy: item.managedBy } : undefined; + }, + [browseState] + ); + + const getFolderMeta = useCallback( + async (uid: string) => { + if (folderCache[uid] !== undefined) { + return folderCache[uid]; + } + try { + const folder = await folderClient.get(uid); + const managedBy = folder.metadata?.annotations?.[AnnoKeyManagerKind]; + const result = managedBy === ManagerKind.Repo; + setFolderCache((prev) => ({ ...prev, [uid]: result })); + return result; + } catch { + return false; + } + }, + [folderCache, folderClient] + ); + + const getDashboardMeta = useCallback( + async (uid: string) => { + if (dashboardCache[uid] !== undefined) { + return dashboardCache[uid]; + } + try { + const dto = await getDashboardAPI().getDashboardDTO(uid); + const result = isProvisionedDashboardFromMeta(dto); + setDashboardCache((prev) => ({ ...prev, [uid]: result })); + return result; + } catch { + return false; + } + }, + [dashboardCache] + ); + + const checkItemProvisioning = useCallback( + async (uid: string, isFolder: boolean): Promise => { + if (isSearching) { + // If searching, we need provisioning status with fetching metadata + return isFolder ? await getFolderMeta(uid) : await getDashboardMeta(uid); + } + + const item = findItemInState(uid); + if (isFolder) { + return item?.managedBy === ManagerKind.Repo; + } + + // Check parent folder first + const parent = item?.parentUID ? findItemInState(item.parentUID) : undefined; + if (parent?.managedBy === ManagerKind.Repo) { + return true; + } + + return item?.managedBy === ManagerKind.Repo; + }, + [isSearching, getFolderMeta, getDashboardMeta, findItemInState] + ); + + useEffect(() => { + const checkProvisioningStatus = async () => { + // If the instance is provisioned or the parent folder is provisioned, we can skip checking individual items + if (isProvisionedInstance || isParentProvisioned) { + setStatus({ hasProvisioned: true, hasNonProvisioned: false }); + return; + } + + if (!provisioningEnabled) { + setStatus({ hasProvisioned: false, hasNonProvisioned: true }); + return; + } + + const folders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]); + const dashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]); + + let hasProvisioned = false; + let hasNonProvisioned = false; + + const allItems = [ + ...folders.map((uid) => ({ uid, isFolder: true })), + ...dashboards.map((uid) => ({ uid, isFolder: false })), + ]; + for (const { uid, isFolder } of allItems) { + const isProvisioned = await checkItemProvisioning(uid, isFolder); + isProvisioned ? (hasProvisioned = true) : (hasNonProvisioned = true); + if (hasProvisioned && hasNonProvisioned) { + // If we have both provisioned and non-provisioned items, we can stop checking + break; + } + } + + setStatus({ hasProvisioned, hasNonProvisioned }); + }; + + checkProvisioningStatus(); + }, [ + selectedItems, + isProvisionedInstance, + isParentProvisioned, + isSearching, + findItemInState, + checkItemProvisioning, + provisioningEnabled, + ]); + + return status; +} diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 6091907260e..c5b9e7f3d51 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -5,7 +5,6 @@ import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { Checkbox, useStyles2 } from '@grafana/ui'; -import { ManagerKind } from '../../apiserver/types'; import { DashboardsTreeCellProps, SelectionState } from '../types'; import { isSharedWithMe } from './utils'; @@ -42,7 +41,6 @@ export default function CheckboxCell({ value={state === SelectionState.Selected} indeterminate={state === SelectionState.Mixed} onChange={(ev) => onItemSelectionChange?.(item, ev.currentTarget.checked)} - disabled={item.managedBy === ManagerKind.Repo} /> ); } diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index da611f9da95..26030620753 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -2,7 +2,6 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; -import { ManagerKind } from '../../apiserver/types'; import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState } from '../types'; @@ -92,7 +91,7 @@ export function setItemSelectionState( const { item, isSelected } = action.payload; // UI shouldn't allow it, but also prevent sharedwithme from being selected - if (isSharedWithMe(item.uid) || item.managedBy === ManagerKind.Repo) { + if (isSharedWithMe(item.uid)) { return; } @@ -173,8 +172,8 @@ export function setAllSelection( } for (const child of collection.items) { - // Don't traverse into the sharedwithme/provisioned folders - if (isSharedWithMe(child.uid) || child.managedBy === ManagerKind.Repo) { + // Don't traverse into the sharedwithme folder + if (isSharedWithMe(child.uid)) { continue; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 804e7c186c3..556e6c78632 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3461,6 +3461,8 @@ }, "browse-dashboards": { "action": { + "bulk-delete-provisioned-resources": "Bulk Delete Provisioned Resources", + "bulk-delete-provisioned-resources-not-implemented": "Bulk delete for provisioned resources is not implemented yet.", "cancel-button": "Cancel", "cannot-move-folders": "Folders cannot be moved", "confirmation-text": "Delete", @@ -3479,7 +3481,9 @@ "move-modal-text": "This action will move the following content:", "move-modal-title": "Move", "moving": "Moving...", - "new-folder-name-required-phrase": "Folder name is required." + "new-folder-name-required-phrase": "Folder name is required.", + "selected-mix-resources-modal-text": "You have selected both provisioned and non-provisioned resources. These cannot be processed together. Please select only provisioned resources or only non-provisioned resources and try again.", + "selected-mix-resources-modal-title": "Mixed resource types selected" }, "actions": { "button-to-recently-deleted": "Recently deleted"