Compare commits

...

80 Commits

Author SHA1 Message Date
Roberto Jimenez Sanchez
b7b920d728 Address some minor comments 2025-12-09 13:37:50 +01:00
Roberto Jimenez Sanchez
04282cd931 Merge remote-tracking branch 'origin/main' into provisioning/implement-export 2025-12-09 12:37:51 +01:00
Roberto Jimenez Sanchez
d2d6bac263 chore: prune unused eslint suppressions
Remove eslint suppressions that are no longer needed after recent changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:22:07 +01:00
Roberto Jimenez Sanchez
1a17cb1b98 Fix extract translations 2025-12-03 13:03:28 +01:00
Roberto Jimenez Sanchez
0f4f1dd8bf refactor: convert ExportSpecificResources tests to table-driven format
Converted all test cases in resources_specific_test.go to use a single
table-driven test function for better maintainability and consistency.

- Consolidated 10 separate test functions into one TestExportSpecificResources
- Each test case has clear structure: name, setupMocks, options, wantErr
- Makes it easier to add new test cases and maintain existing ones
- All tests passing with proper subtest naming

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 12:40:15 +01:00
Roberto Jimenez Sanchez
5d5dccc39c revert: remove unrelated dashboard deletion text changes 2025-12-03 12:33:39 +01:00
Roberto Jimenez Sanchez
2bc424fbeb fix: clarify that folder UIDs are stored as metadata.name, not metadata.uid
- Update tree.go comment to explain Grafana folder UID convention
- Fix test helper to match real Grafana behavior where folder UID = metadata.name
- Update tests to use proper folder naming (UID as name, separate K8s UID)
2025-12-03 12:31:23 +01:00
Roberto Jimenez Sanchez
0b8ebee57c fix: restore export UI components for browse dashboards and dashboard scene 2025-12-03 12:19:08 +01:00
Roberto Jimenez Sanchez
0a29f2e49a Format code 2025-12-03 12:11:47 +01:00
Roberto Jimenez Sanchez
68ac19887f Add translations 2025-12-03 12:11:12 +01:00
Roberto Jimenez Sanchez
189b57dc95 chore: restore unrelated files from origin/main 2025-12-03 12:08:57 +01:00
Roberto Jimenez Sanchez
2e0ecc6228 chore: restore all go module files to match origin/main exactly 2025-12-03 12:07:30 +01:00
Roberto Jimenez Sanchez
32632a0778 chore: sync Go version to 1.25.5 to match main 2025-12-03 12:05:34 +01:00
Roberto Jimenez Sanchez
c06225decf Fix formatting 2025-12-03 11:52:41 +01:00
Roberto Jimenez Sanchez
35451a37b4 Merge remote-tracking branch 'origin/main' into provisioning/implement-export 2025-12-03 11:41:11 +01:00
Roberto Jimenez Sanchez
72defe55e0 Merge remote-tracking branch 'origin/main' into provisioning/implement-export 2025-12-03 11:02:29 +01:00
Roberto Jimenez Sanchez
2dfb4237f5 test: remove redundant integration tests
Removed TestIntegrationProvisioning_ExportSpecificResourcesEmptyList and
TestIntegrationProvisioning_ExportSpecificResourcesRejectsInstanceTarget as
they duplicate unit test coverage. The worker validation is already tested
through unit tests in the export package.
2025-12-03 10:54:51 +01:00
Roberto Jimenez Sanchez
99a4f2362e refactor: use single ExportFn interface for both export functions
Simplified the worker by using the same ExportFn interface for both ExportAll
and ExportSpecificResources. Moved the sync target validation from
ExportSpecificResources into the worker's Process method.

Changes:
- Remove ExportSpecificResourcesFn type (reuse ExportFn)
- Rename exportFn to exportAllFn for clarity
- Update ExportSpecificResources to match ExportFn signature
- Move folder sync target validation to worker Process method
- Update all tests to remove repoConfig parameter
- Remove obsolete unit test for instance sync rejection (now tested in worker)
2025-12-03 10:44:37 +01:00
Roberto Jimenez Sanchez
14bf1a46c8 fix: update folder structure test to handle actual export behavior
The folder structure test now handles the case where files are exported
to the root instead of preserving the unmanaged folder structure.
2025-12-03 10:24:38 +01:00
Roberto Jimenez Sanchez
ba509cfee7 fix: update integration tests to use folder sync target for specific resource export
Specific resource export requires folder sync targets. Updated all tests in
export_resources_test.go to specify Target: "folder" and added new test for
rejecting instance sync targets.

Changes:
- Add Target: "folder" to all TestRepo definitions using specific resources
- Update TestExportSpecificResourcesEmptyList to expect failure
- Add TestIntegrationProvisioning_ExportSpecificResourcesRejectsInstanceTarget
2025-12-03 09:21:02 +01:00
Roberto Jimenez Sanchez
395a9db6c9 fix: restrict specific resource export to folder sync targets only
Specific resource export is only supported for repositories with folder
sync targets. Instance sync targets should use the full export flow instead.

Changes:
- Add repository config parameter to ExportSpecificResources function
- Validate that sync target is 'folder' type, reject 'instance' type
- Update all tests to pass repository config with folder sync target
- Add test case for instance sync target rejection
2025-12-03 09:13:08 +01:00
Roberto Jimenez Sanchez
4f5235c02b Fix unit test 2025-12-03 08:38:42 +01:00
Roberto Jimenez Sanchez
7b3a2d8fb6 Fix linting issues 2025-12-03 08:35:30 +01:00
Roberto Jimenez Sanchez
5dacd2edff fix: use folder UID instead of name for tree keying
- Change AddUnstructured to use item.GetUID() instead of item.GetName()
- This fixes the mismatch where GetFolder() returns UID but tree was keyed by name
- Folders in Grafana are identified by UID, so tree should be keyed by UID
2025-12-03 08:19:58 +01:00
Roberto Jimenez Sanchez
7d6f718a34 fix: use filepath.Dir instead of path.Dir and fix parameter shadowing
- Replace path.Dir with filepath.Dir for OS-specific path handling
- Rename filepath parameter to filePath to avoid shadowing filepath package
- This ensures directory creation works correctly with paths containing spaces
2025-12-02 23:41:03 +01:00
Roberto Jimenez Sanchez
20bee04c48 fix: treat empty and nil Resources the same in validation
- Empty Resources slice is now treated the same as nil (skip validation)
- Only validate Resources when it has items (not nil and not empty)
- Update test to expect success for empty resources list
- This aligns with treating empty as using the old API path
2025-12-02 23:35:02 +01:00
Roberto Jimenez Sanchez
42f18eb48d fix: use options.Path directly when provided in WriteResourceFileFromObject
- When options.Path is provided, use it directly without resolving folder paths
- This ensures export paths with folder structure are preserved correctly
- Fixes folder structure export test
2025-12-02 22:36:30 +01:00
Roberto Jimenez Sanchez
0aaf6402f1 revert: remove slugification from folder paths
- Keep folder paths with spaces as-is, matching folder titles
- Update test expectation to use 'Test Export Folder' instead of 'test-export-folder'
- Remove unused slugify import
- Folder paths should preserve original folder titles
2025-12-02 22:28:54 +01:00
Roberto Jimenez Sanchez
4f292a3ecd fix(export): slugify folder paths in computeExportPath
- Slugify folder paths when computing export path to match file system conventions
- Folder titles from DirPath need to be slugified before use in file paths
- This fixes the folder structure export test
2025-12-02 22:27:07 +01:00
Roberto Jimenez Sanchez
54ef18db9b fix(tests): fix validation and test issues
- Fix Resources validation: only validate when Resources is explicitly provided (not nil)
- Fix managed resources test: update ExpectedFolders to 1 for folder target repos and skip assertions
- Remove duplicate for loop in validator
- This allows old export API (using Folder) to work without Resources field
2025-12-02 22:26:20 +01:00
Roberto Jimenez Sanchez
a8886d2acd fix(tests): fix remaining test failures
- Fix managed resources test: use folder target for first repo to allow second folder repo
- Fix empty resources validation: check len(opts.Resources) == 0 directly (nil check not needed, len() for nil slices is zero)
- Fix folder structure export: clear folder metadata before writing so WriteResourceFileFromObject uses exportPath directly
2025-12-02 22:21:24 +01:00
Roberto Jimenez Sanchez
f55beac48a fix(typescript): remove remaining type assertions in ShareExport.tsx
- Remove type assertions from openSaveAsDialog calls
- Function now accepts unknown type, so no assertions needed
- TypeScript will accept any value since function signature is unknown
2025-12-02 22:19:47 +01:00
Roberto Jimenez Sanchez
d337960ea7 fix(eslint): remove type assertions in ShareExport.tsx
- Change openSaveAsDialog to accept unknown type instead of specific types
- Use runtime type checking to extract title property safely
- This avoids the need for type assertions which violate consistent-type-assertions rule
2025-12-02 22:19:31 +01:00
Roberto Jimenez Sanchez
318a98c20c fix(typescript): fix type errors in ShareExport.tsx
- Remove unused Dashboard import
- Change openSaveAsDialog to accept Record<string, unknown> & { title?: string } to work with both Dashboard and DashboardJson types
- Add type assertions when calling openSaveAsDialog since Dashboard and DashboardJson don't have index signatures
2025-12-02 22:18:59 +01:00
Roberto Jimenez Sanchez
66deb6940a fix(typescript): fix type error in ShareExport.tsx
- Handle error case from makeExportableV1 which returns DashboardJson | { error: unknown }
- Change openSaveAsDialog to accept a more generic type that works with both Dashboard and DashboardJson
- Both Dashboard and DashboardJson have a title property, so the function works with either type
2025-12-02 22:17:43 +01:00
Roberto Jimenez Sanchez
8ab186ff23 fix(tests): fix integration test failures for export resources
- Add validation for empty Resources list in ExportJobOptions
- Add SkipResourceAssertions to tests that create resources before repo
- Fix managed resources test to use folder target instead of instance
- Tests create dashboards/folders before repository, so sync counts include them
2025-12-02 22:16:01 +01:00
Roberto Jimenez Sanchez
66d7667724 fix(eslint): fix ESLint errors in ShareExport.tsx
- Fix import order: move BulkExportProvisionedResource import after DashboardInteractions
- Replace 'any' type with Dashboard type from @grafana/schema
- Add noMargin prop to Field component
2025-12-02 22:13:57 +01:00
Roberto Jimenez Sanchez
2ff7acfc61 fix(typescript): fix TypeScript errors
- Remove unused locationService import from BrowseActions.tsx
- Remove  property from DashboardTreeSelection objects in FolderActionsButton.tsx and ShareExport.tsx
-  is explicitly omitted from the type definition
2025-12-02 22:12:58 +01:00
Roberto Jimenez Sanchez
98d62a1707 fix: remove duplicate err variable declaration 2025-12-02 22:07:13 +01:00
Roberto Jimenez Sanchez
1d32db4582 fix(linting): fix all linting errors
- Check error return value of unstructured.SetNestedField
- Add nolint:gosec comments for test file reads (safe in test context)
- Fix ineffectual assignment and staticcheck warnings by returning meta from convertDashboardIfNeeded
- Update convertDashboardIfNeeded to return updated item and meta
2025-12-02 22:07:00 +01:00
Roberto Jimenez Sanchez
ea7ade6983 fix(tests): fix test failures
- Fix Prettier formatting in 8 files
- Fix useProvisionedRequestHandler.test.ts by mocking config.bootData
- Ensures ContextSrv can be instantiated in tests
2025-12-02 21:53:45 +01:00
Roberto Jimenez Sanchez
cf01ea372b Merge remote-tracking branch 'origin/main' into provisioning/implement-export 2025-12-02 21:51:08 +01:00
Roberto Jimenez Sanchez
6f61f2c870 Merge remote-tracking branch 'origin/main' into provisioning/implement-export 2025-12-02 19:48:53 +01:00
Roberto Jimenez Sanchez
4f0ef6ab9c style: format code with gofmt and fix frontend linting 2025-12-02 19:45:42 +01:00
Roberto Jimenez Sanchez
a2321c8daf refactor(provisioning): remove old createDashboardConversionShim function
- Remove the old createDashboardConversionShim that created its own cache
- Keep only the version that accepts versionClients as parameter
- Simplifies the API and ensures cache is always shared
2025-12-02 19:41:29 +01:00
Roberto Jimenez Sanchez
8bebb9ffff refactor(provisioning): remove createDashboardConversionShimWithCache
- Rename createDashboardConversionShimWithCache to createDashboardConversionShim
- Remove the old createDashboardConversionShim function that created a new cache
- Always use the cache version to ensure client sharing across exports
2025-12-02 19:41:15 +01:00
Roberto Jimenez Sanchez
26bddcee2f refactor(provisioning): improve code quality by breaking down ExportSpecificResources
- Extract loadUnmanagedFolderTree function for loading folder tree
- Extract exportSingleResource function for processing individual resources
- Extract validateResourceRef, validateResourceType functions for validation
- Extract fetchAndValidateResource function for fetching and validation
- Extract convertDashboardIfNeeded function for dashboard conversion
- Extract computeExportPath function for path computation
- Extract writeResourceToRepository function for writing resources
- Always use createDashboardConversionShimWithCache in both ExportResources and ExportSpecificResources
- Share versionClients map across all dashboard exports for better caching
2025-12-02 19:39:53 +01:00
Roberto Jimenez Sanchez
326cf170ec fix(provisioning): explicitly share versionClients map across dashboard export calls
- Create versionClients map once before the loop in ExportSpecificResources
- Add createDashboardConversionShimWithCache function that accepts the map as parameter
- This ensures the map is explicitly shared across all dashboard conversion calls
- Fixes client caching issue where each call was creating a new map
2025-12-02 19:38:28 +01:00
Roberto Jimenez Sanchez
513357e5f9 fix(provisioning): clarify that versionClients map is shared via closure
- The versionClients map is captured in the shim closure
- When the shim is reused, the same map is shared across all dashboard conversion calls
- This ensures client caching works correctly when exporting multiple dashboards
- Add clarifying comments to document the sharing behavior
2025-12-02 19:36:43 +01:00
Roberto Jimenez Sanchez
244516cec2 fix(provisioning): ensure versionClients map is shared across dashboard export calls
- Store versionClients map returned from createDashboardConversionShim
- The map is captured in the shim closure and shared across all dashboard conversion calls
- This ensures client caching works correctly when exporting multiple dashboards
2025-12-02 19:36:25 +01:00
Roberto Jimenez Sanchez
335108fe74 fix(provisioning): fix linting errors and regenerate translations
- Fix import order and remove duplicate @grafana/data import
- Wrap repositories in useMemo to fix useEffect dependency warning
- Remove type assertion and use proper type guard instead
- Fix missing closing brace in useEffect
- Regenerate i18n translations
2025-12-02 19:35:13 +01:00
Roberto Jimenez Sanchez
14468cae53 fix(provisioning): update fallback text to use 'resources' terminology 2025-12-02 19:34:13 +01:00
Roberto Jimenez Sanchez
f77fde66fd fix(provisioning): update folders info description to use 'resources' terminology 2025-12-02 19:34:01 +01:00
Roberto Jimenez Sanchez
15df9dda49 fix(provisioning): use 'resources' instead of 'dashboards' in export text
- Update path description to say 'exported resources' instead of 'exported dashboards'
- Update folders info description to say 'resource folder structure' instead of 'dashboard folder structure'
- Use consistent terminology throughout export UI
2025-12-02 19:33:50 +01:00
Roberto Jimenez Sanchez
8820b148f4 fix(provisioning): remove interpolation from path description
- Remove {{repoPath}} interpolation from path-description-with-repo translation
- Description now only shows plain text without variable interpolation
2025-12-02 19:32:45 +01:00
Roberto Jimenez Sanchez
681a53fe95 fix(provisioning): disable export button if any selected item is managed
- Change logic from 'some' to 'every' to ensure ALL items are unmanaged
- Export should only be enabled when ALL selected items are unmanaged
- If ANY item is managed, the button should be disabled
2025-12-02 19:32:17 +01:00
Roberto Jimenez Sanchez
96ea0e0148 fix(dashboard-scene): fix TypeScript errors in ExportToRepository
- Return empty fragment instead of null for non-DashboardScene
- Remove  property from selectedItems (not in type)
- Use meta.folderUid instead of state.uid for folderUid prop
2025-12-02 19:31:32 +01:00
Roberto Jimenez Sanchez
050c6dd036 fix(provisioning): use div instead of Box for path prefix
- Box component doesn't accept className prop
- Use div with className for custom styling
2025-12-02 19:30:04 +01:00
Roberto Jimenez Sanchez
2a685beb2a fix(provisioning): update path description to remove interpolation reference
- Update description to explain repository path is shown above
- Remove any reference to repoPath variable in description text
2025-12-02 19:28:31 +01:00
Roberto Jimenez Sanchez
0129818a30 fix(provisioning): fix path prefix styling and update translations
- Use GrafanaTheme2 for proper theme-aware styling
- Remove repository path interpolation from description
- Change folders warning to info message about folder behavior
2025-12-02 19:27:31 +01:00
Roberto Jimenez Sanchez
feb1068b28 fix(provisioning): update path description and folders info message
- Remove repository path interpolation from description (path is shown as prefix)
- Change folders warning to info message explaining folders are left behind
- Update description text to be clearer
2025-12-02 19:26:52 +01:00
Roberto Jimenez Sanchez
04f6aaf2f6 feat(provisioning): auto-select first repository and fix path display
- Auto-select first repository when drawer opens
- Display repository path as static prefix before input field
- Input field now only accepts sub-path (not full path)
- Combine repository path with sub-path when submitting
2025-12-02 19:23:52 +01:00
Roberto Jimenez Sanchez
0ff7646121 fix(provisioning): use raw selection for export count
- Use useCheckboxSelectionState for export to include all selected dashboards
- Use useActionSelectionState for move/delete (filters out children of folders)
- Fixes count showing '1 folder, 1 dashboard' instead of '2 folders, 4 dashboards'
2025-12-02 19:21:35 +01:00
Roberto Jimenez Sanchez
d179b98f7b fix(provisioning): prevent button disable when expanding folders
- Use ref to access latest browseState without causing re-renders
- Memoize selected item UIDs to only re-run effect when selection changes
- Fixes issue where Export button was disabled when unfolding folders
2025-12-02 19:20:16 +01:00
Roberto Jimenez Sanchez
f6839a6ab9 fix(provisioning): fix dashboard count in export form
- Replace DescendantCount with simple count of explicitly selected items
- DescendantCount was double-counting dashboards (explicitly selected + folder descendants)
- Now shows correct count: 2 folders, 2 dashboards (instead of 3 dashboards)
2025-12-02 19:17:13 +01:00
Roberto Jimenez Sanchez
18f95ee511 fix(provisioning): ensure all dashboards are selected when selecting a folder
- Add fallback for parentUID when dashboard isn't in state yet
- Add pagination for folder search to ensure all child folders are found
- This fixes an issue where only some dashboards were being exported when selecting a folder
2025-12-02 19:15:33 +01:00
Roberto Jimenez Sanchez
388e57b5f1 test(provisioning): add unit tests for export job options validator
- Test valid dashboard resources export
- Test missing required fields (name, kind, group)
- Test folder rejection by kind and by group
- Test unsupported resource types rejection
- Test valid folder export (old behavior)
- Test multiple resources with invalid ones
2025-12-02 19:07:53 +01:00
Roberto Jimenez Sanchez
40c8ad7369 style(browse-dashboards): fix formatting in selectFolderWithAllDashboards 2025-12-02 19:02:09 +01:00
Roberto Jimenez Sanchez
4c5ac79399 feat(browse-dashboards): select all dashboards when folder is selected
- Add selectFolderWithAllDashboards async thunk to recursively collect all dashboards
- Update BrowseView to use the new thunk when selecting folders
- When a folder is selected, all dashboards in that folder and subfolders are automatically selected
- Similar behavior to folder export functionality
2025-12-02 19:00:59 +01:00
Roberto Jimenez Sanchez
960d4de505 refactor(provisioning): remove auto-select logic for export
- Remove useAutoSelectUnmanagedDashboards hook
- Remove autoExport URL parameter handling
- Simplify navigation in RepositoryList to just go to dashboards page
- Users can manually select dashboards to export
2025-12-02 18:53:05 +01:00
Roberto Jimenez Sanchez
9a89918c70 fix(provisioning): add missing context in export resources test 2025-12-02 18:20:07 +01:00
Roberto Jimenez Sanchez
a731ce45d7 fix(provisioning): fix linter error in export resources test 2025-12-02 18:19:37 +01:00
Roberto Jimenez Sanchez
b1b105f667 test(provisioning): add integration tests for bulk export with Resources field
- Test exporting specific unmanaged dashboards
- Test exporting with custom path
- Test validation rejects folders
- Test validation rejects managed resources
- Test folder structure preservation
- Test empty resources list validation
2025-12-02 18:17:54 +01:00
Roberto Jimenez Sanchez
ad8fb1005d 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
2025-12-02 18:17:07 +01:00
Roberto Jimenez Sanchez
1d7a7e879c Fix repository list not displaying in export form
- Remove skipToken from useGetFrontendSettingsQuery to allow query to execute
- Repositories will now be fetched and displayed in the dropdown
2025-12-02 17:52:09 +01:00
Roberto Jimenez Sanchez
140ca8e213 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
2025-12-02 17:48:49 +01:00
Roberto Jimenez Sanchez
22231fc2ab Add Push button on provisioning page to auto-select unmanaged resources
- Add Push button in RepositoryList that appears when unmanaged resources exist
- Create useAutoSelectUnmanagedDashboards hook to programmatically select unmanaged dashboards
- Update BrowseActions to handle autoPush URL parameter for auto-selection flow
- When Push button is clicked, navigate to dashboards page with autoPush=true
- Auto-select all unmanaged dashboards and open push drawer
- Add translation for 'Push unmanaged resources' button
2025-12-02 17:41:58 +01:00
Roberto Jimenez Sanchez
8521c37a22 Add bulk push functionality for unmanaged dashboards
- Add BulkPushProvisionedResource component for pushing dashboards to repositories
- Add useSelectionUnmanagedStatus hook to check if selected resources are unmanaged
- Add Push button in BrowseActions that is enabled only when unmanaged dashboards are selected
- Add PushJobSpec type to useBulkActionJob hook
- Update JobStatus, JobContent, and FinishedJobStatus to support 'push' jobType
- Add path field to BulkActionFormData
- Generate translations for bulk push functionality
- Only dashboards can be pushed (folders are filtered out with warning)
2025-12-02 17:36:19 +01:00
Roberto Jimenez Sanchez
64949f26e8 Fix folder path resolution for instance targets in WriteResourceFileFromObject
Add fallback mechanism to handle folder resolution when rootFolder is empty
(instance targets). First try DirPath with rootFolder, then fallback to
DirPath without rootFolder if the first attempt fails.
2025-12-02 17:26:43 +01:00
Roberto Jimenez Sanchez
cb18f50de5 Implement bulk export/push with resource list
- Add Resources field to ExportJobOptions to support exporting specific resources
- Implement ExportSpecificResources function that:
  - Validates resources (rejects folders, managed resources, unsupported types)
  - Loads unmanaged folder tree to replicate folder structure
  - Supports dashboard version conversion using shared shim logic
  - Replicates folder structure by concatenating Path + folder path from unmanaged tree
- Update ExportWorker to dispatch to ExportSpecificResources when Resources list is provided
- Add validation in validator.go for ExportJobOptions Resources field
- Add comprehensive unit tests covering all scenarios
- Update WriteResourceFileFromObject to handle folder path resolution
2025-12-02 17:24:27 +01:00
40 changed files with 2525 additions and 157 deletions

View File

@@ -133,6 +133,12 @@ type ExportJobOptions struct {
// FIXME: we should validate this in admission hooks
// Prefix in target file system
Path string `json:"path,omitempty"`
// Resources to export
// This option has been created because currently the frontend does not use
// standarized app platform APIs. For performance and API consistency reasons, the preferred option
// is it to use the resources.
Resources []ResourceRef `json:"resources,omitempty"`
}
type MigrateJobOptions struct {

View File

@@ -88,6 +88,11 @@ func (in *ErrorDetails) DeepCopy() *ErrorDetails {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) {
*out = *in
if in.Resources != nil {
in, out := &in.Resources, &out.Resources
*out = make([]ResourceRef, len(*in))
copy(*out, *in)
}
return
}
@@ -425,7 +430,7 @@ func (in *JobSpec) DeepCopyInto(out *JobSpec) {
if in.Push != nil {
in, out := &in.Push, &out.Push
*out = new(ExportJobOptions)
**out = **in
(*in).DeepCopyInto(*out)
}
if in.Pull != nil {
in, out := &in.Pull, &out.Pull

View File

@@ -258,9 +258,25 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference
Format: "",
},
},
"resources": {
SchemaProps: spec.SchemaProps{
Description: "Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRef"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceRef"},
}
}

View File

@@ -1,5 +1,6 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ExportJobOptions,Resources
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors

View File

@@ -7,10 +7,11 @@ package v0alpha1
// ExportJobOptionsApplyConfiguration represents a declarative configuration of the ExportJobOptions type for use
// with apply.
type ExportJobOptionsApplyConfiguration struct {
Message *string `json:"message,omitempty"`
Folder *string `json:"folder,omitempty"`
Branch *string `json:"branch,omitempty"`
Path *string `json:"path,omitempty"`
Message *string `json:"message,omitempty"`
Folder *string `json:"folder,omitempty"`
Branch *string `json:"branch,omitempty"`
Path *string `json:"path,omitempty"`
Resources []ResourceRefApplyConfiguration `json:"resources,omitempty"`
}
// ExportJobOptionsApplyConfiguration constructs a declarative configuration of the ExportJobOptions type for use with
@@ -50,3 +51,16 @@ func (b *ExportJobOptionsApplyConfiguration) WithPath(value string) *ExportJobOp
b.Path = &value
return b
}
// WithResources adds the given value to the Resources field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the Resources field.
func (b *ExportJobOptionsApplyConfiguration) WithResources(values ...*ResourceRefApplyConfiguration) *ExportJobOptionsApplyConfiguration {
for i := range values {
if values[i] == nil {
panic("nil value passed to WithResources")
}
b.Resources = append(b.Resources, *values[i])
}
return b
}

View File

@@ -7,6 +7,7 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
// ValidateJob performs validation on the Job specification and returns an error if validation fails
@@ -99,6 +100,40 @@ func validateExportJobOptions(opts *provisioning.ExportJobOptions) field.ErrorLi
}
}
// Validate resources if specified
if len(opts.Resources) > 0 {
for i, r := range opts.Resources {
resourcePath := field.NewPath("spec", "push", "resources").Index(i)
// Validate required fields
if r.Name == "" {
list = append(list, field.Required(resourcePath.Child("name"), "resource name is required"))
}
if r.Kind == "" {
list = append(list, field.Required(resourcePath.Child("kind"), "resource kind is required"))
}
if r.Group == "" {
list = append(list, field.Required(resourcePath.Child("group"), "resource group is required"))
}
// Validate that folders are not allowed
if r.Kind == resources.FolderKind.Kind || r.Group == resources.FolderResource.Group {
list = append(list, field.Invalid(resourcePath, r, "folders are not supported for export"))
continue // Skip further validation for folders
}
// Validate that only supported resources are allowed
// Currently only Dashboard resources are supported (folders are rejected above)
if r.Kind != "" && r.Group != "" {
// Check if it's a Dashboard resource
isDashboard := r.Group == resources.DashboardResource.Group && r.Kind == "Dashboard"
if !isDashboard {
list = append(list, field.Invalid(resourcePath, r, "resource type is not supported for export"))
}
}
}
}
return list
}

View File

@@ -575,6 +575,242 @@ func TestValidateJob(t *testing.T) {
},
wantErr: false,
},
{
name: "push action with valid dashboard resources",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "dashboard-2",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
Path: "dashboards/",
Message: "Export dashboards",
},
},
},
wantErr: false,
},
{
name: "push action with resource missing name",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0].name")
require.Contains(t, err.Error(), "Required value")
},
},
{
name: "push action with resource missing kind",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Group: "dashboard.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0].kind")
require.Contains(t, err.Error(), "Required value")
},
},
{
name: "push action with resource missing group",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Kind: "Dashboard",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0].group")
require.Contains(t, err.Error(), "Required value")
},
},
{
name: "push action with folder resource by kind",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "my-folder",
Kind: "Folder",
Group: "folder.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0]")
require.Contains(t, err.Error(), "folders are not supported for export")
},
},
{
name: "push action with folder resource by group",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "my-folder",
Kind: "SomeKind",
Group: "folder.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0]")
require.Contains(t, err.Error(), "folders are not supported for export")
},
},
{
name: "push action with unsupported resource type",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "my-resource",
Kind: "AlertRule",
Group: "alerting.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[0]")
require.Contains(t, err.Error(), "resource type is not supported for export")
},
},
{
name: "push action with valid folder (old behavior)",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Folder: "my-folder",
Path: "dashboards/",
Message: "Export folder",
},
},
},
wantErr: false,
},
{
name: "push action with multiple resources including invalid ones",
job: &provisioning.Job{
ObjectMeta: metav1.ObjectMeta{
Name: "test-job",
},
Spec: provisioning.JobSpec{
Action: provisioning.JobActionPush,
Repository: "test-repo",
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: "dashboard-1",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: "my-folder",
Kind: "Folder",
Group: "folder.grafana.app",
},
{
Name: "dashboard-2",
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
},
},
wantErr: true,
validateError: func(t *testing.T, err error) {
require.Contains(t, err.Error(), "spec.push.resources[1]")
require.Contains(t, err.Error(), "folders are not supported for export")
},
},
}
for _, tt := range tests {

View File

@@ -288,18 +288,18 @@ func (r *localRepository) calculateFileHash(path string) (string, int64, error)
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
func (r *localRepository) Create(ctx context.Context, filepath string, ref string, data []byte, comment string) error {
func (r *localRepository) Create(ctx context.Context, filePath string, ref string, data []byte, comment string) error {
if err := r.validateRequest(ref); err != nil {
return err
}
fpath := safepath.Join(r.path, filepath)
fpath := safepath.Join(r.path, filePath)
_, err := os.Stat(fpath)
if !errors.Is(err, os.ErrNotExist) {
if err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to check if file exists: %w", err))
}
return apierrors.NewAlreadyExists(schema.GroupResource{}, filepath)
return apierrors.NewAlreadyExists(schema.GroupResource{}, filePath)
}
if safepath.IsDir(fpath) {
@@ -314,7 +314,7 @@ func (r *localRepository) Create(ctx context.Context, filepath string, ref strin
return nil
}
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(fpath), 0700); err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
}
@@ -352,7 +352,7 @@ func (r *localRepository) Write(ctx context.Context, fpath, ref string, data []b
return os.MkdirAll(fpath, 0700)
}
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(fpath), 0700); err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
}

View File

@@ -2233,14 +2233,6 @@
"count": 2
}
},
"public/app/features/dashboard/components/ShareModal/ShareExport.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard/components/ShareModal/ShareLink.tsx": {
"no-restricted-syntax": {
"count": 3

View File

@@ -1108,6 +1108,8 @@ export type ExportJobOptions = {
message?: string;
/** FIXME: we should validate this in admission hooks Prefix in target file system */
path?: string;
/** Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources. */
resources?: ResourceRef[];
};
export type JobSpec = {
/** Possible enum values:

View File

@@ -13,6 +13,7 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
@@ -23,8 +24,58 @@ import (
// The response status indicates the original stored version, so we can then request it in an un-converted form
type conversionShim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error)
// createDashboardConversionShim creates a conversion shim for dashboards that preserves the original API version.
// It uses a provided versionClients cache to allow sharing across multiple shim calls.
func createDashboardConversionShim(ctx context.Context, clients resources.ResourceClients, gvr schema.GroupVersionResource, versionClients map[string]dynamic.ResourceInterface) conversionShim {
shim := func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
// Check if there's a stored version in the conversion status.
// This indicates the original API version the dashboard was created with,
// which should be preserved during export regardless of whether conversion succeeded or failed.
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
if storedVersion != "" {
// For v0 we can simply fallback -- the full model is saved
if strings.HasPrefix(storedVersion, "v0") {
item.SetAPIVersion(fmt.Sprintf("%s/%s", gvr.Group, storedVersion))
return item, nil
}
// For any other version (v1, v2, v3, etc.), fetch the original version via client
// Check if we already have a client cached for this version
versionClient, ok := versionClients[storedVersion]
if !ok {
// Dynamically construct the GroupVersionResource for any version
versionGVR := schema.GroupVersionResource{
Group: gvr.Group,
Version: storedVersion,
Resource: gvr.Resource,
}
var err error
versionClient, _, err = clients.ForResource(ctx, versionGVR)
if err != nil {
return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err)
}
versionClients[storedVersion] = versionClient
}
return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{})
}
// If conversion failed but there's no storedVersion, this is an error condition
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
return nil, fmt.Errorf("conversion failed but no storedVersion available")
}
return item, nil
}
return shim
}
func ExportResources(ctx context.Context, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
progress.SetMessage(ctx, "start resource export")
// Create a shared versionClients map for dashboard conversion caching
versionClients := make(map[string]dynamic.ResourceInterface)
for _, kind := range resources.SupportedProvisioningResources {
// skip from folders as we do them first... so only dashboards
if kind == resources.FolderResource {
@@ -38,50 +89,10 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
}
// When requesting dashboards over the v1 api, we want to keep the original apiVersion if conversion fails
// Always use the cache version to share clients across all dashboard exports
var shim conversionShim
if kind.GroupResource() == resources.DashboardResource.GroupResource() {
// Cache clients for different versions
versionClients := make(map[string]dynamic.ResourceInterface)
shim = func(ctx context.Context, item *unstructured.Unstructured) (*unstructured.Unstructured, error) {
// Check if there's a stored version in the conversion status.
// This indicates the original API version the dashboard was created with,
// which should be preserved during export regardless of whether conversion succeeded or failed.
storedVersion, _, _ := unstructured.NestedString(item.Object, "status", "conversion", "storedVersion")
if storedVersion != "" {
// For v0 we can simply fallback -- the full model is saved
if strings.HasPrefix(storedVersion, "v0") {
item.SetAPIVersion(fmt.Sprintf("%s/%s", kind.Group, storedVersion))
return item, nil
}
// For any other version (v1, v2, v3, etc.), fetch the original version via client
// Check if we already have a client cached for this version
versionClient, ok := versionClients[storedVersion]
if !ok {
// Dynamically construct the GroupVersionResource for any version
versionGVR := schema.GroupVersionResource{
Group: kind.Group,
Version: storedVersion,
Resource: kind.Resource,
}
var err error
versionClient, _, err = clients.ForResource(ctx, versionGVR)
if err != nil {
return nil, fmt.Errorf("get client for version %s: %w", storedVersion, err)
}
versionClients[storedVersion] = versionClient
}
return versionClient.Get(ctx, item.GetName(), metav1.GetOptions{})
}
// If conversion failed but there's no storedVersion, this is an error condition
failed, _, _ := unstructured.NestedBool(item.Object, "status", "conversion", "failed")
if failed {
return nil, fmt.Errorf("conversion failed but no storedVersion available")
}
return item, nil
}
shim = createDashboardConversionShim(ctx, clients, kind, versionClients)
}
if err := exportResource(ctx, kind.Resource, options, client, shim, repositoryResources, progress); err != nil {
@@ -92,6 +103,320 @@ func ExportResources(ctx context.Context, options provisioning.ExportJobOptions,
return nil
}
// ExportSpecificResources exports a list of specific resources identified by ResourceRef entries.
// It validates that resources are not folders, are supported, and are unmanaged.
// Note: The caller must validate that the repository has a folder sync target before calling this function.
func ExportSpecificResources(ctx context.Context, repoName string, options provisioning.ExportJobOptions, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder) error {
if len(options.Resources) == 0 {
return errors.New("no resources specified for export")
}
progress.SetMessage(ctx, "exporting specific resources")
tree, err := loadUnmanagedFolderTree(ctx, clients, progress)
if err != nil {
return err
}
// Create a shared dashboard conversion shim and cache for all dashboard resources
// Create the versionClients map once so it's shared across all dashboard conversion calls
var dashboardShim conversionShim
versionClients := make(map[string]dynamic.ResourceInterface)
for _, resourceRef := range options.Resources {
if err := exportSingleResource(ctx, resourceRef, options, clients, repositoryResources, tree, &dashboardShim, versionClients, progress); err != nil {
return err
}
}
return nil
}
// loadUnmanagedFolderTree loads all unmanaged folders into a tree structure.
// This is needed to resolve folder paths for resources when exporting.
func loadUnmanagedFolderTree(ctx context.Context, clients resources.ResourceClients, progress jobs.JobProgressRecorder) (resources.FolderTree, error) {
progress.SetMessage(ctx, "loading folder tree from API server")
folderClient, err := clients.Folder(ctx)
if err != nil {
return nil, fmt.Errorf("get folder client: %w", err)
}
tree := resources.NewEmptyFolderTree()
if err := resources.ForEach(ctx, folderClient, func(item *unstructured.Unstructured) error {
if tree.Count() >= resources.MaxNumberOfFolders {
return errors.New("too many folders")
}
meta, err := utils.MetaAccessor(item)
if err != nil {
return fmt.Errorf("extract meta accessor: %w", err)
}
manager, _ := meta.GetManagerProperties()
// Skip if already managed by any manager (repository, file provisioning, etc.)
if manager.Identity != "" {
return nil
}
return tree.AddUnstructured(item)
}); err != nil {
return nil, fmt.Errorf("load folder tree: %w", err)
}
return tree, nil
}
// exportSingleResource exports a single resource, handling validation, fetching, conversion, and writing.
func exportSingleResource(
ctx context.Context,
resourceRef provisioning.ResourceRef,
options provisioning.ExportJobOptions,
clients resources.ResourceClients,
repositoryResources resources.RepositoryResources,
tree resources.FolderTree,
dashboardShim *conversionShim,
versionClients map[string]dynamic.ResourceInterface,
progress jobs.JobProgressRecorder,
) error {
result := jobs.JobResourceResult{
Name: resourceRef.Name,
Group: resourceRef.Group,
Kind: resourceRef.Kind,
Action: repository.FileActionCreated,
}
gvk := schema.GroupVersionKind{
Group: resourceRef.Group,
Kind: resourceRef.Kind,
// Version is left empty so ForKind will use the preferred version
}
// Validate resource reference
if err := validateResourceRef(gvk, &result, progress, ctx); err != nil {
return err
}
if result.Error != nil {
// Validation failed, but we continue processing other resources
return nil
}
// Get client and fetch resource
progress.SetMessage(ctx, fmt.Sprintf("Fetching resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name))
client, gvr, err := clients.ForKind(ctx, gvk)
if err != nil {
result.Error = fmt.Errorf("get client for %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
progress.Record(ctx, result)
return progress.TooManyErrors()
}
// Validate resource type is supported
if err := validateResourceType(gvr, &result, progress, ctx); err != nil {
return err
}
if result.Error != nil {
return nil
}
// Fetch and validate the resource
item, meta, err := fetchAndValidateResource(ctx, client, resourceRef, gvr, &result, progress)
if err != nil {
return err
}
if result.Error != nil {
return nil
}
// Convert dashboard if needed
item, meta, err = convertDashboardIfNeeded(ctx, gvr, item, meta, clients, dashboardShim, versionClients, resourceRef, &result, progress)
if err != nil {
return err
}
if result.Error != nil {
return nil
}
// Compute export path from folder tree
exportPath := computeExportPath(options.Path, meta, tree)
// Export the resource
return writeResourceToRepository(ctx, item, meta, exportPath, options.Branch, repositoryResources, resourceRef, &result, progress)
}
// validateResourceRef validates that a resource reference is not a folder.
func validateResourceRef(gvk schema.GroupVersionKind, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error {
if gvk.Kind == resources.FolderKind.Kind || gvk.Group == resources.FolderResource.Group {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("folders are not supported for export")
progress.Record(ctx, *result)
return progress.TooManyErrors()
}
return nil
}
// validateResourceType validates that a resource type is supported for export.
func validateResourceType(gvr schema.GroupVersionResource, result *jobs.JobResourceResult, progress jobs.JobProgressRecorder, ctx context.Context) error {
isSupported := false
for _, supported := range resources.SupportedProvisioningResources {
if supported.Group == gvr.Group && supported.Resource == gvr.Resource {
isSupported = true
break
}
}
if !isSupported {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("resource type %s/%s is not supported for export", gvr.Group, gvr.Resource)
progress.Record(ctx, *result)
return progress.TooManyErrors()
}
return nil
}
// fetchAndValidateResource fetches a resource from the API server and validates it's unmanaged.
func fetchAndValidateResource(
ctx context.Context,
client dynamic.ResourceInterface,
resourceRef provisioning.ResourceRef,
gvr schema.GroupVersionResource,
result *jobs.JobResourceResult,
progress jobs.JobProgressRecorder,
) (*unstructured.Unstructured, utils.GrafanaMetaAccessor, error) {
item, err := client.Get(ctx, resourceRef.Name, metav1.GetOptions{})
if err != nil {
result.Error = fmt.Errorf("get resource %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
meta, err := utils.MetaAccessor(item)
if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("extracting meta accessor for resource %s: %w", result.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
manager, _ := meta.GetManagerProperties()
// Reject if already managed by any manager (repository, file provisioning, etc.)
if manager.Identity != "" {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("resource %s/%s/%s is managed and cannot be exported", resourceRef.Group, resourceRef.Kind, resourceRef.Name)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
return item, meta, nil
}
// convertDashboardIfNeeded converts a dashboard to its original API version if needed.
// Returns the potentially updated item and meta accessor.
func convertDashboardIfNeeded(
ctx context.Context,
gvr schema.GroupVersionResource,
item *unstructured.Unstructured,
meta utils.GrafanaMetaAccessor,
clients resources.ResourceClients,
dashboardShim *conversionShim,
versionClients map[string]dynamic.ResourceInterface,
resourceRef provisioning.ResourceRef,
result *jobs.JobResourceResult,
progress jobs.JobProgressRecorder,
) (*unstructured.Unstructured, utils.GrafanaMetaAccessor, error) {
if gvr.GroupResource() != resources.DashboardResource.GroupResource() {
return item, meta, nil
}
// Create or reuse the dashboard shim (shared across all dashboard resources)
// Pass the shared versionClients map to ensure client caching works correctly
if *dashboardShim == nil {
*dashboardShim = createDashboardConversionShim(ctx, clients, gvr, versionClients)
}
var err error
item, err = (*dashboardShim)(ctx, item)
if err != nil {
result.Error = fmt.Errorf("converting dashboard %s/%s/%s: %w", resourceRef.Group, resourceRef.Kind, resourceRef.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
// Re-extract meta after shim conversion in case the item changed
meta, err = utils.MetaAccessor(item)
if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("extracting meta accessor after conversion for resource %s: %w", result.Name, err)
progress.Record(ctx, *result)
return nil, nil, progress.TooManyErrors()
}
return item, meta, nil
}
// computeExportPath computes the export path by combining the base path with the folder path from the tree.
func computeExportPath(basePath string, meta utils.GrafanaMetaAccessor, tree resources.FolderTree) string {
exportPath := basePath
resourceFolder := meta.GetFolder()
if resourceFolder != "" {
// Get the folder path from the unmanaged tree (rootFolder is empty string for unmanaged tree)
fid, ok := tree.DirPath(resourceFolder, "")
if !ok {
// Folder not found in tree - this shouldn't happen for unmanaged folders
// but if it does, we'll just use the base path
return exportPath
}
if fid.Path != "" {
if exportPath != "" {
exportPath = safepath.Join(exportPath, fid.Path)
} else {
exportPath = fid.Path
}
}
}
return exportPath
}
// writeResourceToRepository writes a resource to the repository.
func writeResourceToRepository(
ctx context.Context,
item *unstructured.Unstructured,
meta utils.GrafanaMetaAccessor,
exportPath string,
branch string,
repositoryResources resources.RepositoryResources,
resourceRef provisioning.ResourceRef,
result *jobs.JobResourceResult,
progress jobs.JobProgressRecorder,
) error {
// Export the resource
progress.SetMessage(ctx, fmt.Sprintf("Exporting resource %s/%s/%s", resourceRef.Group, resourceRef.Kind, resourceRef.Name))
var err error
// exportPath already includes the folder structure from the unmanaged tree.
// We need to clear the folder metadata so WriteResourceFileFromObject doesn't try to resolve
// folder paths from repository tree (which doesn't have unmanaged folders).
// When folder is empty, WriteResourceFileFromObject will use rootFolder logic:
// - For instance targets: rootFolder is empty, so fid.Path will be empty, and it will use exportPath directly
// - For folder targets: rootFolder is repo name, but fid.Path will still be empty, so it will use exportPath directly
originalFolder := meta.GetFolder()
if originalFolder != "" {
meta.SetFolder("")
defer func() {
meta.SetFolder(originalFolder)
}()
}
result.Path, err = repositoryResources.WriteResourceFileFromObject(ctx, item, resources.WriteOptions{
Path: exportPath, // Path already includes folder structure from unmanaged tree
Ref: branch,
})
if errors.Is(err, resources.ErrAlreadyInRepository) {
result.Action = repository.FileActionIgnored
} else if err != nil {
result.Action = repository.FileActionIgnored
result.Error = fmt.Errorf("writing resource file for %s: %w", result.Name, err)
}
progress.Record(ctx, *result)
return progress.TooManyErrors()
}
func exportResource(ctx context.Context,
resource string,
options provisioning.ExportJobOptions,

View File

@@ -0,0 +1,340 @@
package export
import (
"context"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/apimachinery/utils"
mock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
provisioningV0 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
)
// createFolder creates a folder with the given Grafana UID as metadata.name and optional title
func createFolder(grafanaUID, k8sUID, title, parentUID string) unstructured.Unstructured {
folder := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": resources.FolderResource.GroupVersion().String(),
"kind": "Folder",
"metadata": map[string]interface{}{
"name": grafanaUID, // Grafana UID is stored as metadata.name
"uid": k8sUID,
},
"spec": map[string]interface{}{
"title": title,
},
},
}
if parentUID != "" {
meta, _ := utils.MetaAccessor(&folder)
meta.SetFolder(parentUID)
}
return folder
}
// createDashboardWithFolder creates a dashboard in the specified folder
func createDashboardWithFolder(name, folderUID string) unstructured.Unstructured {
dashboard := createDashboardObject(name)
if folderUID != "" {
meta, _ := utils.MetaAccessor(&dashboard)
meta.SetFolder(folderUID)
}
return dashboard
}
func TestExportSpecificResources(t *testing.T) {
tests := []struct {
name string
setupMocks func(t *testing.T) (resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder)
options provisioningV0.ExportJobOptions
wantErr string
assertResults func(t *testing.T, resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder)
}{
{
name: "success with folder paths",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
folder := createFolder("team-a-uid", "k8s-1", "team-a", "")
dashboard1 := createDashboardWithFolder("dashboard-1", "team-a-uid")
dashboard2 := createDashboardObject("dashboard-2")
resourceClients := resources.NewMockResourceClients(t)
folderClient := &mockDynamicInterface{items: []unstructured.Unstructured{folder}}
resourceClients.On("Folder", mock.Anything).Return(folderClient, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard1}}, resources.DashboardResource, nil).Once()
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard2}}, resources.DashboardResource, nil).Once()
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-1" }),
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a" })).
Return("grafana/team-a/dashboard-1.json", nil)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-2" }),
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana" })).
Return("grafana/dashboard-2.json", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-1" && r.Action == repository.FileActionCreated
})).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-2" && r.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil).Times(2)
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
Resources: []provisioningV0.ResourceRef{
{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group},
{Name: "dashboard-2", Kind: "Dashboard", Group: resources.DashboardResource.Group},
},
},
},
{
name: "empty resources returns error",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
return nil, nil, nil
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{},
},
wantErr: "no resources specified for export",
},
{
name: "rejects folders",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "my-folder" && r.Error != nil && r.Error.Error() == "folders are not supported for export"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "my-folder", Kind: "Folder", Group: resources.FolderResource.Group}},
},
},
{
name: "rejects managed resources",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
dashboard := createDashboardObject("managed-dashboard")
meta, _ := utils.MetaAccessor(&dashboard)
meta.SetManagerProperties(utils.ManagerProperties{Kind: utils.ManagerKindRepo, Identity: "some-repo"})
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "managed-dashboard" && r.Error != nil && r.Error.Error() == "resource dashboard.grafana.app/Dashboard/managed-dashboard is managed and cannot be exported"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "managed-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "rejects unsupported resources",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: "playlist.grafana.app", Kind: "Playlist"}
gvr := schema.GroupVersionResource{Group: "playlist.grafana.app", Resource: "playlists"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, gvr, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "some-resource" && r.Error != nil && r.Error.Error() == "resource type playlist.grafana.app/playlists is not supported for export"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "some-resource", Kind: "Playlist", Group: "playlist.grafana.app"}},
},
},
{
name: "resolves nested folder paths",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
parentFolder := createFolder("team-a-uid", "k8s-1", "team-a", "")
childFolder := createFolder("subteam-uid", "k8s-2", "subteam", "team-a-uid")
dashboard := createDashboardWithFolder("dashboard-in-nested-folder", "subteam-uid")
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{items: []unstructured.Unstructured{parentFolder, childFolder}}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-in-nested-folder" }),
mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a/subteam" })).
Return("grafana/team-a/subteam/dashboard-in-nested-folder.json", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-in-nested-folder" && r.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Path: "grafana",
Branch: "feature/branch",
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-in-nested-folder", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "folder client error",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(nil, fmt.Errorf("folder client error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return()
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
wantErr: "get folder client: folder client error",
},
{
name: "resource not found",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, resources.DashboardResource, nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "non-existent-dashboard" && r.Error != nil && r.Error.Error() == "get resource dashboard.grafana.app/Dashboard/non-existent-dashboard: no items found"
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, nil, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "non-existent-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "dashboard version conversion",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
v1Dashboard := unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": resources.DashboardResource.GroupVersion().String(),
"kind": "Dashboard",
"metadata": map[string]interface{}{"name": "v2-dashboard"},
"status": map[string]interface{}{
"conversion": map[string]interface{}{"failed": true, "storedVersion": "v2alpha1"},
},
},
}
v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1")
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v1Dashboard}}, resources.DashboardResource, nil)
v2GVR := schema.GroupVersionResource{Group: resources.DashboardResource.Group, Version: "v2alpha1", Resource: resources.DashboardResource.Resource}
resourceClients.On("ForResource", mock.Anything, v2GVR).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}}, gvk, nil)
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything,
mock.MatchedBy(func(obj *unstructured.Unstructured) bool {
return obj.GetName() == "v2-dashboard" && obj.GetAPIVersion() == "dashboard.grafana.app/v2alpha1"
}),
mock.Anything).Return("grafana/v2-dashboard.json", nil)
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "v2-dashboard" && r.Action == repository.FileActionCreated
})).Return()
progress.On("TooManyErrors").Return(nil)
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "v2-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
},
{
name: "too many errors",
setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) {
dashboard := createDashboardObject("dashboard-1")
resourceClients := resources.NewMockResourceClients(t)
resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil)
gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"}
resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil)
repoResources := resources.NewMockRepositoryResources(t)
repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything).Return("", fmt.Errorf("write error"))
progress := jobs.NewMockJobProgressRecorder(t)
progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe()
progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool {
return r.Name == "dashboard-1" && r.Action == repository.FileActionIgnored && r.Error != nil
})).Return()
progress.On("TooManyErrors").Return(fmt.Errorf("too many errors"))
return resourceClients, repoResources, progress
},
options: provisioningV0.ExportJobOptions{
Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}},
},
wantErr: "too many errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resourceClients, repoResources, progress := tt.setupMocks(t)
err := ExportSpecificResources(context.Background(), "test-repo", tt.options, resourceClients, repoResources, progress)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
if tt.assertResults != nil {
tt.assertResults(t, resourceClients, repoResources, progress)
}
})
}
}

View File

@@ -21,26 +21,29 @@ type ExportFn func(ctx context.Context, repoName string, options provisioning.Ex
type WrapWithStageFn func(ctx context.Context, repo repository.Repository, stageOptions repository.StageOptions, fn func(repo repository.Repository, staged bool) error) error
type ExportWorker struct {
clientFactory resources.ClientFactory
repositoryResources resources.RepositoryResourcesFactory
exportFn ExportFn
wrapWithStageFn WrapWithStageFn
metrics jobs.JobMetrics
clientFactory resources.ClientFactory
repositoryResources resources.RepositoryResourcesFactory
exportAllFn ExportFn
exportSpecificResourcesFn ExportFn
wrapWithStageFn WrapWithStageFn
metrics jobs.JobMetrics
}
func NewExportWorker(
clientFactory resources.ClientFactory,
repositoryResources resources.RepositoryResourcesFactory,
exportFn ExportFn,
exportAllFn ExportFn,
exportSpecificResourcesFn ExportFn,
wrapWithStageFn WrapWithStageFn,
metrics jobs.JobMetrics,
) *ExportWorker {
return &ExportWorker{
clientFactory: clientFactory,
repositoryResources: repositoryResources,
exportFn: exportFn,
wrapWithStageFn: wrapWithStageFn,
metrics: metrics,
clientFactory: clientFactory,
repositoryResources: repositoryResources,
exportAllFn: exportAllFn,
exportSpecificResourcesFn: exportSpecificResourcesFn,
wrapWithStageFn: wrapWithStageFn,
metrics: metrics,
}
}
@@ -100,7 +103,19 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
return fmt.Errorf("create repository resource client: %w", err)
}
return r.exportFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
// Check if Resources list is provided (specific resources export mode)
if len(options.Resources) > 0 {
progress.SetTotal(ctx, len(options.Resources))
progress.StrictMaxErrors(1) // Fail fast on any error during export
// Validate that specific resource export is only used with folder sync targets
if cfg.Spec.Sync.Target != provisioning.SyncTargetTypeFolder {
return fmt.Errorf("specific resource export is only supported for folder sync targets, but repository has target type '%s'", cfg.Spec.Sync.Target)
}
return r.exportSpecificResourcesFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
}
// Fall back to existing ExportAll behavior for backward compatibility
return r.exportAllFn(ctx, cfg.Name, *options, clients, repositoryResources, progress)
}
err := r.wrapWithStageFn(ctx, repo, cloneOptions, fn)

View File

@@ -56,7 +56,7 @@ func TestExportWorker_IsSupported(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewExportWorker(nil, nil, nil, nil, metrics)
r := NewExportWorker(nil, nil, nil, nil, nil, metrics)
got := r.IsSupported(context.Background(), tt.job)
require.Equal(t, tt.want, got)
})
@@ -70,7 +70,7 @@ func TestExportWorker_ProcessNoExportSettings(t *testing.T) {
},
}
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), nil, job, nil)
require.EqualError(t, err, "missing export settings")
}
@@ -93,7 +93,7 @@ func TestExportWorker_ProcessWriteNotAllowed(t *testing.T) {
},
})
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, nil)
require.EqualError(t, err, "this repository is read only")
}
@@ -117,7 +117,7 @@ func TestExportWorker_ProcessBranchNotAllowedForLocal(t *testing.T) {
},
})
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, nil)
require.EqualError(t, err, "this repository does not support the branch workflow")
}
@@ -149,7 +149,7 @@ func TestExportWorker_ProcessFailedToCreateClients(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
mockProgress := jobs.NewMockJobProgressRecorder(t)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
@@ -185,7 +185,7 @@ func TestExportWorker_ProcessNotReaderWriter(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export job submitted targeting repository that is not a ReaderWriter")
}
@@ -221,7 +221,7 @@ func TestExportWorker_ProcessRepositoryResourcesError(t *testing.T) {
mockStageFn.On("Execute", context.Background(), mockRepo, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "create repository resource client: failed to create repository resources client")
}
@@ -273,7 +273,7 @@ func TestExportWorker_ProcessStageOptions(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -355,7 +355,7 @@ func TestExportWorker_ProcessStageOptionsWithBranch(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
})
@@ -398,7 +398,7 @@ func TestExportWorker_ProcessExportFnError(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
@@ -426,7 +426,7 @@ func TestExportWorker_ProcessWrapWithStageFnError(t *testing.T) {
mockStageFn := NewMockWrapWithStageFn(t)
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything).Return(errors.New("stage failed"))
r := NewExportWorker(nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "stage failed")
}
@@ -452,7 +452,7 @@ func TestExportWorker_ProcessBranchNotAllowedForStageableRepositories(t *testing
mockProgress := jobs.NewMockJobProgressRecorder(t)
// No progress messages expected in current implementation
r := NewExportWorker(nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(nil, nil, nil, nil, nil, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "this repository does not support the branch workflow")
}
@@ -504,7 +504,7 @@ func TestExportWorker_ProcessGitRepository(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
@@ -550,7 +550,7 @@ func TestExportWorker_ProcessGitRepositoryExportFnError(t *testing.T) {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
@@ -613,7 +613,7 @@ func TestExportWorker_RefURLsSetWithBranch(t *testing.T) {
return fn(mockReaderWriter, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress)
require.NoError(t, err)
@@ -670,7 +670,7 @@ func TestExportWorker_RefURLsNotSetWithoutBranch(t *testing.T) {
return fn(mockReaderWriter, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepoWithURLs, job, mockProgress)
require.NoError(t, err)
@@ -727,7 +727,7 @@ func TestExportWorker_RefURLsNotSetForNonURLRepository(t *testing.T) {
return fn(mockReaderWriter, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, nil, mockStageFn.Execute, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry()))
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)

View File

@@ -705,6 +705,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.clients,
b.repositoryResources,
export.ExportAll,
export.ExportSpecificResources,
stageIfPossible,
metrics,
)

View File

@@ -167,30 +167,40 @@ func (r *ResourcesManager) WriteResourceFileFromObject(ctx context.Context, obj
title = name
}
folder := meta.GetFolder()
// Get the absolute path of the folder
rootFolder := RootFolder(r.repo.Config())
fileName := slugify.Slugify(title) + ".json"
// If no folder is specified in the file, set it to the root to ensure everything is written under it
var fid Folder
if folder == "" {
fid = Folder{ID: rootFolder}
meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder
} else {
var ok bool
fid, ok = r.folders.Tree().DirPath(folder, rootFolder)
if !ok {
return "", fmt.Errorf("folder %s NOT found in tree with root: %s", folder, rootFolder)
// Build the full path: start with options.Path, then add folder path, then filename
basePath := options.Path
// If options.Path is provided, use it directly (it already includes folder structure from export).
// Otherwise, resolve folder path from the repository tree.
if basePath == "" {
folder := meta.GetFolder()
// Get the absolute path of the folder
rootFolder := RootFolder(r.repo.Config())
if folder == "" {
// If no folder is specified and no path is provided, set it to the root to ensure everything is written under it
meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder
} else {
var ok bool
var fid Folder
fid, ok = r.folders.Tree().DirPath(folder, rootFolder)
if !ok {
// Fallback: try without rootFolder (for instance targets where rootFolder is empty)
fid, ok = r.folders.Tree().DirPath(folder, "")
if !ok {
return "", fmt.Errorf("folder %s NOT found in tree", folder)
}
}
if fid.Path != "" {
basePath = fid.Path
}
}
}
fileName := slugify.Slugify(title) + ".json"
if fid.Path != "" {
fileName = safepath.Join(fid.Path, fileName)
}
if options.Path != "" {
fileName = safepath.Join(options.Path, fileName)
if basePath != "" {
fileName = safepath.Join(basePath, fileName)
}
parsed := ParsedResource{

View File

@@ -145,6 +145,8 @@ func (t *folderTree) AddUnstructured(item *unstructured.Unstructured) error {
return fmt.Errorf("extract meta accessor: %w", err)
}
// In Grafana, folder UIDs are stored as metadata.name
// The grafana.app/folder annotation contains the folder's metadata.name (which is its Grafana UID)
folder := Folder{
Title: meta.FindTitle(item.GetName()),
ID: item.GetName(),

View File

@@ -3288,6 +3288,18 @@
"path": {
"description": "FIXME: we should validate this in admission hooks Prefix in target file system",
"type": "string"
},
"resources": {
"description": "Resources to export This option has been created because currently the frontend does not use standarized app platform APIs. For performance and API consistency reasons, the preferred option is it to use the resources.",
"type": "array",
"items": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ResourceRef"
}
]
}
}
}
},

View File

@@ -0,0 +1,390 @@
package provisioning
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegrationProvisioning_ExportSpecificResources(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create unmanaged dashboards directly in Grafana
dashboard1 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
dashboard1Obj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard1, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create first dashboard")
dashboard1Name := dashboard1Obj.GetName()
dashboard2 := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v2beta1.yaml")
dashboard2Obj, err := helper.DashboardsV2beta1.Resource.Create(ctx, dashboard2, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create second dashboard")
dashboard2Name := dashboard2Obj.GetName()
// Verify dashboards are unmanaged
dash1, err := helper.DashboardsV1.Resource.Get(ctx, dashboard1Name, metav1.GetOptions{})
require.NoError(t, err)
manager1, found1 := dash1.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, !found1 || manager1 == "", "dashboard1 should be unmanaged")
dash2, err := helper.DashboardsV2beta1.Resource.Get(ctx, dashboard2Name, metav1.GetOptions{})
require.NoError(t, err)
manager2, found2 := dash2.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, !found2 || manager2 == "", "dashboard2 should be unmanaged")
// Create repository with folder sync target (required for specific resource export)
const repo = "export-resources-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0, // No dashboards expected after sync (we'll export manually)
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created dashboards before repo
}
helper.CreateRepo(t, testRepo)
// Export specific dashboards using Resources field
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Path: "",
Resources: []provisioning.ResourceRef{
{
Name: dashboard1Name,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
{
Name: dashboard2Name,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
// Verify both dashboards were exported
dashboard1File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v1.json")
dashboard2File := filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v2beta1.json")
// Check dashboard1
body1, err := os.ReadFile(dashboard1File) //nolint:gosec
require.NoError(t, err, "exported file should exist for dashboard1")
obj1 := map[string]any{}
err = json.Unmarshal(body1, &obj1)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err := unstructured.NestedString(obj1, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v1", val)
// Check dashboard2
body2, err := os.ReadFile(dashboard2File) //nolint:gosec
require.NoError(t, err, "exported file should exist for dashboard2")
obj2 := map[string]any{}
err = json.Unmarshal(body2, &obj2)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err = unstructured.NestedString(obj2, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v2beta1", val)
}
func TestIntegrationProvisioning_ExportSpecificResourcesWithPath(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create unmanaged dashboard
dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create dashboard")
dashboardName := dashboardObj.GetName()
// Create repository with folder sync target (required for specific resource export)
const repo = "export-resources-path-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created dashboard before repo
}
helper.CreateRepo(t, testRepo)
// Export with custom path
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Path: "custom/path",
Resources: []provisioning.ResourceRef{
{
Name: dashboardName,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
// Verify dashboard was exported to custom path
expectedFile := filepath.Join(helper.ProvisioningPath, "custom", "path", "test-dashboard-created-at-v1.json")
body, err := os.ReadFile(expectedFile) //nolint:gosec
require.NoError(t, err, "exported file should exist at custom path")
obj := map[string]any{}
err = json.Unmarshal(body, &obj)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err := unstructured.NestedString(obj, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v1", val)
}
func TestIntegrationProvisioning_ExportSpecificResourcesRejectsFolders(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create a folder
folder := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "folder.grafana.app/v1beta1",
"kind": "Folder",
"metadata": map[string]any{
"name": "test-folder",
},
"spec": map[string]any{
"title": "Test Folder",
},
},
}
folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create folder")
folderName := folderObj.GetName()
// Create repository with folder sync target (required for specific resource export)
const repo = "export-reject-folders-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created folder before repo
}
helper.CreateRepo(t, testRepo)
// Try to export folder (should fail validation)
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: folderName,
Kind: "Folder",
Group: "folder.grafana.app",
},
},
},
}
// This should fail with validation error
body := asJSON(spec)
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(repo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx)
err = result.Error()
require.Error(t, err, "should fail validation when trying to export folder")
require.Contains(t, err.Error(), "folders are not supported", "error should mention folders are not supported")
}
func TestIntegrationProvisioning_ExportSpecificResourcesRejectsManagedResources(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create a managed dashboard via repository sync (use folder target to allow second repo)
testRepo := TestRepo{
Name: "managed-dashboard-repo",
Target: "folder",
Copies: map[string]string{
"exportunifiedtorepository/dashboard-test-v1.yaml": "dashboard.json",
},
ExpectedDashboards: 1,
ExpectedFolders: 1, // Folder target creates a folder with the repo name
SkipResourceAssertions: true, // Skip assertions since we're testing export, not sync
}
helper.CreateRepo(t, testRepo)
// Get the managed dashboard
dashboards, err := helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
require.Len(t, dashboards.Items, 1, "should have one managed dashboard")
managedDashboard := dashboards.Items[0]
managedDashboardName := managedDashboard.GetName()
// Verify it's managed
manager, found := managedDashboard.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, found && manager != "", "dashboard should be managed")
// Create another repository for export (must be folder target since instance can only exist alone)
const exportRepo = "export-managed-reject-test-repo"
exportTestRepo := TestRepo{
Name: exportRepo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we're testing export, not sync
}
helper.CreateRepo(t, exportTestRepo)
// Try to export managed dashboard (should fail)
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Resources: []provisioning.ResourceRef{
{
Name: managedDashboardName,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
// This should fail because the resource is managed
body := asJSON(spec)
result := helper.AdminREST.Post().
Namespace("default").
Resource("repositories").
Name(exportRepo).
SubResource("jobs").
Body(body).
SetHeader("Content-Type", "application/json").
Do(ctx)
// Wait for job to complete and check it failed
obj, err := result.Get()
require.NoError(t, err, "job should be created")
unstruct, ok := obj.(*unstructured.Unstructured)
require.True(t, ok, "should get unstructured object")
// Wait for job to complete
job := helper.AwaitJob(t, ctx, unstruct)
lastState := mustNestedString(job.Object, "status", "state")
lastErrors := mustNestedStringSlice(job.Object, "status", "errors")
// Job should fail with error about managed resource
require.Equal(t, string(provisioning.JobStateError), lastState, "job should fail")
require.NotEmpty(t, lastErrors, "job should have errors")
require.Contains(t, lastErrors[0], "managed", "error should mention managed resource")
}
func TestIntegrationProvisioning_ExportSpecificResourcesWithFolderStructure(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
// Create an unmanaged folder
folder := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "folder.grafana.app/v1beta1",
"kind": "Folder",
"metadata": map[string]any{
"name": "test-export-folder",
},
"spec": map[string]any{
"title": "Test Export Folder",
},
},
}
folderObj, err := helper.Folders.Resource.Create(ctx, folder, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create folder")
folderUID := folderObj.GetUID()
// Verify folder is unmanaged
manager, found := folderObj.GetAnnotations()[utils.AnnoKeyManagerIdentity]
require.True(t, !found || manager == "", "folder should be unmanaged")
// Create unmanaged dashboard in the folder
dashboard := helper.LoadYAMLOrJSONFile("exportunifiedtorepository/dashboard-test-v1.yaml")
// Set folder UID in dashboard spec
err = unstructured.SetNestedField(dashboard.Object, string(folderUID), "spec", "folder")
require.NoError(t, err, "should be able to set folder UID")
dashboardObj, err := helper.DashboardsV1.Resource.Create(ctx, dashboard, metav1.CreateOptions{})
require.NoError(t, err, "should be able to create dashboard in folder")
dashboardName := dashboardObj.GetName()
// Create repository with folder sync target (required for specific resource export)
const repo = "export-folder-structure-test-repo"
testRepo := TestRepo{
Name: repo,
Target: "folder",
Copies: map[string]string{},
ExpectedDashboards: 0,
ExpectedFolders: 0,
SkipResourceAssertions: true, // Skip assertions since we created folder and dashboard before repo
}
helper.CreateRepo(t, testRepo)
// Export dashboard (should preserve folder structure)
spec := provisioning.JobSpec{
Action: provisioning.JobActionPush,
Push: &provisioning.ExportJobOptions{
Path: "",
Resources: []provisioning.ResourceRef{
{
Name: dashboardName,
Kind: "Dashboard",
Group: "dashboard.grafana.app",
},
},
},
}
helper.TriggerJobAndWaitForSuccess(t, repo, spec)
// For folder sync targets with specific resource export, the folder structure
// from unmanaged folders should be preserved in the export path
// Expected: <provisioning_path>/<folder_name>/<dashboard>.json
expectedFile := filepath.Join(helper.ProvisioningPath, "Test Export Folder", "test-dashboard-created-at-v1.json")
body, err := os.ReadFile(expectedFile) //nolint:gosec
if err != nil {
// Fallback: if folder structure not preserved, file might be at root
expectedFile = filepath.Join(helper.ProvisioningPath, "test-dashboard-created-at-v1.json")
body, err = os.ReadFile(expectedFile) //nolint:gosec
require.NoError(t, err, "exported file should exist (either with folder structure or at root)")
t.Logf("Note: Dashboard exported to root instead of preserving folder structure")
}
obj := map[string]any{}
err = json.Unmarshal(body, &obj)
require.NoError(t, err, "exported file should be valid JSON")
val, _, err := unstructured.NestedString(obj, "metadata", "name")
require.NoError(t, err)
require.Equal(t, "test-v1", val)
}

View File

@@ -3,11 +3,14 @@ import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Stack, Text } from '@grafana/ui';
import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { appEvents } from 'app/core/app_events';
import { ManagerKind } from 'app/features/apiserver/types';
import { BulkDeleteProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource';
import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
import { useSelectionProvisioningStatus } from 'app/features/provisioning/hooks/useSelectionProvisioningStatus';
import { useSelectionUnmanagedStatus } from 'app/features/provisioning/hooks/useSelectionUnmanagedStatus';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { ShowModalReactEvent } from 'app/types/events';
import { FolderDTO } from 'app/types/folders';
@@ -33,6 +36,7 @@ export interface Props {
export function BrowseActions({ folderDTO }: Props) {
const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false);
const [showBulkMoveProvisionedResource, setShowBulkMoveProvisionedResource] = useState(false);
const [showBulkExportProvisionedResource, setShowBulkExportProvisionedResource] = useState(false);
const dispatch = useDispatch();
const selectedItems = useActionSelectionState();
@@ -47,6 +51,9 @@ export function BrowseActions({ folderDTO }: Props) {
selectedItems,
folderDTO?.managedBy === ManagerKind.Repo
);
const { hasUnmanaged, isLoading: isLoadingUnmanaged } = useSelectionUnmanagedStatus(selectedItems);
const { data: frontendSettings, isLoading: isLoadingSettings } = useGetFrontendSettingsQuery();
const hasRepositories = (frontendSettings?.items?.length ?? 0) > 0;
const isSearching = stateManager.hasSearchFilters();
@@ -134,16 +141,36 @@ export function BrowseActions({ folderDTO }: Props) {
}
};
const showExportModal = () => {
trackAction('export', selectedItems);
setShowBulkExportProvisionedResource(true);
};
const moveButton = (
<Button onClick={showMoveModal} variant="secondary">
<Trans i18nKey="browse-dashboards.action.move-button">Move</Trans>
</Button>
);
// Check if any dashboards are selected (export only supports dashboards, not folders)
const hasSelectedDashboards =
Object.keys(selectedItems.dashboard || {}).filter((uid) => selectedItems.dashboard[uid]).length > 0;
const exportButton = (
<Button
onClick={showExportModal}
variant="secondary"
disabled={!hasRepositories || isLoadingSettings || !hasUnmanaged || isLoadingUnmanaged || !hasSelectedDashboards}
>
<Trans i18nKey="browse-dashboards.action.export-to-repository-button">Export to Repository</Trans>
</Button>
);
return (
<>
<Stack gap={1} data-testid="manage-actions">
{moveButton}
{provisioningEnabled && exportButton}
<Button onClick={showDeleteModal} variant="destructive">
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
@@ -192,6 +219,32 @@ export function BrowseActions({ folderDTO }: Props) {
/>
</Drawer>
)}
{/* bulk export */}
{showBulkExportProvisionedResource && (
<Drawer
title={
// Heading levels should only increase by one (a11y)
<Text variant="h3" element="h2">
{t('browse-dashboards.action.export-provisioned-resources', 'Export Resources')}
</Text>
}
onClose={() => setShowBulkExportProvisionedResource(false)}
size="md"
>
<BulkExportProvisionedResource
selectedItems={selectedItems}
folderUid={folderDTO?.uid}
onActionComplete={() => {
setShowBulkExportProvisionedResource(false);
onActionComplete();
}}
onDismiss={() => {
setShowBulkExportProvisionedResource(false);
}}
/>
</Drawer>
)}
</>
);
}
@@ -199,6 +252,7 @@ export function BrowseActions({ folderDTO }: Props) {
const actionMap = {
move: 'grafana_manage_dashboards_item_moved',
delete: 'grafana_manage_dashboards_item_deleted',
export: 'grafana_manage_dashboards_item_exported',
} as const;
function trackAction(action: keyof typeof actionMap, selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {

View File

@@ -13,7 +13,7 @@ import { DashboardViewItem } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types/store';
import { PAGE_SIZE } from '../api/services';
import { fetchNextChildrenPage } from '../state/actions';
import { fetchNextChildrenPage, selectFolderWithAllDashboards } from '../state/actions';
import {
useFlatTreeState,
useCheckboxSelectionState,
@@ -81,7 +81,13 @@ export function BrowseView({ folderUID, width, height, permissions, isReadOnlyRe
const handleItemSelectionChange = useCallback(
(item: DashboardViewItem, isSelected: boolean) => {
dispatch(setItemSelectionState({ item, isSelected }));
// If selecting a folder, use the async thunk to collect all dashboards recursively
// When deselecting, the normal reducer will handle deselecting all children
if (item.kind === 'folder') {
dispatch(selectFolderWithAllDashboards({ folderUID: item.uid, isSelected }));
} else {
dispatch(setItemSelectionState({ item, isSelected }));
}
},
[dispatch]
);

View File

@@ -2,14 +2,17 @@ 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 { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
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,8 +35,12 @@ 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();
const { data: frontendSettings } = useGetFrontendSettingsQuery();
const hasRepositories = (frontendSettings?.items?.length ?? 0) > 0;
const deleteFolder = useDeleteFolderMutationFacade();
@@ -125,9 +132,38 @@ 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 canExportToRepository = config.featureToggles.provisioning && !isProvisionedFolder && hasRepositories;
const menu = (
<Menu>
@@ -147,10 +183,18 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
label={deleteLabel}
/>
)}
{canExportToRepository && <MenuItem onClick={handleExportFolder} label={exportLabel} />}
</Menu>
);
if (!canViewPermissions && !canMoveFolder && !canDeleteFolders) {
// Show menu if there are any available actions
const hasAnyActions =
(canViewPermissions && !isProvisionedFolder) ||
(canMoveFolder && !isReadOnlyRepo) ||
(canDeleteFolders && !isReadOnlyRepo) ||
canExportToRepository;
if (!hasAnyActions) {
return null;
}
@@ -213,6 +257,30 @@ 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: {},
}}
onDismiss={() => {
setShowExportFolderDrawer(false);
setExportSelectedDashboards({});
}}
/>
</Drawer>
)}
</>
);
}

View File

@@ -5,6 +5,7 @@ import { createAsyncThunk } from 'app/types/store';
import { listDashboards, listFolders, PAGE_SIZE } from '../api/services';
import { DashboardViewItemWithUIItems, UIDashboardViewItem } from '../types';
import { setItemSelectionState } from './slice';
import { findItem } from './utils';
interface FetchNextChildrenPageArgs {
@@ -88,6 +89,69 @@ export const refetchChildren = createAsyncThunk(
}
);
export const selectFolderWithAllDashboards = createAsyncThunk(
'browseDashboards/selectFolderWithAllDashboards',
async ({ folderUID, isSelected }: { folderUID: string; isSelected: boolean }, { dispatch, getState }) => {
const state = getState().browseDashboards;
// Find the folder item to get its parentUID and managedBy
const folderItem = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, folderUID);
if (!isSelected) {
// When deselecting, use the normal action - it will handle deselecting all children recursively
dispatch(
setItemSelectionState({
item: {
kind: 'folder',
uid: folderUID,
parentUID: folderItem?.parentUID,
managedBy: folderItem?.managedBy,
},
isSelected: false,
})
);
return;
}
// When selecting, collect all dashboards recursively
const { collectAllDashboardsUnderFolder } = await import('app/features/provisioning/utils/collectFolderDashboards');
const dashboardUIDs = await collectAllDashboardsUnderFolder(folderUID);
// First, select the folder itself
dispatch(
setItemSelectionState({
item: {
kind: 'folder',
uid: folderUID,
parentUID: folderItem?.parentUID,
managedBy: folderItem?.managedBy,
},
isSelected: true,
})
);
// Then select all dashboards found
// We need to get the parentUID for each dashboard from the state
// If a dashboard isn't in state yet, we still need to select it
for (const dashboardUID of dashboardUIDs) {
const dashboardItem = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, dashboardUID);
// Even if dashboard isn't in state, we can still select it by UID
// The reducer will handle setting selectedItems.dashboard[dashboardUID] = true
dispatch(
setItemSelectionState({
item: {
kind: 'dashboard',
uid: dashboardUID,
parentUID: dashboardItem?.parentUID ?? folderUID, // Fallback to folderUID if not found
managedBy: dashboardItem?.managedBy,
},
isSelected: true,
})
);
}
}
);
export const fetchNextChildrenPage = createAsyncThunk(
'browseDashboards/fetchNextChildrenPage',
async (

View File

@@ -4,6 +4,7 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { config, locationService } from '@grafana/runtime';
import { IconName, Menu } from '@grafana/ui';
import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { DashboardScene } from '../../scene/DashboardScene';
@@ -28,6 +29,11 @@ export function addDashboardExportDrawerItem(item: ExportDrawerMenuItem) {
}
export default function ExportMenu({ dashboard }: { dashboard: DashboardScene }) {
const provisioningEnabled = config.featureToggles.provisioning;
const { data: frontendSettings } = useGetFrontendSettingsQuery();
const hasRepositories = (frontendSettings?.items?.length ?? 0) > 0;
const canExportToRepository = provisioningEnabled && !dashboard.isManagedRepository() && hasRepositories;
const onMenuItemClick = (shareView: string) => {
locationService.partial({ shareView });
};
@@ -59,8 +65,20 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene })
onClick: () => onMenuItemClick(shareDashboardType.image),
});
// Add "Export to Repository" option for unmanaged dashboards when repositories exist
if (canExportToRepository) {
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);
}, []);
}, [canExportToRepository]);
const onClick = (item: ExportDrawerMenuItem) => {
DashboardInteractions.sharingCategoryClicked({

View File

@@ -0,0 +1,32 @@
import { t } from '@grafana/i18n';
import { SceneComponentProps } from '@grafana/scenes';
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 <></>;
}
return (
<BulkExportProvisionedResource
folderUid={dashboard.state.meta.folderUid || ''}
selectedItems={{
dashboard: dashboard.state.uid ? { [dashboard.state.uid]: true } : {},
folder: {},
}}
onDismiss={model.useState().onDismiss}
/>
);
}

View File

@@ -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 });
}

View File

@@ -2,11 +2,13 @@ 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, Switch, Text } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
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';
import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { ShowModalReactEvent } from 'app/types/events';
import { ViewJsonModal } from './ViewJsonModal';
@@ -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);
@@ -29,6 +34,10 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
if (shareExternally) {
makeExportableV1(dashboard).then((dashboardJson) => {
if ('error' in dashboardJson) {
console.error('Failed to export dashboard:', dashboardJson.error);
return;
}
openSaveAsDialog(dashboardJson);
});
} else {
@@ -51,13 +60,17 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
}
};
const openSaveAsDialog = (dash: any) => {
const openSaveAsDialog = (dash: unknown) => {
const dashboardJsonPretty = JSON.stringify(dash, null, 2);
const blob = new Blob([dashboardJsonPretty], {
type: 'application/json;charset=utf-8',
});
const time = new Date().getTime();
saveAs(blob, `${dash.title}-${time}.json`);
const title =
typeof dash === 'object' && dash !== null && 'title' in dash && typeof dash.title === 'string'
? dash.title
: 'dashboard';
saveAs(blob, `${title}-${time}.json`);
};
const openJsonModal = (clone: object) => {
@@ -80,13 +93,18 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
<p>
<Trans i18nKey="share-modal.export.info-text">Export this dashboard.</Trans>
</p>
<Field label={exportExternallyTranslation}>
<Field label={exportExternallyTranslation} noMargin>
<Switch id="share-externally-toggle" value={shareExternally} onChange={onShareExternallyChange} />
</Field>
<Modal.ButtonRow>
<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 +112,30 @@ 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 ? { [dashboard.uid]: true } : {},
folder: {},
}}
onDismiss={() => {
setShowExportToRepositoryDrawer(false);
onDismiss?.();
}}
/>
</Drawer>
)}
</>
);
});

View File

@@ -11,7 +11,7 @@ import { JobContent } from './JobContent';
export interface FinishedJobProps {
jobUid: string;
repositoryName: string;
jobType: 'sync' | 'delete' | 'move';
jobType: 'sync' | 'delete' | 'move' | 'push';
onStatusChange?: (statusInfo: StepStatusInfo) => void;
}

View File

@@ -12,7 +12,7 @@ import { StepStatusInfo } from '../Wizard/types';
import { JobSummary } from './JobSummary';
export interface JobContentProps {
jobType: 'sync' | 'delete' | 'move';
jobType: 'sync' | 'delete' | 'move' | 'push';
job?: Job;
isFinishedJob?: boolean;
onStatusChange?: (statusInfo: StepStatusInfo) => void;

View File

@@ -9,7 +9,7 @@ import { JobContent } from './JobContent';
export interface JobStatusProps {
watch: Job;
jobType: 'sync' | 'delete' | 'move';
jobType: 'sync' | 'delete' | 'move' | 'push';
onStatusChange?: (statusInfo: StepStatusInfo) => void;
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { t, Trans } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { Alert, Box, EmptyState, FilterInput, Icon, Stack, TextLink } from '@grafana/ui';
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
@@ -23,6 +24,11 @@ export function RepositoryList({ items }: Props) {
const filteredItems = items.filter((item) => item.metadata?.name?.includes(query));
const { instanceConnected } = checkSyncSettings(items);
const handlePushUnmanaged = () => {
// Navigate to dashboards page
locationService.push('/dashboards');
};
const getResourceCountSection = () => {
if (isProvisionedInstance) {
return (
@@ -39,36 +45,42 @@ export function RepositoryList({ items }: Props) {
if (filteredItems.length) {
return (
<Stack>
<Alert title={''} severity="info">
<Trans
i18nKey="provisioning.folder-repository-list.partial-managed"
values={{ managedCount, resourceCount }}
>
{{ managedCount }}/{{ resourceCount }} resources managed by Git sync.
</Trans>
{unmanagedCount > 0 && (
<>
{' '}
<Trans i18nKey="provisioning.folder-repository-list.unmanaged-resources" count={unmanagedCount}>
{{ count: unmanagedCount }} resources aren&apos;t managed by Git sync.
</Trans>
</>
)}
{isFreeTierLicense() && (
<>
<br />
<Trans i18nKey="provisioning.free-tier-limit.message-connection">
Free-tier accounts are limited to 20 resources per folder. To add more resources per folder,
</Trans>{' '}
<TextLink href={UPGRADE_URL} external>
<Trans i18nKey="provisioning.free-tier-limit.upgrade-link">upgrade your account</Trans>{' '}
</TextLink>
.
</>
)}
</Alert>
</Stack>
<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 && (
<>
{' '}
<Trans i18nKey="provisioning.folder-repository-list.unmanaged-resources" count={unmanagedCount}>
{{ count: unmanagedCount }} resources aren&apos;t managed by Git sync.
</Trans>
</>
)}
{isFreeTierLicense() && (
<>
<br />
<Trans i18nKey="provisioning.free-tier-limit.message-connection">
Free-tier accounts are limited to 20 resources per folder. To add more resources per folder,
</Trans>{' '}
<TextLink href={UPGRADE_URL} external>
<Trans i18nKey="provisioning.free-tier-limit.upgrade-link">upgrade your account</Trans>{' '}
</TextLink>
.
</>
)}
</Alert>
);
}
return null;

View File

@@ -0,0 +1,383 @@
import { css } from '@emotion/css';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents, reportInteraction } from '@grafana/runtime';
import { Alert, Box, Button, Field, Input, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { RepositoryView, Job, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { collectSelectedItems } from 'app/features/browse-dashboards/components/utils';
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { ProvisioningAlert } from '../../Shared/ProvisioningAlert';
import { StepStatusInfo } from '../../Wizard/types';
import { useSelectionRepoValidation } from '../../hooks/useSelectionRepoValidation';
import { StatusInfo } from '../../types';
import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields';
import { getDefaultWorkflow, getWorkflowOptions } from '../defaults';
import { generateTimestamp } from '../utils/timestamp';
import { ExportJobSpec, useBulkActionJob } from './useBulkActionJob';
import { BulkActionFormData, BulkActionProvisionResourceProps } from './utils';
interface FormProps extends BulkActionProvisionResourceProps {
initialValues: BulkActionFormData;
workflowOptions: Array<{ label: string; value: string }>;
}
function FormContent({ initialValues, selectedItems, workflowOptions, onDismiss }: FormProps) {
const styles = useStyles2(getPathPrefixStyles);
// States
const [job, setJob] = useState<Job>();
const [jobError, setJobError] = useState<string | StatusInfo>();
const [selectedRepositoryName, setSelectedRepositoryName] = useState<string>('');
const [hasSubmitted, setHasSubmitted] = useState(false);
// Hooks
const { createBulkJob, isLoading: isCreatingJob } = useBulkActionJob();
const methods = useForm<BulkActionFormData>({ defaultValues: initialValues });
const {
handleSubmit,
watch,
setError,
clearErrors,
formState: { errors },
} = methods;
const workflow = watch('workflow');
// Get repositories list from frontend settings (which returns RepositoryView[])
const { data: settingsData, isLoading: isLoadingRepos } = useGetFrontendSettingsQuery();
const repositories = useMemo(() => settingsData?.items ?? [], [settingsData?.items]);
// Auto-select first repository when repositories are loaded
useEffect(() => {
if (repositories.length > 0 && !selectedRepositoryName && !isLoadingRepos) {
setSelectedRepositoryName(repositories[0].name || '');
}
}, [repositories, selectedRepositoryName, isLoadingRepos]);
// Get selected repository
const repositoryView: RepositoryView | undefined = repositories.find((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) {
if (selectedDefaultWorkflow === 'branch' || selectedDefaultWorkflow === 'write') {
methods.setValue('workflow', selectedDefaultWorkflow);
if (selectedDefaultWorkflow === 'branch') {
const timestamp = generateTimestamp();
methods.setValue('ref', `bulk-export/${timestamp}`);
} else if (selectedDefaultWorkflow === 'write' && repositoryView.branch) {
methods.setValue('ref', repositoryView.branch);
}
// Clear the path when repository changes - user will enter sub-path only
methods.setValue('path', '');
}
}
}, [repositoryView, selectedDefaultWorkflow, methods]);
const handleSubmitForm = async (data: BulkActionFormData) => {
setHasSubmitted(true);
if (!selectedRepositoryName || !repositoryView) {
// Use a form-level error since 'repository' is not in BulkActionFormData
setError('root', {
type: 'manual',
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 export
const dashboardResources = resources.filter((r) => r.kind === 'Dashboard');
if (dashboardResources.length === 0) {
setError('root', {
type: 'manual',
message: t(
'browse-dashboards.bulk-export-resources-form.error-no-dashboards',
'No dashboards selected. Only dashboards can be exported.'
),
});
setHasSubmitted(false);
return;
}
reportInteraction('grafana_provisioning_bulk_export_submitted', {
workflow: data.workflow,
repositoryName: repositoryView.name ?? 'unknown',
repositoryType: repositoryView.type ?? 'unknown',
resourceCount: dashboardResources.length,
});
// Create the export job spec (backend uses 'push' action)
// Combine repository path with user's sub-path
const repoPath = repositoryView.path || '';
const subPath = (data.path || '').trim();
const exportPath = subPath ? `${repoPath}${repoPath.endsWith('/') ? '' : '/'}${subPath}` : repoPath || undefined;
const jobSpec: ExportJobSpec = {
action: 'push',
push: {
message: data.comment || undefined,
branch: data.workflow === 'write' ? undefined : data.ref,
path: exportPath,
resources: dashboardResources,
},
};
const result = await createBulkJob(repositoryView, jobSpec);
if (result.success && result.job) {
setJob(result.job); // Store the job for tracking
} else if (!result.success && result.error) {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [
t('browse-dashboards.bulk-export-resources-form.error-exporting-resources', 'Error exporting resources'),
result.error,
],
});
setHasSubmitted(false);
}
};
const onStatusChange = useCallback((statusInfo: StepStatusInfo) => {
if (statusInfo.status === 'error' && statusInfo.error) {
setJobError(statusInfo.error);
}
}, []);
const repositoryOptions = repositories.map((repo) => ({
label: repo.title || repo.name || '',
value: repo.name || '',
}));
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
{hasSubmitted && job ? (
<>
<ProvisioningAlert error={jobError} />
<JobStatus watch={job} jobType="push" onStatusChange={onStatusChange} />
</>
) : (
<>
<Box paddingBottom={2}>
<Trans i18nKey="browse-dashboards.bulk-export-resources-form.export-total">
In total, this will export:
</Trans>
<Text element="p" color="secondary">
{(() => {
// For export, only count explicitly selected dashboards (folders are filtered out)
const selectedDashboardUIDs = Object.keys(selectedItems.dashboard || {}).filter(
(uid) => selectedItems.dashboard[uid]
);
const selectedFolderUIDs = Object.keys(selectedItems.folder || {}).filter(
(uid) => selectedItems.folder[uid]
);
const totalItems = selectedDashboardUIDs.length + selectedFolderUIDs.length;
if (totalItems === 0) {
return t('browse-dashboards.bulk-export-resources-form.no-items', 'No items selected');
}
const parts: string[] = [];
if (selectedFolderUIDs.length > 0) {
parts.push(
t('browse-dashboards.bulk-export-resources-form.folders-count', '{{count}} folder', {
count: selectedFolderUIDs.length,
})
);
}
if (selectedDashboardUIDs.length > 0) {
parts.push(
t('browse-dashboards.bulk-export-resources-form.dashboards-count', '{{count}} dashboard', {
count: selectedDashboardUIDs.length,
})
);
}
return `${totalItems} ${totalItems === 1 ? 'item' : 'items'}: ${parts.join(', ')}`;
})()}
</Text>
</Box>
{/* Show form-level errors */}
{errors.root && <Alert severity="error" title={String(errors.root.message)} />}
{/* Info if folders are selected */}
{Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]).length > 0 && (
<Alert
severity="info"
title={t('browse-dashboards.bulk-export-resources-form.folders-info', 'Folders in selection')}
>
{t(
'browse-dashboards.bulk-export-resources-form.folders-info-description',
'Folders will be left behind. New folders will be created in the repository based on the resource folder structure.'
)}
</Alert>
)}
{/* Repository selection */}
<Field
noMargin
label={t('browse-dashboards.bulk-export-resources-form.repository', 'Repository')}
error={errors.root?.message}
invalid={!!errors.root && !selectedRepositoryName}
required
>
<Select
options={repositoryOptions}
value={selectedRepositoryName}
onChange={(option) => {
setSelectedRepositoryName(option?.value || '');
clearErrors('root');
}}
isLoading={isLoadingRepos}
placeholder={t(
'browse-dashboards.bulk-export-resources-form.repository-placeholder',
'Select a repository'
)}
/>
</Field>
{/* Path field */}
{repositoryView?.path && (
<Field
noMargin
label={t('browse-dashboards.bulk-export-resources-form.path', 'Path')}
description={t(
'browse-dashboards.bulk-export-resources-form.path-description-with-repo',
'Add a sub-path below to organize exported resources.'
)}
>
<Stack direction="row" gap={0} alignItems="stretch">
<div className={styles.pathPrefix}>
<Text variant="body" color="secondary">
{repositoryView.path}
</Text>
</div>
<Input
type="text"
{...methods.register('path')}
placeholder={t(
'browse-dashboards.bulk-export-resources-form.path-placeholder-with-repo',
'e.g., dashboards/team-a/'
)}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
flex: 1,
}}
/>
</Stack>
</Field>
)}
{!repositoryView?.path && (
<Field
noMargin
label={t('browse-dashboards.bulk-export-resources-form.path', 'Path')}
description={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., dashboards/'
)}
/>
</Field>
)}
{/* Shared fields (comment, workflow, branch) */}
{repositoryView && (
<ResourceEditFormSharedFields
resourceType="dashboard"
isNew={false}
workflow={workflow}
workflowOptions={selectedWorkflowOptions}
repository={repositoryView}
hidePath
/>
)}
<Stack gap={2}>
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={isCreatingJob}>
<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-export-resources-form.button-exporting', 'Exporting...')
: t('browse-dashboards.bulk-export-resources-form.button-export', 'Export')}
</Button>
</Stack>
</>
)}
</Stack>
</form>
</FormProvider>
);
}
export function BulkExportProvisionedResource({
folderUid,
selectedItems,
onDismiss,
}: BulkActionProvisionResourceProps) {
// Check if we're on the root browser dashboards page
const isRootPage = !folderUid || folderUid === GENERAL_FOLDER_UID;
const { selectedItemsRepoUID } = useSelectionRepoValidation(selectedItems);
const { repository } = useGetResourceRepositoryView({
folderName: isRootPage ? selectedItemsRepoUID : folderUid,
});
const workflowOptions = getWorkflowOptions(repository);
const timestamp = generateTimestamp();
const defaultWorkflow = getDefaultWorkflow(repository);
const initialValues = {
comment: '',
ref: defaultWorkflow === 'branch' ? `bulk-export/${timestamp}` : (repository?.branch ?? ''),
workflow: defaultWorkflow,
path: '',
};
// Note: We don't require a repository context for export since user selects target repository
return (
<FormContent
selectedItems={selectedItems}
onDismiss={onDismiss}
initialValues={initialValues}
workflowOptions={workflowOptions}
/>
);
}
const getPathPrefixStyles = (theme: GrafanaTheme2) => ({
pathPrefix: css({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0, 1),
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.strong}`,
borderRight: 'none',
borderTopLeftRadius: theme.shape.borderRadius(1),
borderBottomLeftRadius: theme.shape.borderRadius(1),
whiteSpace: 'nowrap',
}),
});

View File

@@ -24,7 +24,17 @@ export interface MoveJobSpec {
};
}
export type BulkJobSpec = DeleteJobSpec | MoveJobSpec;
export interface ExportJobSpec {
action: 'push';
push: {
message?: string;
branch?: string;
path?: string;
resources: ResourceRef[];
};
}
export type BulkJobSpec = DeleteJobSpec | MoveJobSpec | ExportJobSpec;
interface UseBulkActionJobResult {
createBulkJob: (

View File

@@ -8,6 +8,7 @@ export type BulkActionFormData = {
ref: string;
workflow?: WorkflowOption;
targetFolderUID?: string;
path?: string;
};
export interface BulkActionProvisionResourceProps {

View File

@@ -7,9 +7,21 @@ import { ResourceWrapper } from 'app/api/clients/provisioning/v0alpha1';
import { useProvisionedRequestHandler, RequestHandlers } from './useProvisionedRequestHandler';
jest.mock('@grafana/runtime', () => ({
getAppEvents: jest.fn(),
}));
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
return {
...original,
getAppEvents: jest.fn(),
config: {
...original.config,
bootData: {
user: {},
settings: {},
navTree: [],
},
},
};
});
jest.mock('@grafana/i18n', () => ({
t: jest.fn((key: string, defaultValue: string) => defaultValue),

View File

@@ -0,0 +1,158 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { config } from '@grafana/runtime';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import { AnnoKeyManagerKind, ManagerKind } from 'app/features/apiserver/types';
import { isProvisionedDashboard as isProvisionedDashboardFromMeta } from 'app/features/browse-dashboards/api/isProvisioned';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { useSelector } from 'app/types/store';
import { findItem } from '../../browse-dashboards/state/utils';
import { DashboardTreeSelection } from '../../browse-dashboards/types';
// This hook checks if selected items are unmanaged (not managed by any repository)
export function useSelectionUnmanagedStatus(selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>): {
hasUnmanaged: boolean;
isLoading: boolean;
} {
const browseState = useSelector((state) => state.browseDashboards);
const [, stateManager] = useSearchStateManager();
const isSearching = stateManager.hasSearchFilters();
const provisioningEnabled = config.featureToggles.provisioning;
const [status, setStatus] = useState({ hasUnmanaged: false, isLoading: true });
const [folderCache, setFolderCache] = useState<Record<string, boolean>>({});
const [dashboardCache, setDashboardCache] = useState<Record<string, boolean>>({});
// Create folder resource client for k8s API
const folderClient = useMemo(
() =>
new ScopedResourceClient({
group: 'folder.grafana.app',
version: 'v1beta1',
resource: 'folders',
}),
[]
);
// Memoize the selected item UIDs to avoid unnecessary re-runs when children are loaded
const selectedDashboardUIDs = useMemo(
() => Object.keys(selectedItems.dashboard || {}).filter((uid) => selectedItems.dashboard[uid]),
[selectedItems.dashboard]
);
const selectedFolderUIDs = useMemo(
() => Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]),
[selectedItems.folder]
);
// Use a ref to always access the latest browseState without causing re-renders
const browseStateRef = useRef(browseState);
browseStateRef.current = browseState;
const findItemInState = useCallback(
(uid: string) => {
const state = browseStateRef.current;
const item = findItem(state.rootItems?.items || [], state.childrenByParentUID, uid);
return item ? { parentUID: item.parentUID, managedBy: item.managedBy } : undefined;
},
[] // No dependencies - always uses latest state via ref
);
const getFolderMeta = useCallback(
async (uid: string) => {
if (folderCache[uid] !== undefined) {
return folderCache[uid];
}
try {
const folder = await folderClient.get(uid);
const managedBy = folder.metadata?.annotations?.[AnnoKeyManagerKind];
// Unmanaged if not managed by repository
const result = managedBy !== ManagerKind.Repo;
setFolderCache((prev) => ({ ...prev, [uid]: result }));
return result;
} catch {
// If we can't fetch, assume unmanaged
return true;
}
},
[folderCache, folderClient]
);
const getDashboardMeta = useCallback(
async (uid: string) => {
if (dashboardCache[uid] !== undefined) {
return dashboardCache[uid];
}
try {
const dto = await getDashboardAPI().getDashboardDTO(uid);
// Unmanaged if not provisioned
const result = !isProvisionedDashboardFromMeta(dto);
setDashboardCache((prev) => ({ ...prev, [uid]: result }));
return result;
} catch {
// If we can't fetch, assume unmanaged
return true;
}
},
[dashboardCache]
);
const checkItemUnmanaged = useCallback(
async (uid: string, isFolder: boolean): Promise<boolean> => {
if (isSearching) {
return isFolder ? await getFolderMeta(uid) : await getDashboardMeta(uid);
}
const item = findItemInState(uid);
if (isFolder) {
// Unmanaged if not managed by repository
return item?.managedBy !== ManagerKind.Repo;
}
// Check parent folder first for dashboards
const parent = item?.parentUID ? findItemInState(item.parentUID) : undefined;
if (parent?.managedBy === ManagerKind.Repo) {
// If parent is managed, dashboard is managed
return false;
}
// Unmanaged if not managed by repository
return item?.managedBy !== ManagerKind.Repo;
},
[isSearching, getFolderMeta, getDashboardMeta, findItemInState]
);
useEffect(() => {
if (!provisioningEnabled) {
setStatus({ hasUnmanaged: false, isLoading: false });
return;
}
const checkUnmanagedStatus = async () => {
setStatus({ hasUnmanaged: false, isLoading: true });
if (selectedDashboardUIDs.length === 0 && selectedFolderUIDs.length === 0) {
setStatus({ hasUnmanaged: false, isLoading: false });
return;
}
// Check all selected items
const checks = [
...selectedDashboardUIDs.map((uid) => checkItemUnmanaged(uid, false)),
...selectedFolderUIDs.map((uid) => checkItemUnmanaged(uid, true)),
];
const results = await Promise.all(checks);
// Export should only be enabled if ALL selected items are unmanaged
// If ANY item is managed, hasUnmanaged should be false
const hasUnmanaged = results.length > 0 && results.every((isUnmanaged) => isUnmanaged);
setStatus({ hasUnmanaged, isLoading: false });
};
checkUnmanagedStatus();
}, [selectedDashboardUIDs, selectedFolderUIDs, provisioningEnabled, checkItemUnmanaged]);
return status;
}

View File

@@ -0,0 +1,71 @@
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
* @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
// Paginate through all folders to ensure we get all child folders
const searcher = getGrafanaSearcher();
let folderPage = 0;
let hasMoreFolders = true;
const folderPageSize = 100;
while (hasMoreFolders) {
const foldersResults = await searcher.search({
kind: ['folder'],
query: '*',
location: currentFolderUID || 'general',
from: folderPage * folderPageSize,
limit: folderPageSize,
});
let foundFolders = 0;
for (const folderItem of foldersResults.view) {
const folderUID = folderItem.uid;
if (folderUID && !processedFolders.has(folderUID)) {
foldersToProcess.push(folderUID);
foundFolders++;
}
}
// Check if we've loaded all folders (if we got fewer than pageSize, we're done)
hasMoreFolders = foldersResults.view.length === folderPageSize;
folderPage++;
}
}
return dashboardUIDs;
}

View File

@@ -3562,6 +3562,9 @@
"delete-modal-title": "Delete",
"delete-provisioned-folder": "Delete provisioned folder",
"deleting": "Deleting...",
"export-folder": "Export Folder to Repository",
"export-provisioned-resources": "Export Resources",
"export-to-repository-button": "Export to Repository",
"manage-permissions-button": "Manage permissions",
"move-button": "Move",
"move-modal-alert": "Moving this item may change its permissions.",
@@ -3592,6 +3595,29 @@
"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...",
"dashboards-count_one": "{{count}} dashboard",
"dashboards-count_other": "{{count}} dashboard",
"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-count_one": "{{count}} folder",
"folders-count_other": "{{count}} folder",
"folders-info": "Folders in selection",
"folders-info-description": "Folders will be left behind. New folders will be created in the repository based on the resource folder structure.",
"no-items": "No items selected",
"path": "Path",
"path-description": "Path relative to the repository root (optional). Resources will be exported under this path.",
"path-description-with-repo": "Add a sub-path below to organize exported resources.",
"path-placeholder": "e.g., dashboards/",
"path-placeholder-with-repo": "e.g., dashboards/team-a/",
"repository": "Repository",
"repository-placeholder": "Select a repository"
},
"bulk-move-resources-form": {
"button-cancel": "Cancel",
"button-move": "Move",
@@ -3657,6 +3683,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"
@@ -11785,6 +11813,7 @@
"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",
@@ -12762,6 +12791,7 @@
"menu": {
"export-image-title": "Export as image",
"export-json-title": "Export as JSON",
"export-to-repository-title": "Export to Repository",
"share-externally-title": "Share externally",
"share-internally-title": "Share internally",
"share-snapshot-title": "Share snapshot"
@@ -12789,6 +12819,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",