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:
Roberto Jimenez Sanchez
2025-12-02 17:36:19 +01:00
parent 64949f26e8
commit 8521c37a22
15 changed files with 547 additions and 9 deletions
@@ -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,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
@@ -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
}
@@ -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;
}
@@ -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;
}
+18
View File
@@ -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",