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:
Roberto Jimenez Sanchez
2025-12-02 17:48:49 +01:00
parent 22231fc2ab
commit 140ca8e213
14 changed files with 331 additions and 170 deletions
@@ -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 = () => {
@@ -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;
}
+23 -18
View File
@@ -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",