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 (
+
+
+
+ );
+}
+
+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"
},