From 20d6702e50b21211e0e3fca68eb9da7aeb601e3c Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Mon, 21 Jul 2025 11:41:21 -0400 Subject: [PATCH] Git Sync: Bulk deletion (#107800) * Git sync bulk delete --- public/app/api/utils.ts | 12 + .../BrowseActions/BrowseActions.tsx | 13 +- .../components/BulkActionFailureBanner.tsx | 29 ++ .../components/BulkActionProgress.tsx | 29 ++ .../BulkDeleteProvisionedResource.tsx | 259 ++++++++++++++++++ .../DeleteProvisionedFolderForm.test.tsx | 7 + .../NewProvisionedFolderForm.test.tsx | 7 + .../ProvisionedFolderPreviewBanner.tsx | 6 +- .../browse-dashboards/components/utils.ts | 50 +++- .../provisioned/PreviewBannerViewPR.tsx | 31 ++- .../provisioning/hooks/usePullRequestParam.ts | 2 + public/locales/en-US/grafana.json | 20 +- 12 files changed, 455 insertions(+), 10 deletions(-) create mode 100644 public/app/features/browse-dashboards/components/BulkActionFailureBanner.tsx create mode 100644 public/app/features/browse-dashboards/components/BulkActionProgress.tsx create mode 100644 public/app/features/browse-dashboards/components/BulkDeleteProvisionedResource.tsx diff --git a/public/app/api/utils.ts b/public/app/api/utils.ts index 1ad0ff690e5..5242b18fcfb 100644 --- a/public/app/api/utils.ts +++ b/public/app/api/utils.ts @@ -37,3 +37,15 @@ export const handleError = (e: unknown, dispatch: ThunkDispatch, message: string } } }; + +export function extractErrorMessage(error: unknown): string { + if (error && typeof error === 'object') { + if ('data' in error && error.data && typeof error.data === 'object' && 'message' in error.data) { + return String(error.data.message); + } + if ('message' in error) { + return String(error.message); + } + } + return String(error); +} diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index de3e281fae0..d246ccfcce9 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -14,6 +14,7 @@ import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDa import { useActionSelectionState } from '../../state/hooks'; import { setAllSelection } from '../../state/slice'; import { DashboardTreeSelection } from '../../types'; +import { BulkDeleteProvisionedResource } from '../BulkDeleteProvisionedResource'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; @@ -133,10 +134,14 @@ export function BrowseActions({ folderDTO }: Props) { onClose={() => 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. - + { + setShowBulkDeleteProvisionedResource(false); + onActionComplete(); + }} + /> )} diff --git a/public/app/features/browse-dashboards/components/BulkActionFailureBanner.tsx b/public/app/features/browse-dashboards/components/BulkActionFailureBanner.tsx new file mode 100644 index 00000000000..332e0695144 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActionFailureBanner.tsx @@ -0,0 +1,29 @@ +import { t } from '@grafana/i18n'; +import { Alert } from '@grafana/ui'; + +export type MoveResultFailed = { + status?: 'failed'; + title?: string; + errorMessage?: string; +}; + +export function BulkActionFailureBanner({ result, onDismiss }: { result: MoveResultFailed[]; onDismiss: () => void }) { + return ( + +
    + {result.map((item) => ( +
  • + {item.title} + {item.errorMessage && `: ${item.errorMessage}`} +
  • + ))} +
+
+ ); +} diff --git a/public/app/features/browse-dashboards/components/BulkActionProgress.tsx b/public/app/features/browse-dashboards/components/BulkActionProgress.tsx new file mode 100644 index 00000000000..1456162886e --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkActionProgress.tsx @@ -0,0 +1,29 @@ +import { Trans } from '@grafana/i18n'; +import { Box, Text } from '@grafana/ui'; +import ProgressBar from 'app/features/provisioning/Shared/ProgressBar'; + +export interface ProgressState { + current: number; + total: number; + item: string; +} + +export function BulkActionProgress({ progress }: { progress: ProgressState }) { + const progressPercentage = Math.round((progress.current / progress.total) * 100); + + return ( + + + + + + + {progress.item} + + + ); +} diff --git a/public/app/features/browse-dashboards/components/BulkDeleteProvisionedResource.tsx b/public/app/features/browse-dashboards/components/BulkDeleteProvisionedResource.tsx new file mode 100644 index 00000000000..4e7155b6187 --- /dev/null +++ b/public/app/features/browse-dashboards/components/BulkDeleteProvisionedResource.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { AppEvents } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { getAppEvents } from '@grafana/runtime'; +import { Box, Button, Stack } from '@grafana/ui'; +import { + DeleteRepositoryFilesWithPathApiArg, + DeleteRepositoryFilesWithPathApiResponse, + RepositoryView, + useDeleteRepositoryFilesWithPathMutation, +} from 'app/api/clients/provisioning/v0alpha1'; +import { extractErrorMessage } from 'app/api/utils'; +import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; +import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; +import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp'; +import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; +import { WorkflowOption } from 'app/features/provisioning/types'; +import { useSelector } from 'app/types/store'; + +import { useChildrenByParentUIDState, rootItemsSelector } from '../state/hooks'; +import { findItem } from '../state/utils'; +import { DashboardTreeSelection } from '../types'; + +import { DescendantCount } from './BrowseActions/DescendantCount'; +import { BulkActionFailureBanner, MoveResultFailed } from './BulkActionFailureBanner'; +import { BulkActionProgress, ProgressState } from './BulkActionProgress'; +import { collectSelectedItems, fetchProvisionedDashboardPath } from './utils'; + +interface BulkDeleteFormData { + comment: string; + ref: string; + workflow?: WorkflowOption; +} + +interface FormProps extends BulkDeleteProvisionResourceProps { + initialValues: BulkDeleteFormData; + repository: RepositoryView; + workflowOptions: Array<{ label: string; value: string }>; + isGitHub: boolean; + folderPath?: string; +} + +interface BulkDeleteProvisionResourceProps { + folderUid?: string; + selectedItems: Omit; + onDismiss?: () => void; +} + +type BulkSuccessResponse = Array<{ + index: number; + item: DeleteRepositoryFilesWithPathApiArg; + data: DeleteRepositoryFilesWithPathApiResponse; +}>; + +function FormContent({ + initialValues, + selectedItems, + repository, + workflowOptions, + folderPath, + isGitHub, + onDismiss, +}: FormProps) { + const [deleteRepoFile, request] = useDeleteRepositoryFilesWithPathMutation(); + const [progress, setProgress] = useState(null); + const [failureResults, setFailureResults] = useState(); + + const methods = useForm({ defaultValues: initialValues }); + const childrenByParentUID = useChildrenByParentUIDState(); + const rootItems = useSelector(rootItemsSelector); + const { handleSubmit, watch } = methods; + const workflow = watch('workflow'); + const navigate = useNavigate(); + + const getResourcePath = async (uid: string, isFolder: boolean): Promise => { + const item = findItem(rootItems?.items || [], childrenByParentUID, uid); + if (!item) { + return undefined; + } + return isFolder ? `${folderPath}/${item.title}/` : fetchProvisionedDashboardPath(uid); + }; + + const handleSuccess = (successes: BulkSuccessResponse) => { + getAppEvents().publish({ + type: AppEvents.alertSuccess.name, + payload: [ + t('browse-dashboards.bulk-delete-resources-form.api-success', `Successfully deleted {{count}} items`, { + count: successes.length, + }), + ], + }); + + if (workflow === 'branch') { + onDismiss?.(); + const repoUrl = successes[0].data.urls?.repositoryURL; + if (repoUrl) { + navigate({ search: `?repo_url=${encodeURIComponent(repoUrl)}` }); + return; + } + window.location.reload(); + } else { + onDismiss?.(); + window.location.reload(); + } + }; + + const handleSubmitForm = async (data: BulkDeleteFormData) => { + setFailureResults(undefined); + + const targets = collectSelectedItems(selectedItems, childrenByParentUID, rootItems?.items || []); + + if (targets.length > 0) { + setProgress({ + current: 0, + total: targets.length, + item: targets[0].displayName || 'Unknown', + }); + } + + const successes: BulkSuccessResponse = []; + const failures: MoveResultFailed[] = []; + + // Iterate through each selected item and delete it + // We want sequential processing to avoid overwhelming the API + for (let i = 0; i < targets.length; i++) { + const { uid, isFolder, displayName } = targets[i]; + setProgress({ + current: i, + total: targets.length, + item: displayName, + }); + + try { + // get path in repository + const path = await getResourcePath(uid, isFolder); + if (!path) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: t('browse-dashboards.bulk-delete-resources-form.error-path-not-found', 'Path not found'), + }); + continue; + } + + // build params + const deleteParams: DeleteRepositoryFilesWithPathApiArg = { + name: repository.name, + path, + ref: workflow === 'write' ? undefined : data.ref, + message: data.comment || `Delete resource ${path}`, + }; + + // perform delete operation + const response = await deleteRepoFile(deleteParams).unwrap(); + successes.push({ index: i, item: deleteParams, data: response }); + } catch (error: unknown) { + failures.push({ + status: 'failed', + title: `${isFolder ? 'Folder' : 'Dashboard'}: ${displayName}`, + errorMessage: extractErrorMessage(error), + }); + } + + setProgress({ + current: i + 1, + total: targets.length, + item: targets[i + 1]?.displayName, + }); + } + + setProgress(null); + + if (successes.length > 0 && failures.length === 0) { + handleSuccess(successes); + } else if (failures.length > 0) { + setFailureResults(failures); + } + }; + + return ( + +
+ + + + This will delete selected folders and their descendants. In total, this will affect: + + + + + {failureResults && ( + setFailureResults(undefined)} /> + )} + + {progress && } + + + + + + + + +
+
+ ); +} + +export function BulkDeleteProvisionedResource({ + folderUid, + selectedItems, + onDismiss, +}: BulkDeleteProvisionResourceProps) { + const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid }); + + const workflowOptions = getWorkflowOptions(repository); + const isGitHub = repository?.type === 'github'; + const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || ''; + const timestamp = generateTimestamp(); + + const initialValues = { + comment: '', + ref: `bulk-delete/${timestamp}`, + workflow: getDefaultWorkflow(repository), + }; + + if (!repository) { + return null; + } + + return ( + + ); +} diff --git a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx index 01d9216ff5f..454db4db534 100644 --- a/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx +++ b/public/app/features/browse-dashboards/components/DeleteProvisionedFolderForm.test.tsx @@ -30,6 +30,13 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ }, }, }, + provisioningAPIv0alpha1: { + endpoints: { + listRepository: { + select: jest.fn(() => () => ({ data: { items: [] } })), + }, + }, + }, })); jest.mock('../hooks/useProvisionedFolderFormData'); diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx index a983a66ada0..c12c902228c 100644 --- a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx @@ -37,6 +37,13 @@ jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => { jest.mock('app/api/clients/provisioning/v0alpha1', () => { return { useCreateRepositoryFilesWithPathMutation: jest.fn(), + provisioningAPIv0alpha1: { + endpoints: { + listRepository: { + select: jest.fn().mockReturnValue(() => ({ data: { items: [] } })), + }, + }, + }, }; }); diff --git a/public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx b/public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx index 32068d49077..f4b124357bc 100644 --- a/public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx +++ b/public/app/features/browse-dashboards/components/ProvisionedFolderPreviewBanner.tsx @@ -5,7 +5,7 @@ import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequ export function ProvisionedFolderPreviewBanner({ queryParams }: CommonBannerProps) { const provisioningEnabled = config.featureToggles.provisioning; - const { prURL, newPrURL } = usePullRequestParam(); + const { prURL, newPrURL, repoURL } = usePullRequestParam(); if (!provisioningEnabled || 'kiosk' in queryParams) { return null; @@ -19,5 +19,9 @@ export function ProvisionedFolderPreviewBanner({ queryParams }: CommonBannerProp return ; } + if (repoURL) { + return ; + } + return null; } diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index 8f29702bda7..0427ba787a0 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -1,7 +1,12 @@ import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; +import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; +import { DashboardViewItem } from 'app/features/search/types'; -import { DashboardViewItemWithUIItems } from '../types'; +import { useChildrenByParentUIDState } from '../state/hooks'; +import { findItem } from '../state/utils'; +import { DashboardTreeSelection, DashboardViewItemWithUIItems } from '../types'; export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { return baseId + item.uid; @@ -57,3 +62,46 @@ export function formatFolderName(folderName?: string): string { return result; } + +// Fetch provisioned dashboard path in repository +export async function fetchProvisionedDashboardPath(uid: string): Promise { + try { + const dto = await getDashboardAPI().getDashboardDTO(uid); + const sourcePath = + 'meta' in dto + ? dto.meta.k8s?.annotations?.[AnnoKeySourcePath] || dto.meta.provisionedExternalId + : dto.metadata?.annotations?.[AnnoKeySourcePath]; + return `${sourcePath}`; + } catch (error) { + console.error('Error fetching provisioned dashboard path:', error); + return undefined; + } +} + +// Collect selected dashboard and folder from the DashboardTreeSelection +// This is used to prepare the items for bulk delete operation. +export function collectSelectedItems( + selectedItems: Omit, + childrenByParentUID: ReturnType, + rootItems: DashboardViewItem[] = [] +) { + const targets: Array<{ uid: string; isFolder: boolean; displayName: string }> = []; + + // folders + for (const [uid, selected] of Object.entries(selectedItems.folder)) { + if (selected) { + const item = findItem(rootItems, childrenByParentUID, uid); + targets.push({ uid, isFolder: true, displayName: item?.title || uid }); + } + } + + // dashboards + for (const [uid, selected] of Object.entries(selectedItems.dashboard)) { + if (selected) { + const item = findItem(rootItems, childrenByParentUID, uid); + targets.push({ uid, isFolder: false, displayName: item?.title || uid }); + } + } + + return targets; +} diff --git a/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx b/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx index e1da054147f..54eef1b265b 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/PreviewBannerViewPR.tsx @@ -7,14 +7,16 @@ import { commonAlertProps } from './DashboardPreviewBanner'; // TODO: We have this https://github.com/grafana/git-ui-sync-project/issues/166 to add more details about the PR. interface Props { - prParam: string; + prParam?: string; isNewPr?: boolean; + behindBranch?: boolean; + repoUrl?: string; } /** * @description This component is used to display a banner when a provisioned dashboard/folder is created or loaded from a new branch in Github. */ -export function PreviewBannerViewPR({ prParam, isNewPr }: Props) { +export function PreviewBannerViewPR({ prParam, isNewPr, behindBranch, repoUrl }: Props) { const titleText = isNewPr ? t( 'provisioned-resource-preview-banner.title-created-branch-git-hub', @@ -25,6 +27,29 @@ export function PreviewBannerViewPR({ prParam, isNewPr }: Props) { 'This resource is loaded from a pull request in GitHub.' ); + if (behindBranch) { + return ( + + {t('provisioned-resource-preview-banner.preview-banner.open-git-hub', 'Open in Github')} + + + } + title={t( + 'provisioned-resource-preview-banner.preview-banner.behind-branch', + 'This resource is behind the branch in GitHub.' + )} + onRemove={repoUrl ? () => window.open(textUtil.sanitizeUrl(repoUrl), '_blank') : undefined} + > + + View it in GitHub to see the latest changes. + + + ); + } + return ( } - onRemove={() => window.open(textUtil.sanitizeUrl(prParam), '_blank')} + onRemove={prParam ? () => window.open(textUtil.sanitizeUrl(prParam), '_blank') : undefined} > The value is not yet saved in the Grafana database diff --git a/public/app/features/provisioning/hooks/usePullRequestParam.ts b/public/app/features/provisioning/hooks/usePullRequestParam.ts index e4432232900..4934fff1ffc 100644 --- a/public/app/features/provisioning/hooks/usePullRequestParam.ts +++ b/public/app/features/provisioning/hooks/usePullRequestParam.ts @@ -5,9 +5,11 @@ export const usePullRequestParam = () => { const [params] = useUrlParams(); const prParam = params.get('pull_request_url'); const newPrParam = params.get('new_pull_request_url'); + const repoUrl = params.get('repo_url'); return { prURL: prParam ? textUtil.sanitizeUrl(prParam) : undefined, newPrURL: newPrParam ? textUtil.sanitizeUrl(newPrParam) : undefined, + repoURL: repoUrl ? textUtil.sanitizeUrl(repoUrl) : undefined, }; }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e0453c80fb6..bc908e38a00 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3479,7 +3479,6 @@ "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", @@ -3512,6 +3511,22 @@ "browse-view": { "this-folder-is-empty": "This folder is empty" }, + "bulk-action-resources-form": { + "failed-alert_one": "{{count}} item failed", + "failed-alert_other": "{{count}} items failed" + }, + "bulk-delete-resources-form": { + "api-success_one": "Successfully deleted {{count}} item", + "api-success_other": "Successfully deleted {{count}} items", + "button-cancel": "Cancel", + "button-delete": "Delete", + "button-deleting": "Deleting...", + "delete-warning": "This will delete selected folders and their descendants. In total, this will affect:", + "error-path-not-found": "Path not found" + }, + "bulk-move-resources-form": { + "progress": "Progress: {{current}} of {{total}}" + }, "counts": { "alertRule_one": "{{count}} alert rule", "alertRule_other": "{{count}} alert rule", @@ -10521,7 +10536,10 @@ }, "provisioned-resource-preview-banner": { "preview-banner": { + "behind-branch": "This resource is behind the branch in GitHub.", + "new-branch": "View it in GitHub to see the latest changes.", "not-saved": "The value is not yet saved in the Grafana database", + "open-git-hub": "Open in Github", "open-pull-request-in-git-hub": "Open pull request in GitHub", "view-pull-request-in-git-hub": "View pull request in GitHub" },