From 140ca8e213231e421743b2de5c1ea861e145ff07 Mon Sep 17 00:00:00 2001 From: Roberto Jimenez Sanchez Date: Tue, 2 Dec 2025 17:48:49 +0100 Subject: [PATCH] Rename push to export in UI, add Export to Repository actions - Rename BulkPushProvisionedResource to BulkExportProvisionedResource - Change UI terminology from 'push' to 'export' (backend job type remains 'push') - Add 'Export to Repository' action in FolderActionsButton for unmanaged folders - Add 'Export to Repository' option in ShareExport for unmanaged dashboards - Add collectAllDashboardsUnderFolder helper to recursively collect dashboards - Update PullRequestButtons and RepositoryLink to accept 'push' jobType - Update translations from push to export terminology - Update autoPush URL parameter to autoExport --- .../BrowseActions/BrowseActions.tsx | 30 +-- .../components/FolderActionsButton.tsx | 62 +++++- .../components/ShareModal/ShareExport.tsx | 38 +++- .../provisioning/Job/FinishedJobStatus.tsx | 2 +- .../features/provisioning/Job/JobContent.tsx | 2 +- .../features/provisioning/Job/JobStatus.tsx | 2 +- .../Repository/PullRequestButtons.tsx | 2 +- .../Repository/RepositoryLink.tsx | 2 +- .../provisioning/Shared/RepositoryList.tsx | 4 +- ....tsx => BulkExportProvisionedResource.tsx} | 50 ++--- .../BulkActions/useBulkActionJob.ts | 4 +- .../hooks/useAutoSelectUnmanagedDashboards.ts | 202 +++++++++--------- .../utils/collectFolderDashboards.ts | 60 ++++++ public/locales/en-US/grafana.json | 41 ++-- 14 files changed, 331 insertions(+), 170 deletions(-) rename public/app/features/provisioning/components/BulkActions/{BulkPushProvisionedResource.tsx => BulkExportProvisionedResource.tsx} (79%) create mode 100644 public/app/features/provisioning/utils/collectFolderDashboards.ts 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 = ( @@ -147,10 +178,13 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) label={deleteLabel} /> )} + {!isProvisionedFolder && canEditFolders && ( + + )} ); - 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",