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