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)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+18
-4
@@ -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
|
||||
}
|
||||
|
||||
+2
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
</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 pushButton = (
|
||||
<Button
|
||||
onClick={() => setShowBulkPushProvisionedResource(true)}
|
||||
variant="secondary"
|
||||
disabled={!hasUnmanaged || isLoadingUnmanaged || !hasSelectedDashboards}
|
||||
>
|
||||
<Trans i18nKey="browse-dashboards.action.push-button">Push</Trans>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap={1} data-testid="manage-actions">
|
||||
{moveButton}
|
||||
{provisioningEnabled && pushButton}
|
||||
|
||||
<Button onClick={showDeleteModal} variant="destructive">
|
||||
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
|
||||
@@ -192,6 +211,28 @@ export function BrowseActions({ folderDTO }: Props) {
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
{/* bulk push */}
|
||||
{showBulkPushProvisionedResource && (
|
||||
<Drawer
|
||||
title={
|
||||
// Heading levels should only increase by one (a11y)
|
||||
<Text variant="h3" element="h2">
|
||||
{t('browse-dashboards.action.bulk-push-provisioned-resources', 'Bulk Push Resources')}
|
||||
</Text>
|
||||
}
|
||||
onClose={() => setShowBulkPushProvisionedResource(false)}
|
||||
size="md"
|
||||
>
|
||||
<BulkPushProvisionedResource
|
||||
selectedItems={selectedItems}
|
||||
folderUid={folderDTO?.uid}
|
||||
onDismiss={() => {
|
||||
setShowBulkPushProvisionedResource(false);
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getAppEvents, reportInteraction } from '@grafana/runtime';
|
||||
import { Alert, Box, Button, Field, Input, Select, Stack } from '@grafana/ui';
|
||||
import { RepositoryView, Job, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
|
||||
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 { PushJobSpec, 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) {
|
||||
// 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(skipToken);
|
||||
const repositories = settingsData?.items ?? [];
|
||||
|
||||
// Get selected repository
|
||||
const repositoryView: RepositoryView | undefined = repositories.find(
|
||||
(repo) => repo.name === selectedRepositoryName
|
||||
);
|
||||
|
||||
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-push-resources-form.error-no-repository', 'Please select a repository'),
|
||||
});
|
||||
setHasSubmitted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const resources = collectSelectedItems(selectedItems);
|
||||
// Filter out folders - only dashboards are supported for push
|
||||
const dashboardResources = resources.filter((r) => r.kind === 'Dashboard');
|
||||
|
||||
if (dashboardResources.length === 0) {
|
||||
setError('root', {
|
||||
type: 'manual',
|
||||
message: t(
|
||||
'browse-dashboards.bulk-push-resources-form.error-no-dashboards',
|
||||
'No dashboards selected. Only dashboards can be pushed.'
|
||||
),
|
||||
});
|
||||
setHasSubmitted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
reportInteraction('grafana_provisioning_bulk_push_submitted', {
|
||||
workflow: data.workflow,
|
||||
repositoryName: repositoryView.name ?? 'unknown',
|
||||
repositoryType: repositoryView.type ?? 'unknown',
|
||||
resourceCount: dashboardResources.length,
|
||||
});
|
||||
|
||||
// Create the push job spec
|
||||
const jobSpec: PushJobSpec = {
|
||||
action: 'push',
|
||||
push: {
|
||||
message: data.comment || undefined,
|
||||
branch: data.workflow === 'write' ? undefined : data.ref,
|
||||
path: data.path || undefined,
|
||||
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-push-resources-form.error-pushing-resources', 'Error pushing 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-push-resources-form.push-total">
|
||||
In total, this will push:
|
||||
</Trans>
|
||||
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
|
||||
</Box>
|
||||
|
||||
{/* Show form-level errors */}
|
||||
{errors.root && (
|
||||
<Alert severity="error" title={String(errors.root.message)} />
|
||||
)}
|
||||
|
||||
{/* Warn if folders are selected */}
|
||||
{Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]).length > 0 && (
|
||||
<Alert severity="warning" title={t('browse-dashboards.bulk-push-resources-form.folders-warning', 'Folders cannot be pushed')}>
|
||||
{t(
|
||||
'browse-dashboards.bulk-push-resources-form.folders-warning-description',
|
||||
'Only dashboards can be pushed. Folders in your selection will be ignored.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Repository selection */}
|
||||
<Field
|
||||
noMargin
|
||||
label={t('browse-dashboards.bulk-push-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-push-resources-form.repository-placeholder',
|
||||
'Select a repository'
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Path field */}
|
||||
<Field
|
||||
noMargin
|
||||
label={t('browse-dashboards.bulk-push-resources-form.path', 'Path')}
|
||||
description={t(
|
||||
'browse-dashboards.bulk-push-resources-form.path-description',
|
||||
'Prefix path in the target repository (optional)'
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
{...methods.register('path')}
|
||||
placeholder={t('browse-dashboards.bulk-push-resources-form.path-placeholder', 'e.g., grafana/')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Shared fields (comment, workflow, branch) */}
|
||||
{repositoryView && (
|
||||
<ResourceEditFormSharedFields
|
||||
resourceType="dashboard"
|
||||
isNew={false}
|
||||
workflow={workflow}
|
||||
workflowOptions={workflowOptions}
|
||||
repository={repositoryView}
|
||||
hidePath
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button variant="secondary" fill="outline" onClick={onDismiss} disabled={isCreatingJob}>
|
||||
<Trans i18nKey="browse-dashboards.bulk-push-resources-form.button-cancel">Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!!job || isCreatingJob || hasSubmitted || !selectedRepositoryName}
|
||||
>
|
||||
{isCreatingJob
|
||||
? t('browse-dashboards.bulk-push-resources-form.button-pushing', 'Pushing...')
|
||||
: t('browse-dashboards.bulk-push-resources-form.button-push', 'Push')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<FormContent
|
||||
selectedItems={selectedItems}
|
||||
onDismiss={onDismiss}
|
||||
initialValues={initialValues}
|
||||
workflowOptions={workflowOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -8,6 +8,7 @@ export type BulkActionFormData = {
|
||||
ref: string;
|
||||
workflow?: WorkflowOption;
|
||||
targetFolderUID?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export interface BulkActionProvisionResourceProps {
|
||||
|
||||
@@ -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<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',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
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<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 });
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user