diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 8a3def39e8d..d9e28d6799c 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -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 diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 9a4a99d703a..68530ef5eef 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -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"}, } } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index c67b1462a9e..ad68423349a 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -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 diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/exportjoboptions.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/exportjoboptions.go index f1d543cdd90..b4c2c6d403f 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/exportjoboptions.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/exportjoboptions.go @@ -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 +} diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index 7ef1fc4fc91..e650453a2a7 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -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: diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index f99b8f60738..b0eff371ae7 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -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" + } + ] + } } } }, diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 1c89c3c6dd4..927a8ea7a79 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -6,8 +6,10 @@ import { Button, Drawer, Stack, Text } from '@grafana/ui'; import { appEvents } from 'app/core/app_events'; import { ManagerKind } from 'app/features/apiserver/types'; import { BulkDeleteProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource'; +import { BulkPushProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkPushProvisionedResource'; import { 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 +35,7 @@ export interface Props { export function BrowseActions({ folderDTO }: Props) { const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false); const [showBulkMoveProvisionedResource, setShowBulkMoveProvisionedResource] = useState(false); + const [showBulkPushProvisionedResource, setShowBulkPushProvisionedResource] = useState(false); const dispatch = useDispatch(); const selectedItems = useActionSelectionState(); @@ -47,6 +50,7 @@ export function BrowseActions({ folderDTO }: Props) { selectedItems, folderDTO?.managedBy === ManagerKind.Repo ); + const { hasUnmanaged, isLoading: isLoadingUnmanaged } = useSelectionUnmanagedStatus(selectedItems); const isSearching = stateManager.hasSearchFilters(); @@ -140,10 +144,25 @@ export function BrowseActions({ folderDTO }: Props) { ); + // 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 pushButton = ( + + ); + return ( <> {moveButton} + {provisioningEnabled && pushButton} + + + + )} + + + + ); +} + +export function BulkPushProvisionedResource({ + 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-push/${timestamp}` : (repository?.branch ?? ''), + workflow: defaultWorkflow, + path: '', + }; + + // Note: We don't require a repository context for push since user selects target repository + return ( + + ); +} + diff --git a/public/app/features/provisioning/components/BulkActions/useBulkActionJob.ts b/public/app/features/provisioning/components/BulkActions/useBulkActionJob.ts index fe47c0469e6..22cf22f1a3f 100644 --- a/public/app/features/provisioning/components/BulkActions/useBulkActionJob.ts +++ b/public/app/features/provisioning/components/BulkActions/useBulkActionJob.ts @@ -24,7 +24,17 @@ export interface MoveJobSpec { }; } -export type BulkJobSpec = DeleteJobSpec | MoveJobSpec; +export interface PushJobSpec { + action: 'push'; + push: { + message?: string; + branch?: string; + path?: string; + resources: ResourceRef[]; + }; +} + +export type BulkJobSpec = DeleteJobSpec | MoveJobSpec | PushJobSpec; interface UseBulkActionJobResult { createBulkJob: ( diff --git a/public/app/features/provisioning/components/BulkActions/utils.ts b/public/app/features/provisioning/components/BulkActions/utils.ts index f86fef030b5..4cf26394a0e 100644 --- a/public/app/features/provisioning/components/BulkActions/utils.ts +++ b/public/app/features/provisioning/components/BulkActions/utils.ts @@ -8,6 +8,7 @@ export type BulkActionFormData = { ref: string; workflow?: WorkflowOption; targetFolderUID?: string; + path?: string; }; export interface BulkActionProvisionResourceProps { diff --git a/public/app/features/provisioning/hooks/useSelectionUnmanagedStatus.ts b/public/app/features/provisioning/hooks/useSelectionUnmanagedStatus.ts new file mode 100644 index 00000000000..02f2eb7ae17 --- /dev/null +++ b/public/app/features/provisioning/hooks/useSelectionUnmanagedStatus.ts @@ -0,0 +1,146 @@ +import { useState, useEffect, useCallback, useMemo } 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 +): { 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>({}); + const [dashboardCache, setDashboardCache] = useState>({}); + + // Create folder resource client for k8s API + const folderClient = useMemo( + () => + new ScopedResourceClient({ + group: 'folder.grafana.app', + version: 'v1beta1', + resource: 'folders', + }), + [] + ); + + const findItemInState = useCallback( + (uid: string) => { + const item = findItem(browseState.rootItems?.items || [], browseState.childrenByParentUID, uid); + return item ? { parentUID: item.parentUID, managedBy: item.managedBy } : undefined; + }, + [browseState] + ); + + 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 => { + 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 }); + + const selectedDashboards = Object.keys(selectedItems.dashboard || {}).filter( + (uid) => selectedItems.dashboard[uid] + ); + const selectedFolders = Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]); + + if (selectedDashboards.length === 0 && selectedFolders.length === 0) { + setStatus({ hasUnmanaged: false, isLoading: false }); + return; + } + + // Check all selected items + const checks = [ + ...selectedDashboards.map((uid) => checkItemUnmanaged(uid, false)), + ...selectedFolders.map((uid) => checkItemUnmanaged(uid, true)), + ]; + + const results = await Promise.all(checks); + const hasUnmanaged = results.some((isUnmanaged) => isUnmanaged); + + setStatus({ hasUnmanaged, isLoading: false }); + }; + + checkUnmanagedStatus(); + }, [selectedItems, provisioningEnabled, checkItemUnmanaged]); + + return status; +} + diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d9efaadc4d6..432b1367d01 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3552,6 +3552,7 @@ "action": { "bulk-delete-provisioned-resources": "Bulk Delete Provisioned Resources", "bulk-move-provisioned-resources": "Bulk Move Provisioned Resources", + "bulk-push-provisioned-resources": "Bulk Push Resources", "cancel-button": "Cancel", "confirmation-text": "Delete", "delete-button": "Delete", @@ -3573,6 +3574,7 @@ "move-provisioned-folder": "Move provisioned folder", "moving": "Moving...", "new-folder-name-required-phrase": "Folder name is required.", + "push-button": "Push", "selected-mix-resources-modal-text": "You have selected both provisioned and non-provisioned resources. These cannot be processed together. Please select only provisioned resources or only non-provisioned resources and try again.", "selected-mix-resources-modal-title": "Mixed resource types selected" }, @@ -3611,6 +3613,22 @@ "move-warning-tooltip": "You can only move provisioned resources within their provisioned folder, and local resources to local folders.", "target-folder": "Target Folder" }, + "bulk-push-resources-form": { + "button-cancel": "Cancel", + "button-push": "Push", + "button-pushing": "Pushing...", + "error-no-dashboards": "No dashboards selected. Only dashboards can be pushed.", + "error-no-repository": "Please select a repository", + "error-pushing-resources": "Error pushing resources", + "folders-warning": "Folders cannot be pushed", + "folders-warning-description": "Only dashboards can be pushed. Folders in your selection will be ignored.", + "path": "Path", + "path-description": "Prefix path in the target repository (optional)", + "path-placeholder": "e.g., grafana/", + "push-total": "In total, this will push:", + "repository": "Repository", + "repository-placeholder": "Select a repository" + }, "counts": { "alertRule_one": "{{count}} alert rule", "alertRule_other": "{{count}} alert rules",