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:
@@ -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'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'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;
|
||||
|
||||
+50
-9
@@ -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({
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user