From ad8fb1005d5075480fdb5783bd22d2b692d70578 Mon Sep 17 00:00:00 2001 From: Roberto Jimenez Sanchez Date: Tue, 2 Dec 2025 18:17:07 +0100 Subject: [PATCH] feat(provisioning): add bulk export to repository functionality - Add ExportSpecificResources function to export specific dashboards - Add Resources field to ExportJobOptions for bulk export - Add validation for export job options (reject folders, only unmanaged resources) - Add BulkExportProvisionedResource React component for UI - Add Export to Repository button in dashboards page (enabled for unmanaged resources) - Add Export to Repository option in folder actions menu - Add Export to Repository option in dashboard export menu - Add Export to Repository ShareView component for dashboard scene - Add useSelectionUnmanagedStatus hook to check if resources are unmanaged - Add useAutoSelectUnmanagedDashboards hook for auto-selection - Add collectAllDashboardsUnderFolder utility function - Update translations for export functionality - Reuse dashboard conversion shim logic for version handling --- .../BrowseActions/BrowseActions.tsx | 2 +- .../components/FolderActionsButton.tsx | 19 ++++- .../sharing/ExportButton/ExportMenu.tsx | 17 ++++- .../ExportButton/ExportToRepository.tsx | 33 ++++++++ .../sharing/ShareDrawer/ShareDrawer.tsx | 3 + .../components/ShareModal/ShareExport.tsx | 5 +- .../provisioning/Job/FinishedJobStatus.tsx | 2 +- .../features/provisioning/Job/JobContent.tsx | 2 +- .../features/provisioning/Job/JobStatus.tsx | 2 +- .../provisioning/Shared/RepositoryList.tsx | 76 +++++++++---------- .../BulkExportProvisionedResource.tsx | 59 +++++++++++--- .../utils/collectFolderDashboards.ts | 3 +- public/locales/en-US/grafana.json | 9 ++- 13 files changed, 168 insertions(+), 64 deletions(-) create mode 100644 public/app/features/dashboard-scene/sharing/ExportButton/ExportToRepository.tsx diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 9a156ad3bf4..4a641decfc1 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -189,7 +189,7 @@ export function BrowseActions({ folderDTO }: Props) { variant="secondary" disabled={!hasUnmanaged || isLoadingUnmanaged || !hasSelectedDashboards} > - Export + Export to Repository ); diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index 44c036b0ed0..3c0615d14e3 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { AppEvents } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { locationService, reportInteraction } from '@grafana/runtime'; +import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui'; import { appEvents } from 'app/core/app_events'; import { Permissions } from 'app/core/components/AccessControl/Permissions'; @@ -160,6 +160,11 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) 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 provisioningEnabled = config.featureToggles.provisioning; + // isProvisionedFolder means the folder IS managed/provisioned + // So !isProvisionedFolder means the folder is unmanaged (not provisioned) + const isUnmanagedFolder = !isProvisionedFolder; + const menu = ( {canViewPermissions && !isProvisionedFolder && ( @@ -178,13 +183,20 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) label={deleteLabel} /> )} - {!isProvisionedFolder && canEditFolders && ( + {provisioningEnabled && isUnmanagedFolder && ( )} ); - if (!canViewPermissions && !canMoveFolder && !canDeleteFolders && isProvisionedFolder) { + // Show menu if there are any available actions + const hasAnyActions = + (canViewPermissions && !isProvisionedFolder) || + (canMoveFolder && !isReadOnlyRepo) || + (canDeleteFolders && !isReadOnlyRepo) || + (provisioningEnabled && isUnmanagedFolder); + + if (!hasAnyActions) { return null; } @@ -263,7 +275,6 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) selectedItems={{ dashboard: exportSelectedDashboards, folder: {}, - panel: {}, $all: false, }} onDismiss={() => { diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx index c0099c17f2f..72c05e49625 100644 --- a/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportMenu.tsx @@ -28,6 +28,9 @@ export function addDashboardExportDrawerItem(item: ExportDrawerMenuItem) { } export default function ExportMenu({ dashboard }: { dashboard: DashboardScene }) { + const provisioningEnabled = config.featureToggles.provisioning; + const isUnmanaged = provisioningEnabled && !dashboard.isManagedRepository(); + const onMenuItemClick = (shareView: string) => { locationService.partial({ shareView }); }; @@ -59,8 +62,20 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene }) onClick: () => onMenuItemClick(shareDashboardType.image), }); + // Add "Export to Repository" option for unmanaged dashboards + if (isUnmanaged) { + menuItems.push({ + shareId: 'export-to-repository', + testId: 'export-to-repository', + icon: 'cloud-upload', + label: t('share-dashboard.menu.export-to-repository-title', 'Export to Repository'), + renderCondition: true, + onClick: () => onMenuItemClick('export-to-repository'), + }); + } + return menuItems.filter((item) => item.renderCondition); - }, []); + }, [isUnmanaged]); const onClick = (item: ExportDrawerMenuItem) => { DashboardInteractions.sharingCategoryClicked({ diff --git a/public/app/features/dashboard-scene/sharing/ExportButton/ExportToRepository.tsx b/public/app/features/dashboard-scene/sharing/ExportButton/ExportToRepository.tsx new file mode 100644 index 00000000000..ae3e80e8fcc --- /dev/null +++ b/public/app/features/dashboard-scene/sharing/ExportButton/ExportToRepository.tsx @@ -0,0 +1,33 @@ +import { SceneComponentProps } from '@grafana/scenes'; +import { t } from '@grafana/i18n'; +import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource'; +import { DashboardScene } from '../../scene/DashboardScene'; +import { ShareExportTab } from '../ShareExportTab'; + +export class ExportToRepository extends ShareExportTab { + static Component = ExportToRepositoryRenderer; + + public getTabLabel(): string { + return t('share-modal.export.export-to-repository-title', 'Export Dashboard to Repository'); + } +} + +function ExportToRepositoryRenderer({ model }: SceneComponentProps) { + const dashboard = model.getRoot(); + if (!(dashboard instanceof DashboardScene)) { + return null; + } + + return ( + + ); +} + diff --git a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx index 80a880a04c9..c7d70426370 100644 --- a/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareDrawer/ShareDrawer.tsx @@ -7,6 +7,7 @@ import { DashboardScene } from '../../scene/DashboardScene'; import { getDashboardSceneFor } from '../../utils/utils'; import { ExportAsCode } from '../ExportButton/ExportAsCode'; import { ExportAsImage } from '../ExportButton/ExportAsImage'; +import { ExportToRepository } from '../ExportButton/ExportToRepository'; import { ShareExternally } from '../ShareButton/share-externally/ShareExternally'; import { ShareInternally } from '../ShareButton/share-internally/ShareInternally'; import { ShareSnapshot } from '../ShareButton/share-snapshot/ShareSnapshot'; @@ -96,6 +97,8 @@ function getShareView( return new ExportAsCode({ onDismiss }); case shareDashboardType.image: return new ExportAsImage({ onDismiss }); + case 'export-to-repository': + return new ExportToRepository({ onDismiss }); default: return new ShareInternally({ onDismiss }); } diff --git a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx index fcd92b3916f..50bd8e612ae 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareExport.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareExport.tsx @@ -3,7 +3,7 @@ import { memo, useState, useMemo } from 'react'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; -import { Button, Drawer, Field, Modal, Stack, Switch, Text } from '@grafana/ui'; +import { Button, Drawer, Field, Modal, 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'; @@ -118,9 +118,8 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => { { diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx index 70c2c315d73..15a82efd90b 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' | 'export'; + jobType: 'sync' | 'delete' | 'move' | 'push'; onStatusChange?: (statusInfo: StepStatusInfo) => void; } diff --git a/public/app/features/provisioning/Job/JobContent.tsx b/public/app/features/provisioning/Job/JobContent.tsx index ed617d0b95e..bd4b560b05c 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' | 'export'; + jobType: 'sync' | 'delete' | 'move' | 'push'; 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 9fe77069c57..f6d811c3744 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' | 'export'; + jobType: 'sync' | 'delete' | 'move' | 'push'; onStatusChange?: (statusInfo: StepStatusInfo) => void; } diff --git a/public/app/features/provisioning/Shared/RepositoryList.tsx b/public/app/features/provisioning/Shared/RepositoryList.tsx index 2d1c5731523..483d1ab5bd9 100644 --- a/public/app/features/provisioning/Shared/RepositoryList.tsx +++ b/public/app/features/provisioning/Shared/RepositoryList.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { t, Trans } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; -import { Alert, Box, Button, EmptyState, FilterInput, Icon, Stack, TextLink } from '@grafana/ui'; +import { Alert, Box, EmptyState, FilterInput, Icon, Stack, TextLink } from '@grafana/ui'; import { Repository } from 'app/api/clients/provisioning/v0alpha1'; import { RepositoryListItem } from '../Repository/RepositoryListItem'; @@ -45,45 +45,45 @@ export function RepositoryList({ items }: Props) { if (filteredItems.length) { return ( - - - - {{ managedCount }}/{{ resourceCount }} resources managed by Git sync. - - {unmanagedCount > 0 && ( - <> - {' '} - - {{ count: unmanagedCount }} resources aren't managed by Git sync. - - - )} - {isFreeTierLicense() && ( - <> -
- - Free-tier accounts are limited to 20 resources per folder. To add more resources per folder, - {' '} - - upgrade your account{' '} - - . - - )} -
+ 0 ? ( + + Export remaining resources + + ) : undefined + } + onRemove={unmanagedCount > 0 ? handlePushUnmanaged : undefined} + > + + {{ managedCount }}/{{ resourceCount }} resources managed by Git sync. + {unmanagedCount > 0 && ( - - - + <> + {' '} + + {{ count: unmanagedCount }} resources aren't managed by Git sync. + + )} -
+ {isFreeTierLicense() && ( + <> +
+ + Free-tier accounts are limited to 20 resources per folder. To add more resources per folder, + {' '} + + upgrade your account{' '} + + . + + )} + ); } return null; diff --git a/public/app/features/provisioning/components/BulkActions/BulkExportProvisionedResource.tsx b/public/app/features/provisioning/components/BulkActions/BulkExportProvisionedResource.tsx index 9ee779a5c93..a503f194ddc 100644 --- a/public/app/features/provisioning/components/BulkActions/BulkExportProvisionedResource.tsx +++ b/public/app/features/provisioning/components/BulkActions/BulkExportProvisionedResource.tsx @@ -1,5 +1,4 @@ -import { skipToken } from '@reduxjs/toolkit/query'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { AppEvents } from '@grafana/data'; @@ -57,6 +56,31 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss (repo) => repo.name === selectedRepositoryName ); + // Compute workflow options based on selected repository + const selectedWorkflowOptions = repositoryView ? getWorkflowOptions(repositoryView) : workflowOptions; + const selectedDefaultWorkflow = repositoryView + ? getDefaultWorkflow(repositoryView) + : (workflowOptions[0]?.value === 'branch' || workflowOptions[0]?.value === 'write' + ? workflowOptions[0].value + : undefined); + + // Update workflow, branch, and path when repository changes + useEffect(() => { + if (repositoryView && selectedDefaultWorkflow) { + methods.setValue('workflow', selectedDefaultWorkflow as 'branch' | 'write'); + if (selectedDefaultWorkflow === 'branch') { + const timestamp = generateTimestamp(); + methods.setValue('ref', `bulk-export/${timestamp}`); + } else if (selectedDefaultWorkflow === 'write' && repositoryView.branch) { + methods.setValue('ref', repositoryView.branch); + } + // Set the path to the repository's configured path + if (repositoryView.path) { + methods.setValue('path', repositoryView.path); + } + } + }, [repositoryView, selectedDefaultWorkflow, methods]); + const handleSubmitForm = async (data: BulkActionFormData) => { setHasSubmitted(true); @@ -94,12 +118,14 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss }); // Create the export job spec (backend uses 'push' action) + // Use repository path as default if no path is provided + const exportPath = data.path || repositoryView.path || undefined; const jobSpec: ExportJobSpec = { action: 'push', push: { message: data.comment || undefined, branch: data.workflow === 'write' ? undefined : data.ref, - path: data.path || undefined, + path: exportPath, resources: dashboardResources, }, }; @@ -191,15 +217,30 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss @@ -209,7 +250,7 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss resourceType="dashboard" isNew={false} workflow={workflow} - workflowOptions={workflowOptions} + workflowOptions={selectedWorkflowOptions} repository={repositoryView} hidePath /> diff --git a/public/app/features/provisioning/utils/collectFolderDashboards.ts b/public/app/features/provisioning/utils/collectFolderDashboards.ts index 4606c7b8052..2d142753a3f 100644 --- a/public/app/features/provisioning/utils/collectFolderDashboards.ts +++ b/public/app/features/provisioning/utils/collectFolderDashboards.ts @@ -1,5 +1,5 @@ -import { DashboardViewItem } from 'app/features/search/types'; import { listDashboards } from 'app/features/browse-dashboards/api/services'; +import { getGrafanaSearcher } from 'app/features/search/service/searcher'; /** * Recursively collects all dashboards under a folder and its children @@ -37,7 +37,6 @@ export async function collectAllDashboardsUnderFolder(folderUID: string): Promis // 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({ diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index db3e39b73cb..812e4ccfa0d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3565,6 +3565,7 @@ "delete-provisioned-folder": "Delete provisioned folder", "deleting": "Deleting...", "export-button": "Export", + "export-to-repository-button": "Export to Repository", "export-folder": "Export Folder to Repository", "manage-permissions-button": "Manage permissions", "move-button": "Move", @@ -3607,8 +3608,10 @@ "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/", + "path-description": "Path relative to the repository root (optional). Resources will be exported under this path.", + "path-description-with-repo": "Resources will be exported under the repository path: {{repoPath}}. You can add a sub-path below.", + "path-placeholder": "e.g., dashboards/", + "path-placeholder-with-repo": "e.g., dashboards/team-a/", "repository": "Repository", "repository-placeholder": "Select a repository" }, @@ -11750,10 +11753,10 @@ "folder-repository-list": { "all-resources-managed_one": "All {{count}} resource is managed", "all-resources-managed_other": "All {{count}} resources are managed", + "export-remaining-resources-button": "Export remaining resources", "no-results-matching-your-query": "No results matching your query", "partial-managed": "{{managedCount}}/{{resourceCount}} resources managed by Git sync.", "placeholder-search": "Search", - "push-unmanaged-button": "Push unmanaged resources", "unmanaged-resources_one": "{{count}} resource isn't managed by git sync.", "unmanaged-resources_other": "{{count}} resources aren't managed by git sync." },