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
This commit is contained in:
@@ -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 = (
|
||||
<Button
|
||||
onClick={() => setShowBulkPushProvisionedResource(true)}
|
||||
onClick={() => setShowBulkExportProvisionedResource(true)}
|
||||
variant="secondary"
|
||||
disabled={!hasUnmanaged || isLoadingUnmanaged || !hasSelectedDashboards}
|
||||
>
|
||||
<Trans i18nKey="browse-dashboards.action.push-button">Push</Trans>
|
||||
<Trans i18nKey="browse-dashboards.action.export-button">Export</Trans>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -247,23 +247,23 @@ export function BrowseActions({ folderDTO }: Props) {
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
{/* bulk push */}
|
||||
{showBulkPushProvisionedResource && (
|
||||
{/* bulk export */}
|
||||
{showBulkExportProvisionedResource && (
|
||||
<Drawer
|
||||
title={
|
||||
// Heading levels should only increase by one (a11y)
|
||||
<Text variant="h3" element="h2">
|
||||
{t('browse-dashboards.action.bulk-push-provisioned-resources', 'Bulk Push Resources')}
|
||||
{t('browse-dashboards.action.bulk-export-provisioned-resources', 'Bulk Export Resources')}
|
||||
</Text>
|
||||
}
|
||||
onClose={() => setShowBulkPushProvisionedResource(false)}
|
||||
onClose={() => setShowBulkExportProvisionedResource(false)}
|
||||
size="md"
|
||||
>
|
||||
<BulkPushProvisionedResource
|
||||
<BulkExportProvisionedResource
|
||||
selectedItems={selectedItems}
|
||||
folderUid={folderDTO?.uid}
|
||||
onDismiss={() => {
|
||||
setShowBulkPushProvisionedResource(false);
|
||||
setShowBulkExportProvisionedResource(false);
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
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<string, boolean> = {};
|
||||
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 = (
|
||||
<Menu>
|
||||
@@ -147,10 +178,13 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
label={deleteLabel}
|
||||
/>
|
||||
)}
|
||||
{!isProvisionedFolder && canEditFolders && (
|
||||
<MenuItem onClick={handleExportFolder} label={exportLabel} />
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
if (!canViewPermissions && !canMoveFolder && !canDeleteFolders) {
|
||||
if (!canViewPermissions && !canMoveFolder && !canDeleteFolders && isProvisionedFolder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -213,6 +247,32 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
{showExportFolderDrawer && (
|
||||
<Drawer
|
||||
title={
|
||||
<Text variant="h3" element="h2">
|
||||
{t('browse-dashboards.action.export-folder', 'Export Folder to Repository')}
|
||||
</Text>
|
||||
}
|
||||
subtitle={folder.title}
|
||||
onClose={() => setShowExportFolderDrawer(false)}
|
||||
size="md"
|
||||
>
|
||||
<BulkExportProvisionedResource
|
||||
folderUid={folder.uid}
|
||||
selectedItems={{
|
||||
dashboard: exportSelectedDashboards,
|
||||
folder: {},
|
||||
panel: {},
|
||||
$all: false,
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setShowExportFolderDrawer(false);
|
||||
setExportSelectedDashboards({});
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
||||
<Trans i18nKey="share-modal.export.cancel-button">Cancel</Trans>
|
||||
</Button>
|
||||
{provisioningEnabled && isUnmanaged && (
|
||||
<Button variant="secondary" onClick={() => setShowExportToRepositoryDrawer(true)}>
|
||||
<Trans i18nKey="share-modal.export.export-to-repository-button">Export to Repository</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={onViewJson}>
|
||||
<Trans i18nKey="share-modal.export.view-button">View JSON</Trans>
|
||||
</Button>
|
||||
@@ -94,6 +104,32 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
|
||||
<Trans i18nKey="share-modal.export.save-button">Save to file</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
{showExportToRepositoryDrawer && (
|
||||
<Drawer
|
||||
title={
|
||||
<Text variant="h3" element="h2">
|
||||
{t('share-modal.export.export-to-repository-title', 'Export Dashboard to Repository')}
|
||||
</Text>
|
||||
}
|
||||
subtitle={dashboard.title}
|
||||
onClose={() => setShowExportToRepositoryDrawer(false)}
|
||||
size="md"
|
||||
>
|
||||
<BulkExportProvisionedResource
|
||||
folderUid={dashboard.meta.folderUid}
|
||||
selectedItems={{
|
||||
dashboard: { [dashboard.uid]: true },
|
||||
folder: {},
|
||||
panel: {},
|
||||
$all: false,
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setShowExportToRepositoryDrawer(false);
|
||||
onDismiss?.();
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
+25
-25
@@ -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
|
||||
) : (
|
||||
<>
|
||||
<Box paddingBottom={2}>
|
||||
<Trans i18nKey="browse-dashboards.bulk-push-resources-form.push-total">
|
||||
In total, this will push:
|
||||
<Trans i18nKey="browse-dashboards.bulk-export-resources-form.export-total">
|
||||
In total, this will export:
|
||||
</Trans>
|
||||
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
|
||||
</Box>
|
||||
@@ -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 && (
|
||||
<Alert severity="warning" title={t('browse-dashboards.bulk-push-resources-form.folders-warning', 'Folders cannot be pushed')}>
|
||||
<Alert severity="warning" title={t('browse-dashboards.bulk-export-resources-form.folders-warning', 'Folders cannot be exported')}>
|
||||
{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.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -167,7 +167,7 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
|
||||
{/* Repository selection */}
|
||||
<Field
|
||||
noMargin
|
||||
label={t('browse-dashboards.bulk-push-resources-form.repository', 'Repository')}
|
||||
label={t('browse-dashboards.bulk-export-resources-form.repository', 'Repository')}
|
||||
error={errors.root?.message}
|
||||
invalid={!!errors.root && !selectedRepositoryName}
|
||||
required
|
||||
@@ -181,7 +181,7 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
|
||||
}}
|
||||
isLoading={isLoadingRepos}
|
||||
placeholder={t(
|
||||
'browse-dashboards.bulk-push-resources-form.repository-placeholder',
|
||||
'browse-dashboards.bulk-export-resources-form.repository-placeholder',
|
||||
'Select a repository'
|
||||
)}
|
||||
/>
|
||||
@@ -190,16 +190,16 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
|
||||
{/* Path field */}
|
||||
<Field
|
||||
noMargin
|
||||
label={t('browse-dashboards.bulk-push-resources-form.path', 'Path')}
|
||||
label={t('browse-dashboards.bulk-export-resources-form.path', 'Path')}
|
||||
description={t(
|
||||
'browse-dashboards.bulk-push-resources-form.path-description',
|
||||
'browse-dashboards.bulk-export-resources-form.path-description',
|
||||
'Prefix path in the target repository (optional)'
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
{...methods.register('path')}
|
||||
placeholder={t('browse-dashboards.bulk-push-resources-form.path-placeholder', 'e.g., grafana/')}
|
||||
placeholder={t('browse-dashboards.bulk-export-resources-form.path-placeholder', 'e.g., grafana/')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -217,15 +217,15 @@ function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={isCreatingJob}>
|
||||
<Trans i18nKey="browse-dashboards.bulk-push-resources-form.button-cancel">Cancel</Trans>
|
||||
<Trans i18nKey="browse-dashboards.bulk-export-resources-form.button-cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!!job || isCreatingJob || hasSubmitted || !selectedRepositoryName}
|
||||
>
|
||||
{isCreatingJob
|
||||
? t('browse-dashboards.bulk-push-resources-form.button-pushing', 'Pushing...')
|
||||
: t('browse-dashboards.bulk-push-resources-form.button-push', 'Push')}
|
||||
? t('browse-dashboards.bulk-export-resources-form.button-exporting', 'Exporting...')
|
||||
: t('browse-dashboards.bulk-export-resources-form.button-export', 'Export')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
@@ -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 (
|
||||
<FormContent
|
||||
selectedItems={selectedItems}
|
||||
@@ -24,7 +24,7 @@ export interface MoveJobSpec {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushJobSpec {
|
||||
export interface ExportJobSpec {
|
||||
action: 'push';
|
||||
push: {
|
||||
message?: string;
|
||||
@@ -34,7 +34,7 @@ export interface PushJobSpec {
|
||||
};
|
||||
}
|
||||
|
||||
export type BulkJobSpec = DeleteJobSpec | MoveJobSpec | PushJobSpec;
|
||||
export type BulkJobSpec = DeleteJobSpec | MoveJobSpec | ExportJobSpec;
|
||||
|
||||
interface UseBulkActionJobResult {
|
||||
createBulkJob: (
|
||||
|
||||
@@ -17,119 +17,119 @@ import { BrowseDashboardsState } from '../../browse-dashboards/types';
|
||||
* Returns a function that can be called to select all unmanaged dashboards
|
||||
*/
|
||||
export function useAutoSelectUnmanagedDashboards() {
|
||||
const dispatch = useDispatch();
|
||||
const browseState = useSelector((state) => 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<boolean> => {
|
||||
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<boolean> => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string[]> {
|
||||
const dashboardUIDs: string[] = [];
|
||||
const foldersToProcess: string[] = [folderUID];
|
||||
const processedFolders = new Set<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user