diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx
index bcdc073f900..9a156ad3bf4 100644
--- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx
+++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx
@@ -7,7 +7,7 @@ import { Button, Drawer, Stack, Text } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { ManagerKind } from 'app/features/apiserver/types';
import { BulkDeleteProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource';
-import { BulkPushProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkPushProvisionedResource';
+import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
import { useAutoSelectUnmanagedDashboards } from 'app/features/provisioning/hooks/useAutoSelectUnmanagedDashboards';
import { useSelectionProvisioningStatus } from 'app/features/provisioning/hooks/useSelectionProvisioningStatus';
@@ -37,7 +37,7 @@ export interface Props {
export function BrowseActions({ folderDTO }: Props) {
const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false);
const [showBulkMoveProvisionedResource, setShowBulkMoveProvisionedResource] = useState(false);
- const [showBulkPushProvisionedResource, setShowBulkPushProvisionedResource] = useState(false);
+ const [showBulkExportProvisionedResource, setShowBulkExportProvisionedResource] = useState(false);
const dispatch = useDispatch();
const selectedItems = useActionSelectionState();
@@ -58,19 +58,19 @@ export function BrowseActions({ folderDTO }: Props) {
const isSearching = stateManager.hasSearchFilters();
- // Handle autoPush URL parameter
+ // Handle autoExport URL parameter
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
- if (searchParams.get('autoPush') === 'true' && provisioningEnabled) {
+ if (searchParams.get('autoExport') === 'true' && provisioningEnabled) {
// Remove the parameter from URL
- searchParams.delete('autoPush');
+ searchParams.delete('autoExport');
const newSearch = searchParams.toString();
locationService.replace({
pathname: location.pathname,
search: newSearch ? `?${newSearch}` : '',
});
- // Wait for dashboards to load, then auto-select unmanaged dashboards and open push drawer
+ // Wait for dashboards to load, then auto-select unmanaged dashboards and open export drawer
const attemptAutoSelect = async () => {
// Try multiple times with delays to ensure dashboards are loaded
for (let i = 0; i < 5; i++) {
@@ -79,7 +79,7 @@ export function BrowseActions({ folderDTO }: Props) {
}
// Open the drawer after attempting to select
- setShowBulkPushProvisionedResource(true);
+ setShowBulkExportProvisionedResource(true);
};
// Start after a short delay to allow page to render
@@ -185,11 +185,11 @@ export function BrowseActions({ folderDTO }: Props) {
const pushButton = (
);
@@ -247,23 +247,23 @@ export function BrowseActions({ folderDTO }: Props) {
)}
- {/* bulk push */}
- {showBulkPushProvisionedResource && (
+ {/* bulk export */}
+ {showBulkExportProvisionedResource && (
- {t('browse-dashboards.action.bulk-push-provisioned-resources', 'Bulk Push Resources')}
+ {t('browse-dashboards.action.bulk-export-provisioned-resources', 'Bulk Export Resources')}
}
- onClose={() => setShowBulkPushProvisionedResource(false)}
+ onClose={() => setShowBulkExportProvisionedResource(false)}
size="md"
>
- {
- setShowBulkPushProvisionedResource(false);
+ setShowBulkExportProvisionedResource(false);
}}
/>
diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx
index 7c3070c4184..44c036b0ed0 100644
--- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx
+++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx
@@ -7,9 +7,11 @@ import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/u
import { appEvents } from 'app/core/app_events';
import { Permissions } from 'app/core/components/AccessControl/Permissions';
import { RepoType } from 'app/features/provisioning/Wizard/types';
+import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance';
+import { collectAllDashboardsUnderFolder } from 'app/features/provisioning/utils/collectFolderDashboards';
import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository';
import { ShowModalReactEvent } from 'app/types/events';
import { FolderDTO } from 'app/types/folders';
@@ -32,6 +34,8 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
+ const [showExportFolderDrawer, setShowExportFolderDrawer] = useState(false);
+ const [exportSelectedDashboards, setExportSelectedDashboards] = useState>({});
const [moveFolder] = useMoveFolderMutationFacade();
const isProvisionedInstance = useIsProvisionedInstance();
@@ -125,9 +129,36 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
setShowMoveProvisionedFolderDrawer(true);
};
+ const handleExportFolder = async () => {
+ try {
+ // Collect all dashboards under this folder and its children
+ const dashboardUIDs = await collectAllDashboardsUnderFolder(folder.uid);
+
+ // Create selected items object with all dashboards
+ const selectedDashboards: Record = {};
+ dashboardUIDs.forEach((uid) => {
+ selectedDashboards[uid] = true;
+ });
+
+ setExportSelectedDashboards(selectedDashboards);
+ setShowExportFolderDrawer(true);
+ } catch (error) {
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: [
+ t(
+ 'browse-dashboards.folder-actions-button.export-folder-error',
+ 'Error collecting dashboards. Please try again later.'
+ ),
+ ],
+ });
+ }
+ };
+
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
const moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder');
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
+ const exportLabel = t('browse-dashboards.folder-actions-button.export', 'Export to Repository');
const menu = (
);
- if (!canViewPermissions && !canMoveFolder && !canDeleteFolders) {
+ if (!canViewPermissions && !canMoveFolder && !canDeleteFolders && isProvisionedFolder) {
return null;
}
@@ -213,6 +247,32 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
/>
)}
+ {showExportFolderDrawer && (
+
+ {t('browse-dashboards.action.export-folder', 'Export Folder to Repository')}
+
+ }
+ subtitle={folder.title}
+ onClose={() => setShowExportFolderDrawer(false)}
+ size="md"
+ >
+ {
+ setShowExportFolderDrawer(false);
+ setExportSelectedDashboards({});
+ }}
+ />
+
+ )}
>
);
}
diff --git a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx
index 91045044051..fcd92b3916f 100644
--- a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx
+++ b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx
@@ -2,8 +2,10 @@ import { saveAs } from 'file-saver';
import { memo, useState, useMemo } from 'react';
import { Trans, t } from '@grafana/i18n';
-import { Button, Field, Modal, Switch } from '@grafana/ui';
+import { config } from '@grafana/runtime';
+import { Button, Drawer, Field, Modal, Stack, Switch, Text } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
+import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal/DashboardExporter';
import { makeExportableV1 } from 'app/features/dashboard-scene/scene/export/exporters';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
@@ -17,7 +19,10 @@ interface Props extends ShareModalTabProps {}
export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
const [shareExternally, setShareExternally] = useState(false);
+ const [showExportToRepositoryDrawer, setShowExportToRepositoryDrawer] = useState(false);
const exporter = useMemo(() => new DashboardExporter(), []);
+ const provisioningEnabled = config.featureToggles.provisioning;
+ const isUnmanaged = !dashboard.meta.provisioned;
const onShareExternallyChange = () => setShareExternally((prev) => !prev);
@@ -87,6 +92,11 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
+ {provisioningEnabled && isUnmanaged && (
+
+ )}
@@ -94,6 +104,32 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
Save to file
+ {showExportToRepositoryDrawer && (
+
+ {t('share-modal.export.export-to-repository-title', 'Export Dashboard to Repository')}
+
+ }
+ subtitle={dashboard.title}
+ onClose={() => setShowExportToRepositoryDrawer(false)}
+ size="md"
+ >
+ {
+ setShowExportToRepositoryDrawer(false);
+ onDismiss?.();
+ }}
+ />
+
+ )}
>
);
});
diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx
index 15a82efd90b..70c2c315d73 100644
--- a/public/app/features/provisioning/Job/FinishedJobStatus.tsx
+++ b/public/app/features/provisioning/Job/FinishedJobStatus.tsx
@@ -11,7 +11,7 @@ import { JobContent } from './JobContent';
export interface FinishedJobProps {
jobUid: string;
repositoryName: string;
- jobType: 'sync' | 'delete' | 'move' | 'push';
+ jobType: 'sync' | 'delete' | 'move' | 'export';
onStatusChange?: (statusInfo: StepStatusInfo) => void;
}
diff --git a/public/app/features/provisioning/Job/JobContent.tsx b/public/app/features/provisioning/Job/JobContent.tsx
index bd4b560b05c..ed617d0b95e 100644
--- a/public/app/features/provisioning/Job/JobContent.tsx
+++ b/public/app/features/provisioning/Job/JobContent.tsx
@@ -12,7 +12,7 @@ import { StepStatusInfo } from '../Wizard/types';
import { JobSummary } from './JobSummary';
export interface JobContentProps {
- jobType: 'sync' | 'delete' | 'move' | 'push';
+ jobType: 'sync' | 'delete' | 'move' | 'export';
job?: Job;
isFinishedJob?: boolean;
onStatusChange?: (statusInfo: StepStatusInfo) => void;
diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx
index f6d811c3744..9fe77069c57 100644
--- a/public/app/features/provisioning/Job/JobStatus.tsx
+++ b/public/app/features/provisioning/Job/JobStatus.tsx
@@ -9,7 +9,7 @@ import { JobContent } from './JobContent';
export interface JobStatusProps {
watch: Job;
- jobType: 'sync' | 'delete' | 'move' | 'push';
+ jobType: 'sync' | 'delete' | 'move' | 'export';
onStatusChange?: (statusInfo: StepStatusInfo) => void;
}
diff --git a/public/app/features/provisioning/Repository/PullRequestButtons.tsx b/public/app/features/provisioning/Repository/PullRequestButtons.tsx
index 2e61d879b36..29d47fd2077 100644
--- a/public/app/features/provisioning/Repository/PullRequestButtons.tsx
+++ b/public/app/features/provisioning/Repository/PullRequestButtons.tsx
@@ -3,7 +3,7 @@ import { LinkButton, Stack } from '@grafana/ui';
import { RepositoryUrLs } from 'app/api/clients/provisioning/v0alpha1';
interface Props {
- jobType?: 'sync' | 'delete' | 'move';
+ jobType?: 'sync' | 'delete' | 'move' | 'push';
urls?: RepositoryUrLs;
}
export function PullRequestButtons({ urls, jobType }: Props) {
diff --git a/public/app/features/provisioning/Repository/RepositoryLink.tsx b/public/app/features/provisioning/Repository/RepositoryLink.tsx
index fe4dbd4b9c5..d89898464b2 100644
--- a/public/app/features/provisioning/Repository/RepositoryLink.tsx
+++ b/public/app/features/provisioning/Repository/RepositoryLink.tsx
@@ -8,7 +8,7 @@ import { getRepoHrefForProvider } from '../utils/git';
type RepositoryLinkProps = {
name?: string;
- jobType: 'sync' | 'delete' | 'move';
+ jobType: 'sync' | 'delete' | 'move' | 'push';
};
export function RepositoryLink({ name, jobType }: RepositoryLinkProps) {
diff --git a/public/app/features/provisioning/Shared/RepositoryList.tsx b/public/app/features/provisioning/Shared/RepositoryList.tsx
index 54609e0b944..2d1c5731523 100644
--- a/public/app/features/provisioning/Shared/RepositoryList.tsx
+++ b/public/app/features/provisioning/Shared/RepositoryList.tsx
@@ -25,8 +25,8 @@ export function RepositoryList({ items }: Props) {
const { instanceConnected } = checkSyncSettings(items);
const handlePushUnmanaged = () => {
- // Navigate to dashboards page with autoPush parameter
- locationService.push('/dashboards?autoPush=true');
+ // Navigate to dashboards page with autoExport parameter
+ locationService.push('/dashboards?autoExport=true');
};
const getResourceCountSection = () => {
diff --git a/public/app/features/provisioning/components/BulkActions/BulkPushProvisionedResource.tsx b/public/app/features/provisioning/components/BulkActions/BulkExportProvisionedResource.tsx
similarity index 79%
rename from public/app/features/provisioning/components/BulkActions/BulkPushProvisionedResource.tsx
rename to public/app/features/provisioning/components/BulkActions/BulkExportProvisionedResource.tsx
index 275a7f69dfc..42671005359 100644
--- a/public/app/features/provisioning/components/BulkActions/BulkPushProvisionedResource.tsx
+++ b/public/app/features/provisioning/components/BulkActions/BulkExportProvisionedResource.tsx
@@ -21,7 +21,7 @@ import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFi
import { getDefaultWorkflow, getWorkflowOptions } from '../defaults';
import { generateTimestamp } from '../utils/timestamp';
-import { PushJobSpec, useBulkActionJob } from './useBulkActionJob';
+import { ExportJobSpec, useBulkActionJob } from './useBulkActionJob';
import { BulkActionFormData, BulkActionProvisionResourceProps } from './utils';
interface FormProps extends BulkActionProvisionResourceProps {
@@ -64,37 +64,37 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
// Use a form-level error since 'repository' is not in BulkActionFormData
setError('root', {
type: 'manual',
- message: t('browse-dashboards.bulk-push-resources-form.error-no-repository', 'Please select a repository'),
+ message: t('browse-dashboards.bulk-export-resources-form.error-no-repository', 'Please select a repository'),
});
setHasSubmitted(false);
return;
}
const resources = collectSelectedItems(selectedItems);
- // Filter out folders - only dashboards are supported for push
+ // Filter out folders - only dashboards are supported for export
const dashboardResources = resources.filter((r) => r.kind === 'Dashboard');
if (dashboardResources.length === 0) {
setError('root', {
type: 'manual',
message: t(
- 'browse-dashboards.bulk-push-resources-form.error-no-dashboards',
- 'No dashboards selected. Only dashboards can be pushed.'
+ 'browse-dashboards.bulk-export-resources-form.error-no-dashboards',
+ 'No dashboards selected. Only dashboards can be exported.'
),
});
setHasSubmitted(false);
return;
}
- reportInteraction('grafana_provisioning_bulk_push_submitted', {
+ reportInteraction('grafana_provisioning_bulk_export_submitted', {
workflow: data.workflow,
repositoryName: repositoryView.name ?? 'unknown',
repositoryType: repositoryView.type ?? 'unknown',
resourceCount: dashboardResources.length,
});
- // Create the push job spec
- const jobSpec: PushJobSpec = {
+ // Create the export job spec (backend uses 'push' action)
+ const jobSpec: ExportJobSpec = {
action: 'push',
push: {
message: data.comment || undefined,
@@ -112,7 +112,7 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [
- t('browse-dashboards.bulk-push-resources-form.error-pushing-resources', 'Error pushing resources'),
+ t('browse-dashboards.bulk-export-resources-form.error-exporting-resources', 'Error exporting resources'),
result.error,
],
});
@@ -143,8 +143,8 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
) : (
<>
-
- In total, this will push:
+
+ In total, this will export:
@@ -156,10 +156,10 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
{/* Warn if folders are selected */}
{Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]).length > 0 && (
-
+
{t(
- 'browse-dashboards.bulk-push-resources-form.folders-warning-description',
- 'Only dashboards can be pushed. Folders in your selection will be ignored.'
+ 'browse-dashboards.bulk-export-resources-form.folders-warning-description',
+ 'Only dashboards can be exported. Folders in your selection will be ignored.'
)}
)}
@@ -167,7 +167,7 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
{/* Repository selection */}
@@ -190,16 +190,16 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
{/* Path field */}
@@ -217,15 +217,15 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
>
@@ -236,7 +236,7 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
);
}
-export function BulkPushProvisionedResource({
+export function BulkExportProvisionedResource({
folderUid,
selectedItems,
onDismiss,
@@ -254,12 +254,12 @@ export function BulkPushProvisionedResource({
const initialValues = {
comment: '',
- ref: defaultWorkflow === 'branch' ? `bulk-push/${timestamp}` : (repository?.branch ?? ''),
+ ref: defaultWorkflow === 'branch' ? `bulk-export/${timestamp}` : (repository?.branch ?? ''),
workflow: defaultWorkflow,
path: '',
};
- // Note: We don't require a repository context for push since user selects target repository
+ // Note: We don't require a repository context for export since user selects target repository
return (
state.browseDashboards);
- const [, stateManager] = useSearchStateManager();
- const isSearching = stateManager.hasSearchFilters();
- const provisioningEnabled = config.featureToggles.provisioning;
+ const dispatch = useDispatch();
+ const browseState = useSelector((state) => state.browseDashboards);
+ const [, stateManager] = useSearchStateManager();
+ const isSearching = stateManager.hasSearchFilters();
+ const provisioningEnabled = config.featureToggles.provisioning;
- const findItemInState = useCallback(
- (uid: string, state: BrowseDashboardsState) => {
- const item = findItem(state.rootItems?.items || [], state.childrenByParentUID, uid);
- return item ? { parentUID: item.parentUID, managedBy: item.managedBy } : undefined;
- },
- []
- );
+ const findItemInState = useCallback(
+ (uid: string, state: BrowseDashboardsState) => {
+ const item = findItem(state.rootItems?.items || [], state.childrenByParentUID, uid);
+ return item ? { parentUID: item.parentUID, managedBy: item.managedBy } : undefined;
+ },
+ []
+ );
- const getAllDashboards = useCallback(
- (state: BrowseDashboardsState): DashboardViewItem[] => {
- const dashboards: DashboardViewItem[] = [];
+ const getAllDashboards = useCallback(
+ (state: BrowseDashboardsState): DashboardViewItem[] => {
+ const dashboards: DashboardViewItem[] = [];
- // Helper to recursively collect dashboards from a collection
- const collectDashboards = (collection: typeof state.rootItems, parentUID?: string) => {
- if (!collection) {
- return;
- }
+ // Helper to recursively collect dashboards from a collection
+ const collectDashboards = (collection: typeof state.rootItems, parentUID?: string) => {
+ if (!collection) {
+ return;
+ }
- for (const item of collection.items) {
- if (item.kind === 'dashboard') {
- dashboards.push(item);
- } else if (item.kind === 'folder') {
- // Recursively collect from children
- const children = state.childrenByParentUID[item.uid];
- if (children) {
- collectDashboards(children, item.uid);
+ for (const item of collection.items) {
+ if (item.kind === 'dashboard') {
+ dashboards.push(item);
+ } else if (item.kind === 'folder') {
+ // Recursively collect from children
+ const children = state.childrenByParentUID[item.uid];
+ if (children) {
+ collectDashboards(children, item.uid);
+ }
+ }
+ }
+ };
+
+ // Start from root items
+ collectDashboards(state.rootItems);
+
+ return dashboards;
+ },
+ []
+ );
+
+ const checkDashboardUnmanaged = useCallback(
+ async (dashboard: DashboardViewItem, state: BrowseDashboardsState): Promise => {
+ if (isSearching) {
+ // In search mode, fetch dashboard metadata
+ try {
+ const dto = await getDashboardAPI().getDashboardDTO(dashboard.uid);
+ return !isProvisionedDashboardFromMeta(dto);
+ } catch {
+ return false;
+ }
}
- }
+
+ // Check parent folder first
+ if (dashboard.parentUID) {
+ const parent = findItemInState(dashboard.parentUID, state);
+ if (parent?.managedBy === ManagerKind.Repo) {
+ // If parent is managed, dashboard is managed
+ return false;
+ }
+ }
+
+ // Check dashboard itself
+ const item = findItemInState(dashboard.uid, state);
+ return item?.managedBy !== ManagerKind.Repo;
+ },
+ [isSearching, findItemInState]
+ );
+
+ const selectAllUnmanagedDashboards = useCallback(async () => {
+ if (!provisioningEnabled) {
+ return;
}
- };
- // Start from root items
- collectDashboards(state.rootItems);
+ // Get current state at the time of execution
+ const currentState = browseState;
+ const allDashboards = getAllDashboards(currentState);
- return dashboards;
- },
- []
- );
-
- const checkDashboardUnmanaged = useCallback(
- async (dashboard: DashboardViewItem, state: BrowseDashboardsState): Promise => {
- if (isSearching) {
- // In search mode, fetch dashboard metadata
- try {
- const dto = await getDashboardAPI().getDashboardDTO(dashboard.uid);
- return !isProvisionedDashboardFromMeta(dto);
- } catch {
- return false;
+ if (allDashboards.length === 0) {
+ // No dashboards loaded yet, wait a bit and try again
+ return;
}
- }
- // Check parent folder first
- if (dashboard.parentUID) {
- const parent = findItemInState(dashboard.parentUID, state);
- if (parent?.managedBy === ManagerKind.Repo) {
- // If parent is managed, dashboard is managed
- return false;
+ const unmanagedDashboards: DashboardViewItem[] = [];
+
+ // Check each dashboard to see if it's unmanaged
+ for (const dashboard of allDashboards) {
+ const isUnmanaged = await checkDashboardUnmanaged(dashboard, currentState);
+ if (isUnmanaged) {
+ unmanagedDashboards.push(dashboard);
+ }
}
- }
- // Check dashboard itself
- const item = findItemInState(dashboard.uid, state);
- return item?.managedBy !== ManagerKind.Repo;
- },
- [isSearching, findItemInState]
- );
+ // Select all unmanaged dashboards
+ for (const dashboard of unmanagedDashboards) {
+ dispatch(
+ setItemSelectionState({
+ item: {
+ kind: dashboard.kind,
+ uid: dashboard.uid,
+ parentUID: dashboard.parentUID,
+ managedBy: dashboard.managedBy,
+ },
+ isSelected: true,
+ })
+ );
+ }
+ }, [provisioningEnabled, browseState, getAllDashboards, checkDashboardUnmanaged, dispatch]);
- const selectAllUnmanagedDashboards = useCallback(async () => {
- if (!provisioningEnabled) {
- return;
- }
-
- // Get current state at the time of execution
- const currentState = browseState;
- const allDashboards = getAllDashboards(currentState);
-
- if (allDashboards.length === 0) {
- // No dashboards loaded yet, wait a bit and try again
- return;
- }
-
- const unmanagedDashboards: DashboardViewItem[] = [];
-
- // Check each dashboard to see if it's unmanaged
- for (const dashboard of allDashboards) {
- const isUnmanaged = await checkDashboardUnmanaged(dashboard, currentState);
- if (isUnmanaged) {
- unmanagedDashboards.push(dashboard);
- }
- }
-
- // Select all unmanaged dashboards
- for (const dashboard of unmanagedDashboards) {
- dispatch(
- setItemSelectionState({
- item: {
- kind: dashboard.kind,
- uid: dashboard.uid,
- parentUID: dashboard.parentUID,
- managedBy: dashboard.managedBy,
- },
- isSelected: true,
- })
- );
- }
- }, [provisioningEnabled, browseState, getAllDashboards, checkDashboardUnmanaged, dispatch]);
-
- return selectAllUnmanagedDashboards;
+ return selectAllUnmanagedDashboards;
}
diff --git a/public/app/features/provisioning/utils/collectFolderDashboards.ts b/public/app/features/provisioning/utils/collectFolderDashboards.ts
new file mode 100644
index 00000000000..4606c7b8052
--- /dev/null
+++ b/public/app/features/provisioning/utils/collectFolderDashboards.ts
@@ -0,0 +1,60 @@
+import { DashboardViewItem } from 'app/features/search/types';
+import { listDashboards } from 'app/features/browse-dashboards/api/services';
+
+/**
+ * Recursively collects all dashboards under a folder and its children
+ * @param folderUID - The UID of the folder to collect dashboards from
+ * @returns Array of dashboard UIDs
+ */
+export async function collectAllDashboardsUnderFolder(folderUID: string): Promise {
+ const dashboardUIDs: string[] = [];
+ const foldersToProcess: string[] = [folderUID];
+ const processedFolders = new Set();
+
+ while (foldersToProcess.length > 0) {
+ const currentFolderUID = foldersToProcess.shift()!;
+
+ if (processedFolders.has(currentFolderUID)) {
+ continue;
+ }
+ processedFolders.add(currentFolderUID);
+
+ // Get dashboards directly in this folder
+ let page = 1;
+ const pageSize = 100; // Use a reasonable page size
+ let hasMore = true;
+
+ while (hasMore) {
+ const dashboards = await listDashboards(currentFolderUID, page, pageSize);
+
+ for (const dashboard of dashboards) {
+ dashboardUIDs.push(dashboard.uid);
+ }
+
+ hasMore = dashboards.length === pageSize;
+ page++;
+ }
+
+ // Get child folders and add them to the processing queue
+ // We need to use the search API to find child folders
+ const { getGrafanaSearcher } = await import('@grafana/runtime');
+ const searcher = getGrafanaSearcher();
+
+ const foldersResults = await searcher.search({
+ kind: ['folder'],
+ query: '*',
+ location: currentFolderUID || 'general',
+ limit: 100,
+ });
+
+ for (const folderItem of foldersResults.view) {
+ const folderUID = folderItem.uid;
+ if (folderUID && !processedFolders.has(folderUID)) {
+ foldersToProcess.push(folderUID);
+ }
+ }
+ }
+
+ return dashboardUIDs;
+}
+
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index ae63276e237..db3e39b73cb 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -3551,8 +3551,8 @@
"browse-dashboards": {
"action": {
"bulk-delete-provisioned-resources": "Bulk Delete Provisioned Resources",
+ "bulk-export-provisioned-resources": "Bulk Export Resources",
"bulk-move-provisioned-resources": "Bulk Move Provisioned Resources",
- "bulk-push-provisioned-resources": "Bulk Push Resources",
"cancel-button": "Cancel",
"confirmation-text": "Delete",
"delete-button": "Delete",
@@ -3564,6 +3564,8 @@
"delete-modal-title": "Delete",
"delete-provisioned-folder": "Delete provisioned folder",
"deleting": "Deleting...",
+ "export-button": "Export",
+ "export-folder": "Export Folder to Repository",
"manage-permissions-button": "Manage permissions",
"move-button": "Move",
"move-modal-alert": "Moving this item may change its permissions.",
@@ -3574,7 +3576,6 @@
"move-provisioned-folder": "Move provisioned folder",
"moving": "Moving...",
"new-folder-name-required-phrase": "Folder name is required.",
- "push-button": "Push",
"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"
},
@@ -3595,6 +3596,22 @@
"delete-warning": "This will delete selected folders and their descendants. In total, this will affect:",
"error-deleting-resources": "Error deleting resources"
},
+ "bulk-export-resources-form": {
+ "button-cancel": "Cancel",
+ "button-export": "Export",
+ "button-exporting": "Exporting...",
+ "error-exporting-resources": "Error exporting resources",
+ "error-no-dashboards": "No dashboards selected. Only dashboards can be exported.",
+ "error-no-repository": "Please select a repository",
+ "export-total": "In total, this will export:",
+ "folders-warning": "Folders cannot be exported",
+ "folders-warning-description": "Only dashboards can be exported. Folders in your selection will be ignored.",
+ "path": "Path",
+ "path-description": "Prefix path in the target repository (optional)",
+ "path-placeholder": "e.g., grafana/",
+ "repository": "Repository",
+ "repository-placeholder": "Select a repository"
+ },
"bulk-move-resources-form": {
"button-cancel": "Cancel",
"button-move": "Move",
@@ -3613,22 +3630,6 @@
"move-warning-tooltip": "You can only move provisioned resources within their provisioned folder, and local resources to local folders.",
"target-folder": "Target Folder"
},
- "bulk-push-resources-form": {
- "button-cancel": "Cancel",
- "button-push": "Push",
- "button-pushing": "Pushing...",
- "error-no-dashboards": "No dashboards selected. Only dashboards can be pushed.",
- "error-no-repository": "Please select a repository",
- "error-pushing-resources": "Error pushing resources",
- "folders-warning": "Folders cannot be pushed",
- "folders-warning-description": "Only dashboards can be pushed. Folders in your selection will be ignored.",
- "path": "Path",
- "path-description": "Prefix path in the target repository (optional)",
- "path-placeholder": "e.g., grafana/",
- "push-total": "In total, this will push:",
- "repository": "Repository",
- "repository-placeholder": "Select a repository"
- },
"counts": {
"alertRule_one": "{{count}} alert rule",
"alertRule_other": "{{count}} alert rules",
@@ -3676,6 +3677,8 @@
"folder-actions-button": {
"delete": "Delete this folder",
"delete-folder-error": "Error deleting folder. Please try again later.",
+ "export": "Export to Repository",
+ "export-folder-error": "Error collecting dashboards. Please try again later.",
"folder-actions": "Folder actions",
"manage-permissions": "Manage permissions",
"move": "Move this folder"
@@ -12751,6 +12754,8 @@
"export": {
"back-button": "Back to export config",
"cancel-button": "Cancel",
+ "export-to-repository-button": "Export to Repository",
+ "export-to-repository-title": "Export Dashboard to Repository",
"info-text": "Export this dashboard.",
"loading": "Loading...",
"save-button": "Save to file",