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
This commit is contained in:
Roberto Jimenez Sanchez
2025-12-02 18:17:07 +01:00
parent 1d7a7e879c
commit ad8fb1005d
13 changed files with 168 additions and 64 deletions
@@ -189,7 +189,7 @@ export function BrowseActions({ folderDTO }: Props) {
variant="secondary"
disabled={!hasUnmanaged || isLoadingUnmanaged || !hasSelectedDashboards}
>
<Trans i18nKey="browse-dashboards.action.export-button">Export</Trans>
<Trans i18nKey="browse-dashboards.action.export-to-repository-button">Export to Repository</Trans>
</Button>
);
@@ -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 = (
<Menu>
{canViewPermissions && !isProvisionedFolder && (
@@ -178,13 +183,20 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
label={deleteLabel}
/>
)}
{!isProvisionedFolder && canEditFolders && (
{provisioningEnabled && isUnmanagedFolder && (
<MenuItem onClick={handleExportFolder} label={exportLabel} />
)}
</Menu>
);
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={() => {
@@ -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({
@@ -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<ExportToRepository>) {
const dashboard = model.getRoot();
if (!(dashboard instanceof DashboardScene)) {
return null;
}
return (
<BulkExportProvisionedResource
folderUid={dashboard.state.meta.folderUid || ''}
selectedItems={{
dashboard: dashboard.state.uid ? { [dashboard.state.uid]: true } : {},
folder: {},
$all: false,
}}
onDismiss={model.useState().onDismiss}
/>
);
}
@@ -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 });
}
@@ -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) => {
<BulkExportProvisionedResource
folderUid={dashboard.meta.folderUid}
selectedItems={{
dashboard: { [dashboard.uid]: true },
dashboard: dashboard.uid ? { [dashboard.uid]: true } : {},
folder: {},
panel: {},
$all: false,
}}
onDismiss={() => {
@@ -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;
}
@@ -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;
@@ -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;
}
@@ -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 (
<Stack>
<Alert title={''} severity="info">
<Trans
i18nKey="provisioning.folder-repository-list.partial-managed"
values={{ managedCount, resourceCount }}
>
{{ managedCount }}/{{ resourceCount }} resources managed by Git sync.
</Trans>
{unmanagedCount > 0 && (
<>
{' '}
<Trans i18nKey="provisioning.folder-repository-list.unmanaged-resources" count={unmanagedCount}>
{{ count: unmanagedCount }} resources aren&apos;t managed by Git sync.
</Trans>
</>
)}
{isFreeTierLicense() && (
<>
<br />
<Trans i18nKey="provisioning.free-tier-limit.message-connection">
Free-tier accounts are limited to 20 resources per folder. To add more resources per folder,
</Trans>{' '}
<TextLink href={UPGRADE_URL} external>
<Trans i18nKey="provisioning.free-tier-limit.upgrade-link">upgrade your account</Trans>{' '}
</TextLink>
.
</>
)}
</Alert>
<Alert
title={''}
severity="info"
buttonContent={
unmanagedCount > 0 ? (
<Trans i18nKey="provisioning.folder-repository-list.export-remaining-resources-button">
Export remaining resources
</Trans>
) : undefined
}
onRemove={unmanagedCount > 0 ? handlePushUnmanaged : undefined}
>
<Trans
i18nKey="provisioning.folder-repository-list.partial-managed"
values={{ managedCount, resourceCount }}
>
{{ managedCount }}/{{ resourceCount }} resources managed by Git sync.
</Trans>
{unmanagedCount > 0 && (
<Box>
<Button onClick={handlePushUnmanaged} variant="primary">
<Trans i18nKey="provisioning.folder-repository-list.push-unmanaged-button">
Push unmanaged resources
</Trans>
</Button>
</Box>
<>
{' '}
<Trans i18nKey="provisioning.folder-repository-list.unmanaged-resources" count={unmanagedCount}>
{{ count: unmanagedCount }} resources aren&apos;t managed by Git sync.
</Trans>
</>
)}
</Stack>
{isFreeTierLicense() && (
<>
<br />
<Trans i18nKey="provisioning.free-tier-limit.message-connection">
Free-tier accounts are limited to 20 resources per folder. To add more resources per folder,
</Trans>{' '}
<TextLink href={UPGRADE_URL} external>
<Trans i18nKey="provisioning.free-tier-limit.upgrade-link">upgrade your account</Trans>{' '}
</TextLink>
.
</>
)}
</Alert>
);
}
return null;
@@ -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
<Field
noMargin
label={t('browse-dashboards.bulk-export-resources-form.path', 'Path')}
description={t(
'browse-dashboards.bulk-export-resources-form.path-description',
'Prefix path in the target repository (optional)'
)}
description={
repositoryView?.path
? t(
'browse-dashboards.bulk-export-resources-form.path-description-with-repo',
'Resources will be exported under the repository path: {{repoPath}}. You can add a sub-path below.',
{ repoPath: repositoryView.path }
)
: t(
'browse-dashboards.bulk-export-resources-form.path-description',
'Path relative to the repository root (optional). Resources will be exported under this path.'
)
}
>
<Input
type="text"
{...methods.register('path')}
placeholder={t('browse-dashboards.bulk-export-resources-form.path-placeholder', 'e.g., grafana/')}
placeholder={
repositoryView?.path
? t(
'browse-dashboards.bulk-export-resources-form.path-placeholder-with-repo',
'e.g., dashboards/team-a/'
)
: t('browse-dashboards.bulk-export-resources-form.path-placeholder', 'e.g., dashboards/')
}
/>
</Field>
@@ -209,7 +250,7 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
resourceType="dashboard"
isNew={false}
workflow={workflow}
workflowOptions={workflowOptions}
workflowOptions={selectedWorkflowOptions}
repository={repositoryView}
hidePath
/>
@@ -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({
+6 -3
View File
@@ -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."
},