diff --git a/.betterer.results b/.betterer.results
index 0cdc8d5136e..ee7fabc2121 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -2557,20 +2557,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"]
],
- "public/app/features/provisioning/Config/ConfigForm.tsx:5381": [
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "4"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "5"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "6"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "7"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "8"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "9"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "10"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "11"]
- ],
"public/app/features/provisioning/Config/ConfigFormGithubCollapse.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"]
@@ -2597,15 +2583,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"],
[0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"]
],
- "public/app/features/provisioning/Wizard/FinishStep.tsx:5381": [
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"],
- [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"]
- ],
- "public/app/features/provisioning/types.ts:5381": [
- [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
- ],
"public/app/features/query/components/QueryEditorRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
diff --git a/public/app/api/clients/provisioning/v0alpha1/index.ts b/public/app/api/clients/provisioning/v0alpha1/index.ts
index 986915cebbe..c92490184b1 100644
--- a/public/app/api/clients/provisioning/v0alpha1/index.ts
+++ b/public/app/api/clients/provisioning/v0alpha1/index.ts
@@ -5,7 +5,15 @@ import { notifyApp } from '../../../../core/actions';
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded';
-import { generatedAPI, JobSpec, JobStatus, RepositorySpec, RepositoryStatus, ErrorDetails } from './endpoints.gen';
+import {
+ generatedAPI,
+ JobSpec,
+ JobStatus,
+ RepositorySpec,
+ RepositoryStatus,
+ ErrorDetails,
+ Status,
+} from './endpoints.gen';
export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
endpoints: {
@@ -88,7 +96,20 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
} else if (e instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error validating repository', e)));
} else if (typeof e === 'object' && 'error' in e && isFetchError(e.error)) {
- if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
+ // Handle Status error responses (Kubernetes style)
+ if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
+ const statusError: Status = e.error.data;
+ dispatch(
+ notifyApp(
+ createErrorNotification(
+ 'Error validating repository',
+ new Error(statusError.message || 'Unknown error')
+ )
+ )
+ );
+ }
+ // Handle TestResults error responses with field errors
+ else if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
// Only show notification if there are errors that don't have a field, field errors are handled by the form
if (nonFieldErrors.length > 0) {
diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx
index f69fa3bd4ec..177f788acec 100644
--- a/public/app/features/provisioning/Config/ConfigForm.tsx
+++ b/public/app/features/provisioning/Config/ConfigForm.tsx
@@ -2,11 +2,10 @@ import { useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
-import { Trans, t } from '@grafana/i18n';
+import { t } from '@grafana/i18n';
import {
Button,
Checkbox,
- Combobox,
ControlledCollapse,
Field,
Input,
@@ -19,26 +18,21 @@ import { Repository } from 'app/api/clients/provisioning/v0alpha1';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
+import { getGitProviderFields, getLocalProviderFields } from '../Wizard/fields';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { RepositoryFormData } from '../types';
import { dataToSpec } from '../utils/data';
+import { getRepositoryTypeConfig, isGitProvider } from '../utils/repositoryTypes';
import { ConfigFormGithubCollapse } from './ConfigFormGithubCollapse';
import { getDefaultValues } from './defaults';
// This needs to be a function for translations to work
-const getOptions = () => {
- const typeOptions = [
- { value: 'github', label: t('provisioning.config-form.option-github', 'GitHub') },
- { value: 'local', label: t('provisioning.config-form.option-local', 'Local') },
- ];
-
- const targetOptions = [
+const getTargetOptions = () => {
+ return [
{ value: 'instance', label: t('provisioning.config-form.option-entire-instance', 'Entire instance') },
{ value: 'folder', label: t('provisioning.config-form.option-managed-folder', 'Managed folder') },
];
-
- return [typeOptions, targetOptions];
};
export interface ConfigFormProps {
@@ -59,10 +53,15 @@ export function ConfigForm({ data }: ConfigFormProps) {
const isEdit = Boolean(data?.metadata?.name);
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
+ const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const [type, readOnly] = watch(['type', 'readOnly']);
- const [typeOptions, targetOptions] = useMemo(() => getOptions(), []);
- const [isLoading, setIsLoading] = useState(false);
+ const targetOptions = useMemo(() => getTargetOptions(), []);
+ const isGitBased = isGitProvider(type);
+
+ // Get field configurations based on provider type
+ const gitFields = isGitBased ? getGitProviderFields(type) : null;
+ const localFields = type === 'local' ? getLocalProviderFields(type) : null;
useEffect(() => {
if (request.isSuccess) {
@@ -76,217 +75,214 @@ export function ConfigForm({ data }: ConfigFormProps) {
const onSubmit = async (form: RepositoryFormData) => {
setIsLoading(true);
- const spec = dataToSpec(form);
- if (spec.github) {
- spec.github.token = form.token || data?.spec?.github?.token;
- // If we're still keeping this as GitHub, persist the old token. If we set a new one, it'll be re-encrypted into here.
- spec.github.encryptedToken = data?.spec?.github?.encryptedToken;
+ try {
+ const spec = dataToSpec(form, data);
+ await submitData(spec);
+ } finally {
+ setIsLoading(false);
}
- await submitData(spec);
- setIsLoading(false);
};
- // NOTE: We do not want the lint option to be listed.
return (
);
diff --git a/public/app/features/provisioning/GettingStarted/FeaturesList.tsx b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx
index 165ee52cbd1..e3352e461b5 100644
--- a/public/app/features/provisioning/GettingStarted/FeaturesList.tsx
+++ b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx
@@ -2,31 +2,29 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
-import { Stack, Text, Box, LinkButton, useStyles2 } from '@grafana/ui';
-import { Repository } from 'app/api/clients/provisioning/v0alpha1';
+import { Box, LinkButton, Stack, Text, useStyles2 } from '@grafana/ui';
-import { ConnectRepositoryButton } from '../Shared/ConnectRepositoryButton';
+import { RepositoryTypeCards } from '../Shared/RepositoryTypeCards';
interface FeaturesListProps {
- repos?: Repository[];
hasRequiredFeatures: boolean;
onSetupFeatures: () => void;
}
-export const FeaturesList = ({ repos, hasRequiredFeatures, onSetupFeatures }: FeaturesListProps) => {
+export const FeaturesList = ({ hasRequiredFeatures, onSetupFeatures }: FeaturesListProps) => {
const styles = useStyles2(getStyles);
return (
- Get started with GitSync
+ Get started with Git Sync
- Manage dashboards as code in GitHub and provision updates automatically
+ Manage dashboards as code in Git and provision updates automatically
@@ -45,7 +43,7 @@ export const FeaturesList = ({ repos, hasRequiredFeatures, onSetupFeatures }: Fe
) : (
-
+
)}
diff --git a/public/app/features/provisioning/GettingStarted/GettingStarted.tsx b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx
index dd68b0f304f..40116d38276 100644
--- a/public/app/features/provisioning/GettingStarted/GettingStarted.tsx
+++ b/public/app/features/provisioning/GettingStarted/GettingStarted.tsx
@@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Alert, Stack, useStyles2 } from '@grafana/ui';
-import { useGetFrontendSettingsQuery, Repository } from 'app/api/clients/provisioning/v0alpha1';
+import { Repository, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import provisioningSvg from 'img/provisioning/provisioning.svg';
import { EnhancedFeatures } from './EnhancedFeatures';
@@ -151,17 +151,16 @@ export default function GettingStarted({ items }: Props) {
)}
+
+
+
{
setSetupType('required-features');
setShowModal(true);
}}
/>
-
-
-
{(!hasPublicAccess || !hasImageRenderer) && hasItems && (
{repoHref && (
- window.open(repoHref, '_blank')}>
- Source Code
+ window.open(repoHref, '_blank')}>
+ Source code
)}
diff --git a/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx b/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx
index 385d2ad2ed6..64ace020dcb 100644
--- a/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx
+++ b/public/app/features/provisioning/Shared/ConnectRepositoryButton.tsx
@@ -1,26 +1,22 @@
import { useNavigate } from 'react-router-dom-v5-compat';
-import { Trans, t } from '@grafana/i18n';
-import { Alert, Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui';
+import { Trans } from '@grafana/i18n';
+import { Alert, Button, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
+import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1/endpoints.gen';
-import { RepoType } from '../Wizard/types';
-import { CONNECT_URL } from '../constants';
+import { CONNECT_URL, DEFAULT_REPOSITORY_TYPES } from '../constants';
import { checkSyncSettings } from '../utils/checkSyncSettings';
+import { getOrderedRepositoryConfigs } from '../utils/repositoryTypes';
interface Props {
items?: Repository[];
- showDropdown?: boolean;
}
-type ConnectUrl = `${typeof CONNECT_URL}/${RepoType}`;
-
-const gitURL: ConnectUrl = `${CONNECT_URL}/github`;
-const localURL: ConnectUrl = `${CONNECT_URL}/local`;
-
-export function ConnectRepositoryButton({ items, showDropdown = false }: Props) {
+export function ConnectRepositoryButton({ items }: Props) {
const state = checkSyncSettings(items);
const navigate = useNavigate();
+ const { data: frontendSettings } = useGetFrontendSettingsQuery();
if (state.instanceConnected) {
return null;
@@ -32,52 +28,39 @@ export function ConnectRepositoryButton({ items, showDropdown = false }: Props)
+ >
+ Repository limit reached ({'{{count}}'})
+
);
}
- if (showDropdown) {
- return (
-
- {
- navigate(gitURL);
- }}
- />
- {
- navigate(localURL);
- }}
- />
-
- }
- >
-
-
- Configure
-
-
-
-
- );
- }
+ const availableTypes = frontendSettings?.availableRepositoryTypes || DEFAULT_REPOSITORY_TYPES;
+ const { orderedConfigs } = getOrderedRepositoryConfigs(availableTypes);
return (
-
-
- Configure Git Sync
-
-
- Configure file provisioning
-
-
+
+ {orderedConfigs.map((config) => {
+ return (
+ navigate(`${CONNECT_URL}/${config.type}`)}
+ />
+ );
+ })}
+
+ }
+ >
+
+
+ Configure
+
+
+
+
);
}
diff --git a/public/app/features/provisioning/Shared/RepositoryList.tsx b/public/app/features/provisioning/Shared/RepositoryList.tsx
index 76560f88f5d..95d42d28d10 100644
--- a/public/app/features/provisioning/Shared/RepositoryList.tsx
+++ b/public/app/features/provisioning/Shared/RepositoryList.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
-import { Trans, t } from '@grafana/i18n';
-import { EmptySearchResult, FilterInput, Stack } from '@grafana/ui';
+import { t } from '@grafana/i18n';
+import { EmptyState, FilterInput, Stack } from '@grafana/ui';
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
import { RepositoryCard } from '../Repository/RepositoryCard';
@@ -27,18 +27,20 @@ export function RepositoryList({ items }: Props) {
value={query}
onChange={setQuery}
/>
-
+
)}
{filteredItems.length ? (
filteredItems.map((item) => )
) : (
-
-
- No results matching your query
-
-
+
)}
diff --git a/public/app/features/provisioning/Shared/RepositoryTypeCards.tsx b/public/app/features/provisioning/Shared/RepositoryTypeCards.tsx
new file mode 100644
index 00000000000..3f0fd176069
--- /dev/null
+++ b/public/app/features/provisioning/Shared/RepositoryTypeCards.tsx
@@ -0,0 +1,80 @@
+import { css } from '@emotion/css';
+
+import { Trans } from '@grafana/i18n';
+import { Card, Icon, Stack, Text, useStyles2 } from '@grafana/ui';
+import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1/endpoints.gen';
+
+import { CONNECT_URL, DEFAULT_REPOSITORY_TYPES } from '../constants';
+import { getOrderedRepositoryConfigs } from '../utils/repositoryTypes';
+
+export function RepositoryTypeCards() {
+ const styles = useStyles2(getStyles);
+ const { data: frontendSettings } = useGetFrontendSettingsQuery();
+
+ const availableTypes = frontendSettings?.availableRepositoryTypes || DEFAULT_REPOSITORY_TYPES;
+ const { gitProviders, otherProviders } = getOrderedRepositoryConfigs(availableTypes);
+
+ return (
+
+ {gitProviders.length > 0 && (
+
+
+ Choose a provider:
+
+
+
+ {gitProviders.map((config) => (
+
+
+
+
+ Configure with {'{{ provider }}'}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ If your provider is not listed:
+
+
+
+
+ {otherProviders.map((config) => (
+
+
+
+ {config.type === 'local' ? (
+ Configure file provisioning
+ ) : (
+
+ Configure with {'{{ provider }}'}
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+function getStyles() {
+ return {
+ card: css({
+ width: 220,
+ }),
+ };
+}
diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx
index 85135db960f..a22f631dde0 100644
--- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx
+++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx
@@ -8,6 +8,7 @@ import {
useGetRepositoryFilesQuery,
useGetResourceStatsQuery,
} from 'app/api/clients/provisioning/v0alpha1';
+import { generateRepositoryTitle } from 'app/features/provisioning/utils/data';
import { useStepStatus } from './StepStatusContext';
import { getResourceStats, useModeOptions } from './actions';
@@ -45,15 +46,8 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
useEffect(() => {
// Pick a name nice name based on type+settings
const repository = getValues('repository');
- switch (repository.type) {
- case 'github':
- const name = repository.url ?? 'github';
- setValue('repository.title', name.replace('https://github.com/', ''));
- break;
- case 'local':
- setValue('repository.title', repository.path ?? 'local');
- break;
- }
+ const title = generateRepositoryTitle(repository);
+ setValue('repository.title', title);
}, [getValues, setValue]);
useEffect(() => {
diff --git a/public/app/features/provisioning/Wizard/ConnectPage.tsx b/public/app/features/provisioning/Wizard/ConnectPage.tsx
index 3d6206566f6..07456f5acb5 100644
--- a/public/app/features/provisioning/Wizard/ConnectPage.tsx
+++ b/public/app/features/provisioning/Wizard/ConnectPage.tsx
@@ -3,6 +3,8 @@ import { useParams } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { Page } from 'app/core/components/Page/Page';
+import { isGitProvider } from '../utils/repositoryTypes';
+
import { ProvisioningWizard } from './ProvisioningWizard';
import { StepStatusProvider } from './StepStatusContext';
import { RepoType } from './types';
@@ -18,7 +20,7 @@ export default function ConnectPage() {
- {isGithub && (
+
+ {/*TODO: Add same permission info for other providers*/}
+ {type === 'github' && }
+
+ {gitFields && (
<>
-
{
- return (
- {
- setValue('repository.token', '');
- setTokenConfigured(false);
- }}
- />
- );
- }}
+ rules={gitFields.tokenConfig.validation}
+ render={({ field: { ref, ...field } }) => (
+ {
+ setValue('repository.token', '');
+ setTokenConfigured(false);
+ }}
+ />
+ )}
/>
+ {gitFields.tokenUserConfig && (
+
+
+
+ )}
+
-
+
>
)}
- {type === 'local' && (
+ {localFields && (
)}
diff --git a/public/app/features/provisioning/Wizard/FinishStep.tsx b/public/app/features/provisioning/Wizard/FinishStep.tsx
index 9c3e4531ff0..77c9e06aab2 100644
--- a/public/app/features/provisioning/Wizard/FinishStep.tsx
+++ b/public/app/features/provisioning/Wizard/FinishStep.tsx
@@ -4,15 +4,19 @@ import { useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Checkbox, Field, Input, Stack, Text, TextLink } from '@grafana/ui';
-import { checkPublicAccess, checkImageRenderer } from '../GettingStarted/features';
+import { checkImageRenderer, checkPublicAccess } from '../GettingStarted/features';
+import { isGitProvider } from '../utils/repositoryTypes';
+import { getGitProviderFields } from './fields';
import { WizardFormData } from './types';
export function FinishStep() {
const { register, watch, setValue } = useFormContext();
const [type, readOnly] = watch(['repository.type', 'repository.readOnly']);
+
const isGithub = type === 'github';
+ const isGitBased = isGitProvider(type);
const isPublic = checkPublicAccess();
const hasImageRenderer = checkImageRenderer();
@@ -21,29 +25,34 @@ export function FinishStep() {
setValue('repository.sync.enabled', true);
}, [setValue]);
+ // Get field configurations for git-based providers
+ const gitFields = isGitBased ? getGitProviderFields(type) : null;
+
return (
-
- {isGithub && (
+
+ {isGitBased && (
)}
-
+
{
@@ -60,63 +69,46 @@ export function FinishStep() {
/>
- {isGithub && (
- <>
-
-
- Allows users to choose whether to open a pull request when saving changes. If the repository does not
- allow direct changes to the main branch, a pull request may still be required.
-
- }
- />
-
+ {gitFields && (
+
+
+
+ )}
-
-
-
- Enhance your GitHub experience
-
-
- You can always set this up later
-
-
-
-
+
+
+ Create preview links for pull requests
+
+ {(!isPublic || !hasImageRenderer) && (
<>
-
- Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards
- will be shared in your Git repository and visible to anyone with repository access.
- {' '}
-
-
- Requires image rendering.{' '}
-
- Set up image rendering
-
+ {' '}
+
+
+ (requires{' '}
+
+ image rendering
+ {' '}
+ and public access enabled)
>
- }
- {...register('repository.generateDashboardPreviews')}
- />
-
-
- >
+ )}
+ >
+ }
+ disabled={!isPublic || !hasImageRenderer}
+ />
+
)}
);
diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx
new file mode 100644
index 00000000000..0e26638c310
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx
@@ -0,0 +1,515 @@
+import { QueryStatus } from '@reduxjs/toolkit/query';
+import { screen, waitFor } from '@testing-library/react';
+import { UserEvent } from '@testing-library/user-event';
+import { render } from 'test/test-utils';
+
+import {
+ useCreateRepositoryJobsMutation,
+ useGetFrontendSettingsQuery,
+ useGetRepositoryFilesQuery,
+ useGetResourceStatsQuery,
+} from 'app/api/clients/provisioning/v0alpha1';
+
+import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
+
+import { ProvisioningWizard } from './ProvisioningWizard';
+import { StepStatusProvider } from './StepStatusContext';
+
+const mockNavigate = jest.fn();
+
+jest.mock('react-router-dom-v5-compat', () => ({
+ ...jest.requireActual('react-router-dom-v5-compat'),
+ useNavigate: () => mockNavigate,
+}));
+
+jest.mock('@grafana/runtime', () => ({
+ ...jest.requireActual('@grafana/runtime'),
+ getAppEvents: () => ({
+ publish: jest.fn(),
+ }),
+}));
+
+jest.mock('../hooks/useCreateOrUpdateRepository');
+jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
+ ...jest.requireActual('app/api/clients/provisioning/v0alpha1'),
+ useGetFrontendSettingsQuery: jest.fn(),
+ useGetRepositoryFilesQuery: jest.fn(),
+ useGetResourceStatsQuery: jest.fn(),
+ useCreateRepositoryJobsMutation: jest.fn(),
+}));
+
+const mockUseCreateOrUpdateRepository = useCreateOrUpdateRepository as jest.MockedFunction<
+ typeof useCreateOrUpdateRepository
+>;
+const mockUseGetFrontendSettingsQuery = useGetFrontendSettingsQuery as jest.MockedFunction<
+ typeof useGetFrontendSettingsQuery
+>;
+const mockUseGetRepositoryFilesQuery = useGetRepositoryFilesQuery as jest.MockedFunction<
+ typeof useGetRepositoryFilesQuery
+>;
+const mockUseGetResourceStatsQuery = useGetResourceStatsQuery as jest.MockedFunction;
+const mockUseCreateRepositoryJobsMutation = useCreateRepositoryJobsMutation as jest.MockedFunction<
+ typeof useCreateRepositoryJobsMutation
+>;
+
+function setup(jsx: JSX.Element) {
+ return render({jsx} );
+}
+
+async function typeIntoTokenField(user: UserEvent, placeholder: string, value: string) {
+ const resetButton = screen.queryByRole('button', { name: /Reset/i });
+ if (resetButton) {
+ await user.click(resetButton);
+ }
+ await user.type(screen.getByPlaceholderText(placeholder), value);
+}
+
+async function fillConnectionForm(
+ user: UserEvent,
+ type: 'github' | 'gitlab' | 'bitbucket' | 'local' | 'git',
+ data: {
+ token?: string;
+ tokenUser?: string;
+ url?: string;
+ branch?: string;
+ path?: string;
+ }
+) {
+ if (type !== 'local' && data.token) {
+ const tokenPlaceholders = {
+ github: 'ghp_xxxxxxxxxxxxxxxxxxxx',
+ gitlab: 'glpat-xxxxxxxxxxxxxxxxxxxx',
+ bitbucket: 'ATBBxxxxxxxxxxxxxxxx',
+ git: 'token or password',
+ };
+ await typeIntoTokenField(user, tokenPlaceholders[type], data.token);
+ }
+
+ if ((type === 'bitbucket' || type === 'git') && data.tokenUser) {
+ await user.type(screen.getByPlaceholderText('username'), data.tokenUser);
+ }
+
+ if (type !== 'local' && data.url) {
+ await user.type(screen.getByRole('textbox', { name: /Repository URL/i }), data.url);
+ }
+
+ if (type !== 'local' && data.branch) {
+ await user.type(screen.getByRole('textbox', { name: /Branch/i }), data.branch);
+ }
+
+ if (data.path) {
+ await user.type(screen.getByRole('textbox', { name: /Path/i }), data.path);
+ }
+}
+
+describe('ProvisioningWizard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseGetFrontendSettingsQuery.mockReturnValue({
+ data: {
+ items: [],
+ legacyStorage: false,
+ availableRepositoryTypes: ['github', 'gitlab', 'bitbucket', 'git', 'local'],
+ },
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ } as ReturnType);
+
+ mockUseGetRepositoryFilesQuery.mockReturnValue({
+ data: [],
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ } as ReturnType);
+
+ mockUseGetResourceStatsQuery.mockReturnValue({
+ data: {
+ dashboards: 0,
+ datasources: 0,
+ folders: 0,
+ libraryPanels: 0,
+ alertRules: 0,
+ },
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ } as ReturnType);
+
+ const mockCreateJob = jest.fn();
+ mockUseCreateRepositoryJobsMutation.mockReturnValue([
+ mockCreateJob,
+ {
+ status: QueryStatus.uninitialized,
+ isLoading: false,
+ error: null,
+ data: undefined,
+ isUninitialized: true,
+ isSuccess: false,
+ isError: false,
+ reset: jest.fn(),
+ },
+ ]);
+
+ const mockSubmitData = jest.fn();
+ const mockMutationState = {
+ status: QueryStatus.uninitialized,
+ isLoading: false,
+ error: null,
+ data: undefined,
+ isUninitialized: true,
+ isSuccess: false,
+ isError: false,
+ reset: jest.fn(),
+ };
+ (mockUseCreateOrUpdateRepository as jest.Mock).mockReturnValue([
+ mockSubmitData,
+ mockMutationState,
+ mockMutationState,
+ ]);
+
+ mockSubmitData.mockResolvedValue({
+ data: {
+ metadata: {
+ name: 'test-repo-abc123',
+ },
+ spec: {
+ type: 'github',
+ title: 'Test Repository',
+ },
+ },
+ });
+ });
+
+ describe('Happy Path', () => {
+ it('should render connection step initially', async () => {
+ setup( );
+
+ expect(screen.getByRole('heading', { name: /1\. Connect to external storage/i })).toBeInTheDocument();
+ expect(screen.getByText('Personal Access Token *')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
+ });
+
+ it('should progress through first 3 steps successfully', async () => {
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'github', {
+ token: 'test-token',
+ url: 'https://github.com/test/repo',
+ branch: 'main',
+ path: '/',
+ });
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /2\. Choose what to synchronize/i })).toBeInTheDocument();
+ });
+
+ expect(mockUseCreateOrUpdateRepository).toHaveBeenCalled();
+
+ await user.click(screen.getByRole('button', { name: /Synchronize with external storage/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /3\. Synchronize with external storage/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Begin synchronization/i })).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should show field errors when connection test fails with TestResults error', async () => {
+ const mockSubmitData = jest.fn();
+ const mockMutationState = {
+ status: QueryStatus.uninitialized,
+ isLoading: false,
+ error: null,
+ };
+ (mockUseCreateOrUpdateRepository as jest.Mock).mockReturnValue([
+ mockSubmitData,
+ mockMutationState,
+ mockMutationState,
+ ]);
+
+ const testResultsError = {
+ data: {
+ kind: 'TestResults',
+ apiVersion: 'provisioning.grafana.app/v0alpha1',
+ success: false,
+ code: 400,
+ errors: [
+ {
+ type: 'FieldValueInvalid',
+ field: 'spec.github.branch',
+ detail: 'Branch "invalid-branch" not found',
+ },
+ ],
+ },
+ status: 400,
+ };
+
+ mockSubmitData.mockRejectedValue(testResultsError);
+
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'github', {
+ token: 'test-token',
+ url: 'https://github.com/test/repo',
+ branch: 'invalid-branch',
+ path: '/',
+ });
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Branch "invalid-branch" not found')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('heading', { name: /1\. Connect to external storage/i })).toBeInTheDocument();
+ });
+
+ it('should show error alert for Status API errors', async () => {
+ const mockSubmitData = jest.fn();
+ const mockMutationState = {
+ status: QueryStatus.uninitialized,
+ isLoading: false,
+ error: null,
+ };
+ (mockUseCreateOrUpdateRepository as jest.Mock).mockReturnValue([
+ mockSubmitData,
+ mockMutationState,
+ mockMutationState,
+ ]);
+
+ const statusError = new Error('decrypt gitlab token: not found');
+ mockSubmitData.mockRejectedValue(statusError);
+
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'gitlab', {
+ token: 'invalid-token',
+ url: 'https://gitlab.com/test/repo',
+ branch: 'main',
+ path: '/',
+ });
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByText('Repository connection failed')).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('heading', { name: /1\. Connect to external storage/i })).toBeInTheDocument();
+ });
+
+ it('should show error when repository creation fails', async () => {
+ const mockSubmitData = jest.fn();
+ const mockMutationState = {
+ status: QueryStatus.uninitialized,
+ isLoading: false,
+ error: null,
+ };
+ (mockUseCreateOrUpdateRepository as jest.Mock).mockReturnValue([
+ mockSubmitData,
+ mockMutationState,
+ mockMutationState,
+ ]);
+
+ mockSubmitData.mockResolvedValue({
+ error: {
+ kind: 'Status',
+ status: 'Failure',
+ message: 'Repository creation failed',
+ code: 500,
+ },
+ });
+
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'github', {
+ token: 'test-token',
+ url: 'https://github.com/test/repo',
+ branch: 'main',
+ path: '/',
+ });
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByText('Repository request failed')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Navigation and State', () => {
+ it('should handle cancel on first step', async () => {
+ const { user } = setup( );
+
+ await user.click(screen.getByRole('button', { name: /Cancel/i }));
+
+ expect(mockNavigate).toHaveBeenCalledWith('/admin/provisioning');
+ });
+
+ it('should handle cancel on subsequent steps with repository deletion', async () => {
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'github', {
+ token: 'test-token',
+ url: 'https://github.com/test/repo',
+ branch: 'main',
+ path: '/',
+ });
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /2\. Choose what to synchronize/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /Cancel/i }));
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/admin/provisioning');
+ });
+ });
+
+ it('should disable next button when submitting', async () => {
+ const mockSubmitData = jest.fn();
+ const mockMutationState = {
+ status: QueryStatus.uninitialized,
+ isLoading: false,
+ error: null,
+ data: undefined,
+ isUninitialized: true,
+ isSuccess: false,
+ isError: false,
+ reset: jest.fn(),
+ };
+
+ (mockUseCreateOrUpdateRepository as jest.Mock).mockReturnValue([
+ mockSubmitData,
+ mockMutationState,
+ mockMutationState,
+ ]);
+
+ mockSubmitData.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
+
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'github', {
+ token: 'test-token',
+ url: 'https://github.com/test/repo',
+ branch: 'main',
+ path: '/',
+ });
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ expect(screen.getByRole('button', { name: /Submitting.../i })).toBeDisabled();
+ });
+ });
+
+ describe('Form Validation', () => {
+ it('should validate required fields', async () => {
+ const { user } = setup( );
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ expect(screen.getByRole('heading', { name: /1\. Connect to external storage/i })).toBeInTheDocument();
+ });
+
+ it('should show button text changes based on current step', async () => {
+ const { user } = setup( );
+
+ expect(screen.getByRole('button', { name: /Choose what to synchronize/i })).toBeInTheDocument();
+
+ await fillConnectionForm(user, 'github', {
+ token: 'test-token',
+ url: 'https://github.com/test/repo',
+ branch: 'main',
+ path: '/',
+ });
+
+ await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /Synchronize with external storage/i })).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Different Repository Types', () => {
+ it('should render GitLab-specific fields', async () => {
+ setup( );
+
+ expect(screen.getByText('Project Access Token *')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
+ });
+
+ it('should render Bitbucket-specific fields', async () => {
+ setup( );
+
+ expect(screen.getByText('App Password *')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Username/ })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
+ });
+
+ it('should render Git-specific fields', async () => {
+ setup( );
+
+ expect(screen.getByText('Access Token *')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Username/ })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Repository URL/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Branch/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
+ });
+
+ it('should render local repository fields', async () => {
+ setup( );
+
+ expect(screen.getByRole('textbox', { name: /Path/i })).toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('ghp_xxxxxxxxxxxxxxxxxxxx')).not.toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('glpat-xxxxxxxxxxxxxxxxxxxx')).not.toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('ATBBxxxxxxxxxxxxxxxx')).not.toBeInTheDocument();
+ expect(screen.queryByRole('textbox', { name: /Repository URL/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('textbox', { name: /Branch/i })).not.toBeInTheDocument();
+ });
+
+ it('should accept tokenUser input for Bitbucket provider', async () => {
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'bitbucket', {
+ token: 'test-token',
+ tokenUser: 'test-user',
+ url: 'https://bitbucket.org/test/repo',
+ branch: 'main',
+ path: '/',
+ });
+
+ expect(screen.getByDisplayValue('test-user')).toBeInTheDocument();
+ });
+
+ it('should accept tokenUser input for Git provider', async () => {
+ const { user } = setup( );
+
+ await fillConnectionForm(user, 'git', {
+ token: 'test-token',
+ tokenUser: 'test-user',
+ url: 'https://git.example.com/test/repo.git',
+ branch: 'main',
+ path: '/',
+ });
+
+ expect(screen.getByDisplayValue('test-user')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx
index 0eae8ed5ddb..706b6e52a32 100644
--- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx
+++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx
@@ -165,11 +165,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
const onSubmit = async () => {
if (currentStepConfig?.submitOnNext) {
// Validate form data before proceeding
- if (activeStep === 'connection' || activeStep === 'bootstrap') {
- const isValid = await trigger(['repository', 'repository.title']);
- if (!isValid) {
- return;
- }
+ const fieldsToValidate =
+ activeStep === 'connection' ? (['repository'] as const) : (['repository', 'repository.title'] as const);
+
+ const isValid = await trigger(fieldsToValidate);
+ if (!isValid) {
+ return;
}
setIsSubmitting(true);
diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx
index 449cf3f5038..459ae8c9183 100644
--- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx
+++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx
@@ -6,6 +6,7 @@ import { Button, Text, Stack, Alert, TextLink, Field, Checkbox } from '@grafana/
import { Job, useCreateRepositoryJobsMutation } from 'app/api/clients/provisioning/v0alpha1';
import { JobStatus } from '../Job/JobStatus';
+import { isGitProvider } from '../utils/repositoryTypes';
import { useStepStatus } from './StepStatusContext';
import { WizardFormData } from './types';
@@ -20,7 +21,7 @@ export function SynchronizeStep({ requiresMigration, isLegacyStorage }: Synchron
const [createJob] = useCreateRepositoryJobsMutation();
const { getValues, register, watch } = useFormContext();
const repoType = watch('repository.type');
- const supportsHistory = repoType === 'github' && isLegacyStorage;
+ const supportsHistory = isGitProvider(repoType) && isLegacyStorage;
const [job, setJob] = useState();
const startSynchronization = async () => {
diff --git a/public/app/features/provisioning/Wizard/fields.ts b/public/app/features/provisioning/Wizard/fields.ts
new file mode 100644
index 00000000000..7abe995cc1a
--- /dev/null
+++ b/public/app/features/provisioning/Wizard/fields.ts
@@ -0,0 +1,337 @@
+import { t } from '@grafana/i18n';
+
+import { RepoType } from './types';
+
+export interface FieldConfig {
+ label: string;
+ description?: string;
+ placeholder?: string;
+ required?: boolean;
+ validation?: {
+ required?: string | boolean;
+ pattern?: {
+ value: RegExp;
+ message: string;
+ };
+ };
+}
+
+// Provider-specific field configurations for all providers
+// This needs to be a function for translations to work
+const getProviderConfigs = (): Record> => ({
+ github: {
+ token: {
+ label: t('provisioning.github.token-label', 'Personal Access Token'),
+ description: t(
+ 'provisioning.github.token-description',
+ 'GitHub Personal Access Token with repository permissions'
+ ),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx',
+ required: true,
+ validation: {
+ required: t('provisioning.github.token-required', 'GitHub token is required'),
+ },
+ },
+ url: {
+ label: t('provisioning.github.url-label', 'Repository URL'),
+ description: t('provisioning.github.url-description', 'The GitHub repository URL'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'https://github.com/owner/repository',
+ required: true,
+ validation: {
+ required: t('provisioning.github.url-required', 'Repository URL is required'),
+ pattern: {
+ value: /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/,
+ message: t('provisioning.github.url-pattern', 'Must be a valid GitHub repository URL'),
+ },
+ },
+ },
+ branch: {
+ label: t('provisioning.github.branch-label', 'Branch'),
+ description: t('provisioning.github.branch-description', 'The branch to use for provisioning'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'main',
+ required: true,
+ validation: {
+ required: t('provisioning.github.branch-required', 'Branch is required'),
+ },
+ },
+ path: {
+ label: t('provisioning.github.path-label', 'Path'),
+ description: t('provisioning.github.path-description', 'Optional subdirectory path within the repository'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'grafana/',
+ required: false,
+ },
+ prWorkflow: {
+ label: t('provisioning.github.pr-workflow-label', 'Enable pull request option when saving'),
+ description: t(
+ 'provisioning.github.pr-workflow-description',
+ 'Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.'
+ ),
+ },
+ },
+ gitlab: {
+ token: {
+ label: t('provisioning.gitlab.token-label', 'Project Access Token'),
+ description: t(
+ 'provisioning.gitlab.token-description',
+ 'GitLab Project Access Token with repository permissions'
+ ),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'glpat-xxxxxxxxxxxxxxxxxxxx',
+ required: true,
+ validation: {
+ required: t('provisioning.gitlab.token-required', 'GitLab token is required'),
+ },
+ },
+ url: {
+ label: t('provisioning.gitlab.url-label', 'Repository URL'),
+ description: t('provisioning.gitlab.url-description', 'The GitLab repository URL'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'https://gitlab.com/owner/repository',
+ required: true,
+ validation: {
+ required: t('provisioning.gitlab.url-required', 'Repository URL is required'),
+ pattern: {
+ value: /^https:\/\/gitlab\.com\/[^\/]+\/[^\/]+\/?$/,
+ message: t('provisioning.gitlab.url-pattern', 'Must be a valid GitLab repository URL'),
+ },
+ },
+ },
+ branch: {
+ label: t('provisioning.gitlab.branch-label', 'Branch'),
+ description: t('provisioning.gitlab.branch-description', 'The branch to use for provisioning'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'main',
+ required: true,
+ validation: {
+ required: t('provisioning.gitlab.branch-required', 'Branch is required'),
+ },
+ },
+ path: {
+ label: t('provisioning.gitlab.path-label', 'Path'),
+ description: t('provisioning.gitlab.path-description', 'Optional subdirectory path within the repository'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'grafana/',
+ required: false,
+ },
+ prWorkflow: {
+ label: t('provisioning.gitlab.pr-workflow-label', 'Enable merge request option when saving'),
+ description: t(
+ 'provisioning.gitlab.pr-workflow-description',
+ 'Allows users to choose whether to open a merge request when saving changes. If the repository does not allow direct changes to the main branch, a merge request may still be required.'
+ ),
+ },
+ },
+ bitbucket: {
+ token: {
+ label: t('provisioning.bitbucket.token-label', 'App Password'),
+ description: t('provisioning.bitbucket.token-description', 'Bitbucket App Password with repository permissions'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'ATBBxxxxxxxxxxxxxxxx',
+ required: true,
+ validation: {
+ required: t('provisioning.bitbucket.token-required', 'Bitbucket token is required'),
+ },
+ },
+ tokenUser: {
+ label: t('provisioning.bitbucket.token-user-label', 'Username'),
+ description: t(
+ 'provisioning.bitbucket.token-user-description',
+ 'The username that will be used to access the repository with the app password'
+ ),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'username',
+ required: true,
+ validation: {
+ required: t('provisioning.bitbucket.token-user-required', 'Username is required'),
+ },
+ },
+ url: {
+ label: t('provisioning.bitbucket.url-label', 'Repository URL'),
+ description: t('provisioning.bitbucket.url-description', 'The Bitbucket repository URL'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'https://bitbucket.org/owner/repository',
+ required: true,
+ validation: {
+ required: t('provisioning.bitbucket.url-required', 'Repository URL is required'),
+ pattern: {
+ value: /^https:\/\/bitbucket\.org\/[^\/]+\/[^\/]+\/?$/,
+ message: t('provisioning.bitbucket.url-pattern', 'Must be a valid Bitbucket repository URL'),
+ },
+ },
+ },
+ branch: {
+ label: t('provisioning.bitbucket.branch-label', 'Branch'),
+ description: t('provisioning.bitbucket.branch-description', 'The branch to use for provisioning'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'main',
+ required: true,
+ validation: {
+ required: t('provisioning.bitbucket.branch-required', 'Branch is required'),
+ },
+ },
+ path: {
+ label: t('provisioning.bitbucket.path-label', 'Path'),
+ description: t('provisioning.bitbucket.path-description', 'Optional subdirectory path within the repository'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'grafana/',
+ required: false,
+ },
+ prWorkflow: {
+ label: t('provisioning.bitbucket.pr-workflow-label', 'Enable pull request option when saving'),
+ description: t(
+ 'provisioning.bitbucket.pr-workflow-description',
+ 'Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.'
+ ),
+ },
+ },
+ git: {
+ token: {
+ label: t('provisioning.git.token-label', 'Access Token'),
+ description: t('provisioning.git.token-description', 'Git repository access token or password'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'token or password',
+ required: true,
+ validation: {
+ required: t('provisioning.git.token-required', 'Git token is required'),
+ },
+ },
+ tokenUser: {
+ label: t('provisioning.git.token-user-label', 'Username'),
+ description: t(
+ 'provisioning.git.token-user-description',
+ 'The username that will be used to access the repository with the access token'
+ ),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'username',
+ required: true,
+ validation: {
+ required: t('provisioning.git.token-user-required', 'Username is required'),
+ },
+ },
+ url: {
+ label: t('provisioning.git.url-label', 'Repository URL'),
+ description: t('provisioning.git.url-description', 'The Git repository URL'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'https://git.example.com/owner/repository.git',
+ required: true,
+ validation: {
+ required: t('provisioning.git.url-required', 'Repository URL is required'),
+ pattern: {
+ value: /^https?:\/\/.+/,
+ message: t('provisioning.git.url-pattern', 'Must be a valid Git repository URL'),
+ },
+ },
+ },
+ branch: {
+ label: t('provisioning.git.branch-label', 'Branch'),
+ description: t('provisioning.git.branch-description', 'The branch to use for provisioning'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'main',
+ required: true,
+ validation: {
+ required: t('provisioning.git.branch-required', 'Branch is required'),
+ },
+ },
+ path: {
+ label: t('provisioning.git.path-label', 'Path'),
+ description: t('provisioning.git.path-description', 'Optional subdirectory path within the repository'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: 'grafana/',
+ required: false,
+ },
+ prWorkflow: {
+ label: t('provisioning.git.pr-workflow-label', 'Enable pull request option when saving'),
+ description: t(
+ 'provisioning.git.pr-workflow-description',
+ 'Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.'
+ ),
+ },
+ },
+ local: {
+ path: {
+ label: t('provisioning.local.path-label', 'Repository Path'),
+ description: t('provisioning.local.path-description', 'Local file system path to the repository'),
+ // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
+ placeholder: '/path/to/repository',
+ required: true,
+ validation: {
+ required: t('provisioning.local.path-required', 'Repository path is required'),
+ },
+ },
+ },
+});
+
+/**
+ * Get git provider field configurations that are guaranteed to exist
+ * This should only be called for git-based providers
+ */
+export const getGitProviderFields = (
+ type: RepoType
+):
+ | {
+ tokenConfig: FieldConfig;
+ tokenUserConfig?: FieldConfig;
+ urlConfig: FieldConfig;
+ branchConfig: FieldConfig;
+ pathConfig: FieldConfig;
+ prWorkflowConfig: FieldConfig;
+ }
+ | undefined => {
+ const configs = getProviderConfigs()[type];
+ if (!configs) {
+ throw new Error(`No configuration found for repository type: ${type}`);
+ }
+
+ // For git providers, these fields are guaranteed to exist
+ const tokenConfig = configs.token;
+ const tokenUserConfig = configs.tokenUser; // Optional field, only for some providers
+ const urlConfig = configs.url;
+ const branchConfig = configs.branch;
+ const pathConfig = configs.path;
+ const prWorkflowConfig = configs.prWorkflow;
+
+ if (!tokenConfig || !urlConfig || !branchConfig || !pathConfig || !prWorkflowConfig) {
+ throw new Error(`Missing required field configurations for ${type}`);
+ }
+
+ return {
+ tokenConfig,
+ tokenUserConfig,
+ urlConfig,
+ branchConfig,
+ pathConfig,
+ prWorkflowConfig,
+ };
+};
+
+/**
+ * Get local provider field configurations that are guaranteed to exist
+ * This should only be called for local providers
+ */
+export const getLocalProviderFields = (
+ type: RepoType
+):
+ | {
+ pathConfig: FieldConfig;
+ }
+ | undefined => {
+ const configs = getProviderConfigs()[type];
+ if (!configs) {
+ throw new Error(`No configuration found for repository type: ${type}`);
+ }
+
+ // For local providers, the path field is guaranteed to exist
+ const pathConfig = configs.path;
+
+ if (!pathConfig) {
+ throw new Error(`Missing required field configuration for ${type}: path`);
+ }
+
+ return {
+ pathConfig,
+ };
+};
diff --git a/public/app/features/provisioning/Wizard/types.ts b/public/app/features/provisioning/Wizard/types.ts
index f1d3d404075..7b77e277ffa 100644
--- a/public/app/features/provisioning/Wizard/types.ts
+++ b/public/app/features/provisioning/Wizard/types.ts
@@ -26,12 +26,5 @@ export interface ModeOption {
subtitle: string;
}
-export interface SystemState {
- resourceCount: number;
- resourceCountString: string;
- fileCount: number;
- actions: ModeOption[];
-}
-
export type StepStatus = 'idle' | 'running' | 'error' | 'success';
export type StepStatusInfo = { status: StepStatus } | { status: 'error'; error: string };
diff --git a/public/app/features/provisioning/constants.ts b/public/app/features/provisioning/constants.ts
index 74beb4a06f9..9c7e9ae4713 100644
--- a/public/app/features/provisioning/constants.ts
+++ b/public/app/features/provisioning/constants.ts
@@ -1,3 +1,5 @@
export const PROVISIONING_URL = '/admin/provisioning';
export const CONNECT_URL = `${PROVISIONING_URL}/connect`;
export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`;
+
+export const DEFAULT_REPOSITORY_TYPES: Array<'github' | 'local'> = ['github', 'local'];
diff --git a/public/app/features/provisioning/types.ts b/public/app/features/provisioning/types.ts
index c5bdce6b1ab..516beb0dc67 100644
--- a/public/app/features/provisioning/types.ts
+++ b/public/app/features/provisioning/types.ts
@@ -1,12 +1,60 @@
-import { GitHubRepositoryConfig, LocalRepositoryConfig, RepositorySpec } from '../../api/clients/provisioning/v0alpha1';
+import { ReactNode } from 'react';
+import { Path, UseFormReturn } from 'react-hook-form';
-export type RepositoryFormData = Omit &
+import { SelectableValue } from '@grafana/data';
+
+import {
+ BitbucketRepositoryConfig,
+ GitHubRepositoryConfig,
+ GitLabRepositoryConfig,
+ GitRepositoryConfig,
+ LocalRepositoryConfig,
+ RepositorySpec,
+} from '../../api/clients/provisioning/v0alpha1';
+
+// Repository type definition - extracted from API client
+export type RepositoryType = RepositorySpec['type'];
+
+// Field configuration interface
+export interface RepositoryFieldData {
+ label: string;
+ type: 'text' | 'secret' | 'switch' | 'select' | 'checkbox' | 'custom' | 'component' | 'number';
+ description?: string | ReactNode;
+ placeholder?: string;
+ path?: Path; // Optional nested field path, e.g., 'sync.intervalSeconds'
+ validation?: {
+ required?: boolean | string;
+ message?: string;
+ validate?: (value: unknown) => boolean | string;
+ };
+ defaultValue?: SelectableValue | string | boolean;
+ options?: Array>;
+ multi?: boolean;
+ allowCustomValue?: boolean;
+ hidden?: boolean;
+ content?: (setValue: UseFormReturn['setValue']) => ReactNode; // For custom fields
+}
+
+export type RepositoryFormData = Omit &
+ BitbucketRepositoryConfig &
+ GitRepositoryConfig &
GitHubRepositoryConfig &
+ GitLabRepositoryConfig &
LocalRepositoryConfig & {
readOnly: boolean;
prWorkflow: boolean;
};
+export type RepositorySettingsField = Path;
+
+// Section configuration
+export interface RepositorySection {
+ name: string;
+ id: string;
+ hidden?: boolean;
+ fields: RepositorySettingsField[];
+}
+
// Added to DashboardDTO to help editor
export interface ProvisioningPreview {
repo: string;
@@ -38,6 +86,6 @@ export type FileDetails = {
export type HistoryListResponse = {
apiVersion?: string;
kind?: string;
- metadata?: any;
+ metadata?: Record;
items?: HistoryItem[];
};
diff --git a/public/app/features/provisioning/utils/data.ts b/public/app/features/provisioning/utils/data.ts
index 3f491e1ea16..acecb4d1812 100644
--- a/public/app/features/provisioning/utils/data.ts
+++ b/public/app/features/provisioning/utils/data.ts
@@ -1,4 +1,4 @@
-import { RepositorySpec } from 'app/api/clients/provisioning/v0alpha1';
+import { Repository, RepositorySpec } from 'app/api/clients/provisioning/v0alpha1';
import { RepositoryFormData } from '../types';
@@ -15,22 +15,52 @@ const getWorkflows = (data: RepositoryFormData): RepositorySpec['workflows'] =>
return [...workflows, 'branch'];
};
-export const dataToSpec = (data: RepositoryFormData): RepositorySpec => {
+export const dataToSpec = (data: RepositoryFormData, existingRepository?: Repository): RepositorySpec => {
const spec: RepositorySpec = {
type: data.type,
sync: data.sync,
title: data.title || '',
workflows: getWorkflows(data),
};
+
+ const baseConfig = {
+ url: data.url || '',
+ branch: data.branch,
+ token: data.token,
+ path: data.path,
+ encryptedToken: data.encryptedToken,
+ };
+
switch (data.type) {
case 'github':
spec.github = {
+ ...baseConfig,
generateDashboardPreviews: data.generateDashboardPreviews,
- url: data.url || '',
- branch: data.branch,
- token: data.token,
- path: data.path,
};
+ // Handle token merging for existing repositories
+ if (existingRepository?.spec?.github) {
+ spec.github.token = data.token || existingRepository.spec.github.token;
+ spec.github.encryptedToken = existingRepository.spec.github.encryptedToken;
+ }
+ break;
+ case 'gitlab':
+ spec.gitlab = baseConfig;
+ // Handle token merging for existing repositories
+ if (existingRepository?.spec?.gitlab) {
+ spec.gitlab.token = data.token || existingRepository.spec.gitlab.token;
+ spec.gitlab.encryptedToken = existingRepository.spec.gitlab.encryptedToken;
+ }
+ break;
+ case 'bitbucket':
+ spec.bitbucket = baseConfig;
+ // Handle token merging for existing repositories
+ if (existingRepository?.spec?.bitbucket) {
+ spec.bitbucket.token = data.token || existingRepository.spec.bitbucket.token;
+ spec.bitbucket.encryptedToken = existingRepository.spec.bitbucket.encryptedToken;
+ }
+ break;
+ case 'git':
+ spec.git = baseConfig;
break;
case 'local':
spec.local = {
@@ -45,14 +75,37 @@ export const dataToSpec = (data: RepositoryFormData): RepositorySpec => {
};
export const specToData = (spec: RepositorySpec): RepositoryFormData => {
+ const remoteConfig = spec.github || spec.gitlab || spec.bitbucket || spec.git;
+
return structuredClone({
...spec,
- ...spec.github,
+ ...remoteConfig,
...spec.local,
- branch: spec.github?.branch || '',
- url: spec.github?.url || '',
+ branch: remoteConfig?.branch || '',
+ url: remoteConfig?.url || '',
generateDashboardPreviews: spec.github?.generateDashboardPreviews || false,
readOnly: !spec.workflows.length,
- prWorkflow: spec.workflows.includes('write'),
+ prWorkflow: spec.workflows.includes('branch'),
});
};
+
+export const generateRepositoryTitle = (repository: Pick): string => {
+ switch (repository.type) {
+ case 'github':
+ const name = repository.url ?? 'github';
+ return name.replace('https://github.com/', '');
+ case 'gitlab':
+ const gitlabName = repository.url ?? 'gitlab';
+ return gitlabName.replace('https://gitlab.com/', '');
+ case 'bitbucket':
+ const bitbucketName = repository.url ?? 'bitbucket';
+ return bitbucketName.replace('https://bitbucket.org/', '');
+ case 'git':
+ const gitName = repository.url ?? 'git';
+ return gitName.replace(/^https?:\/\/[^\/]+\//, '');
+ case 'local':
+ return repository.path ?? 'local';
+ default:
+ return '';
+ }
+};
diff --git a/public/app/features/provisioning/utils/getFormErrors.ts b/public/app/features/provisioning/utils/getFormErrors.ts
index d960df560cb..9a0c1a840f5 100644
--- a/public/app/features/provisioning/utils/getFormErrors.ts
+++ b/public/app/features/provisioning/utils/getFormErrors.ts
@@ -13,7 +13,21 @@ export type FormErrorTuple = [RepositoryFormPath | null, { message: string } | n
* @returns Tuple with form field path and error message
*/
export const getFormErrors = (errors: ErrorDetails[]): FormErrorTuple => {
- const fieldsToValidate = ['local.path', 'github.branch', 'github.url', 'github.token'];
+ const fieldsToValidate = [
+ 'local.path',
+ 'github.branch',
+ 'github.url',
+ 'github.token',
+ 'gitlab.branch',
+ 'gitlab.url',
+ 'gitlab.token',
+ 'bitbucket.branch',
+ 'bitbucket.url',
+ 'bitbucket.token',
+ 'git.branch',
+ 'git.url',
+ 'git.token',
+ ];
const fieldMap: Record = {
path: 'repository.path',
branch: 'repository.branch',
diff --git a/public/app/features/provisioning/utils/git.ts b/public/app/features/provisioning/utils/git.ts
index eb7e334888a..cfc30bbae0d 100644
--- a/public/app/features/provisioning/utils/git.ts
+++ b/public/app/features/provisioning/utils/git.ts
@@ -24,3 +24,43 @@ export const getRepoHref = (github?: RepositorySpec['github']) => {
}
return `${github.url}/tree/${github.branch}`;
};
+
+export const getRepoHrefForProvider = (spec?: RepositorySpec) => {
+ if (!spec || !spec.type) {
+ return undefined;
+ }
+
+ switch (spec.type) {
+ case 'github': {
+ const url = spec.github?.url;
+ const branch = spec.github?.branch;
+ if (!url) {
+ return undefined;
+ }
+ return branch ? `${url}/tree/${branch}` : url;
+ }
+
+ case 'gitlab': {
+ const url = spec.gitlab?.url;
+ const branch = spec.gitlab?.branch;
+ if (!url) {
+ return undefined;
+ }
+ return branch ? `${url}/-/tree/${branch}` : url;
+ }
+ case 'bitbucket': {
+ const url = spec.bitbucket?.url;
+ const branch = spec.bitbucket?.branch;
+ if (!url) {
+ return undefined;
+ }
+ return branch ? `${url}/src/${branch}` : url;
+ }
+ case 'git': {
+ // Return a generic URL for pure git repositories
+ return spec.git?.url;
+ }
+ default:
+ return undefined;
+ }
+};
diff --git a/public/app/features/provisioning/utils/repositoryTypes.ts b/public/app/features/provisioning/utils/repositoryTypes.ts
new file mode 100644
index 00000000000..4ac255adc75
--- /dev/null
+++ b/public/app/features/provisioning/utils/repositoryTypes.ts
@@ -0,0 +1,73 @@
+import { t } from '@grafana/i18n';
+import { IconName } from '@grafana/ui';
+
+import { RepoType } from '../Wizard/types';
+
+export interface RepositoryTypeConfig {
+ type: RepoType;
+ label: string;
+ description: string;
+ icon: IconName;
+}
+
+export const getRepositoryTypeConfigs = (): RepositoryTypeConfig[] => [
+ {
+ type: 'git',
+ label: t('provisioning.repository-types.pure-git', 'Pure Git'),
+ description: t('provisioning.repository-types.pure-git-description', 'Connect to any Git repository'),
+ icon: 'code-branch' as const,
+ },
+ {
+ type: 'github',
+ label: t('provisioning.repository-types.github', 'GitHub'),
+ description: t('provisioning.repository-types.github-description', 'Connect to GitHub repositories'),
+ icon: 'github' as const,
+ },
+ {
+ type: 'gitlab',
+ label: t('provisioning.repository-types.gitlab', 'GitLab'),
+ description: t('provisioning.repository-types.gitlab-description', 'Connect to GitLab repositories'),
+ icon: 'gitlab' as const,
+ },
+ {
+ type: 'bitbucket',
+ label: t('provisioning.repository-types.bitbucket', 'Bitbucket'),
+ description: t('provisioning.repository-types.bitbucket-description', 'Connect to Bitbucket repositories'),
+ icon: 'cloud' as const,
+ },
+ {
+ type: 'local',
+ label: t('provisioning.repository-types.local', 'Local'),
+ description: t('provisioning.repository-types.local-description', 'Configure file provisioning'),
+ icon: 'file-alt' as const,
+ },
+];
+
+export const getRepositoryTypeConfig = (type: RepoType): RepositoryTypeConfig | undefined => {
+ return getRepositoryTypeConfigs().find((config) => config.type === type);
+};
+
+const GIT_PROVIDER_TYPES = ['github', 'gitlab', 'bitbucket', 'git'];
+
+export const isGitProvider = (type: RepoType) => {
+ return GIT_PROVIDER_TYPES.includes(type);
+};
+
+/**
+ * Get repository configurations ordered by provider type priority:
+ * 1. Git providers first (github, gitlab, bitbucket) - excludes pure git
+ * 2. Other providers (pure git, local)
+ */
+export const getOrderedRepositoryConfigs = (availableTypes: RepoType[]) => {
+ const repositoryConfigs = getRepositoryTypeConfigs().filter((config) => availableTypes.includes(config.type));
+
+ // Separate git providers from other providers
+ const gitProviders = repositoryConfigs.filter((config) => isGitProvider(config.type) && config.type !== 'git');
+ const otherProviders = repositoryConfigs.filter((config) => !isGitProvider(config.type) || config.type === 'git');
+
+ return {
+ gitProviders,
+ otherProviders,
+ orderedConfigs: [...gitProviders, ...otherProviders],
+ };
+};
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index abf40fc23fe..8d5e7f0702a 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -10530,6 +10530,25 @@
"banner": {
"message": "This feature is currently under active development. For the best experience and latest improvements, we recommend using the <2>nightly build2> of Grafana."
},
+ "bitbucket": {
+ "branch-description": "The branch to use for provisioning",
+ "branch-label": "Branch",
+ "branch-required": "Branch is required",
+ "path-description": "Optional subdirectory path within the repository",
+ "path-label": "Path",
+ "pr-workflow-description": "Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.",
+ "pr-workflow-label": "Enable pull request option when saving",
+ "token-description": "Bitbucket App Password with repository permissions",
+ "token-label": "App Password",
+ "token-required": "Bitbucket token is required",
+ "token-user-description": "The username that will be used to access the repository with the app password",
+ "token-user-label": "Username",
+ "token-user-required": "Username is required",
+ "url-description": "The Bitbucket repository URL",
+ "url-label": "Repository URL",
+ "url-pattern": "Must be a valid Bitbucket repository URL",
+ "url-required": "Repository URL is required"
+ },
"bootstrap-step": {
"description-clear-repository-connection": "Add a clear name for this repository connection",
"empty": "Empty",
@@ -10551,36 +10570,20 @@
"button-save": "Save",
"button-saving": "Saving...",
"description-enabled": "Once automatic pulling is enabled, the target cannot be changed.",
- "description-path": "Path to a subdirectory in the Git repository",
"description-read-only": "Resources can't be modified through Grafana.",
- "description-repository-url": "Enter the GitHub repository URL",
"description-title": "A human-readable name for the config",
"error-required": "This field is required.",
"error-save-repository": "Failed to save repository settings",
- "error-valid-github-url": "Please enter a valid GitHub repository URL",
"label-automatic-pulling": "Automatic pulling",
- "label-branch": "Branch",
"label-enabled": "Enabled",
- "label-github-token": "GitHub token",
"label-interval-seconds": "Interval (seconds)",
- "label-local-path": "Local path",
- "label-path": "Path",
- "label-pr-workflow": "Enable pull request option when saving",
"label-repository-type": "Repository type",
- "label-repository-url": "Repository URL",
"label-target": "Target",
"label-title": "Title",
"option-entire-instance": "Entire instance",
- "option-github": "GitHub",
- "option-local": "Local",
"option-managed-folder": "Managed folder",
- "placeholder-branch": "main",
- "placeholder-github-token": "ghp_yourTokenHere1234567890abcdEFGHijklMNOP",
- "placeholder-github-url": "https://github.com/username/repo-name",
"placeholder-interval-seconds": "60",
- "placeholder-local-path": "/path/to/repo",
- "placeholder-my-config": "My config",
- "placeholder-select-repository-type": "Select repository type"
+ "placeholder-my-config": "My config"
},
"config-form-github-collapse": {
"description-realtime-feedback": "<0>Configure webhooks0> to get instant updates in Grafana as soon as changes are committed. Review and approve changes using pull requests before they go live.",
@@ -10594,27 +10597,8 @@
},
"connect-repository-button": {
"configure": "Configure",
- "configure-file": "Configure file provisioning",
- "configure-git-sync": "Configure Git Sync",
"repository-limit-info-alert": "Repository limit reached ({{count}})"
},
- "connect-step": {
- "description-branch": "Branch to use for the GitHub repository",
- "description-github-path": "This is the path to a subdirectory in your GitHub repository where dashboards will be stored and provisioned from",
- "description-paste-your-git-hub-personal-access-token": "Paste your GitHub personal access token",
- "description-repository-url": "Paste the URL of your GitHub repository",
- "error-field-required": "This field is required.",
- "error-invalid-github-url": "Please enter a valid GitHub repository URL",
- "label-access-token": "GitHub access token",
- "label-branch": "Branch name",
- "label-local-path": "Local path",
- "label-path": "Path to subdirectory in repository",
- "label-repository-url": "GitHub repository URL",
- "placeholder-branch": "main",
- "placeholder-github-token": "github_pat_yourTokenHere1234567890abcdEFGHijklMNOP",
- "placeholder-github-url": "https://github.com/username/repo",
- "placeholder-local-path": "/path/to/repo"
- },
"delete-repository-button": {
"button-delete": "Delete",
"confirm-delete-repository": "Are you sure you want to delete the repository config?",
@@ -10655,8 +10639,8 @@
"actions": {
"set-up-required-feature-toggles": "Set up required feature toggles"
},
- "manage-dashboards-provision-updates-automatically": "Manage dashboards as code in GitHub and provision updates automatically",
- "manage-your-dashboards-with-remote-provisioning": "Get started with GitSync",
+ "manage-dashboards-provision-updates-automatically": "Manage dashboards as code in Git and provision updates automatically",
+ "manage-your-dashboards-with-remote-provisioning": "Get started with Git Sync",
"store-dashboards-in-version-controlled-storage": "Store dashboards in version-controlled storage for better organization and history tracking"
},
"file-history-page": {
@@ -10677,18 +10661,16 @@
},
"finish-step": {
"description-enable-previews": "Adds an image preview of dashboard changes in pull requests. Images of your Grafana dashboards will be shared in your Git repository and visible to anyone with repository access.",
+ "description-generate-dashboard-previews": "Create preview links for pull requests",
"description-image-rendering": "Requires image rendering. <2>Set up image rendering2>",
- "description-often-shall-instance-updates-git-hub": "How often shall the instance pull updates from GitHub?",
- "description-pr-enable-description": "Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.",
+ "description-preview-requirements": "(requires <2>image rendering2> and public access enabled)",
"description-read-only": "Resources can't be modified through Grafana.",
- "description-webhooks-enable": "Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.",
+ "description-sync-interval": "How often to sync changes from the repository",
+ "error-sync-interval-required": "Sync interval is required",
"label-enable-previews": "Enable dashboard previews in pull requests",
- "label-pr-workflow": "Enable pull request option when saving",
+ "label-generate-dashboard-previews": "Generate Dashboard Previews",
"label-read-only": "Read only",
- "label-update-instance-interval-seconds": "Update instance interval (seconds)",
- "placeholder": "60",
- "text-setup-later": "You can always set this up later",
- "title-enhance-github": "Enhance your GitHub experience"
+ "label-sync-interval": "Sync Interval (seconds)"
},
"folder-repository-list": {
"no-results-matching-your-query": "No results matching your query",
@@ -10719,6 +10701,57 @@
"header": "Provisioning",
"subtitle-provisioning-feature": "View and manage your provisioning connections"
},
+ "git": {
+ "branch-description": "The branch to use for provisioning",
+ "branch-label": "Branch",
+ "branch-required": "Branch is required",
+ "path-description": "Optional subdirectory path within the repository",
+ "path-label": "Path",
+ "pr-workflow-description": "Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.",
+ "pr-workflow-label": "Enable pull request option when saving",
+ "token-description": "Git repository access token or password",
+ "token-label": "Access Token",
+ "token-required": "Git token is required",
+ "token-user-description": "The username that will be used to access the repository with the access token",
+ "token-user-label": "Username",
+ "token-user-required": "Username is required",
+ "url-description": "The Git repository URL",
+ "url-label": "Repository URL",
+ "url-pattern": "Must be a valid Git repository URL",
+ "url-required": "Repository URL is required"
+ },
+ "github": {
+ "branch-description": "The branch to use for provisioning",
+ "branch-label": "Branch",
+ "branch-required": "Branch is required",
+ "path-description": "Optional subdirectory path within the repository",
+ "path-label": "Path",
+ "pr-workflow-description": "Allows users to choose whether to open a pull request when saving changes. If the repository does not allow direct changes to the main branch, a pull request may still be required.",
+ "pr-workflow-label": "Enable pull request option when saving",
+ "token-description": "GitHub Personal Access Token with repository permissions",
+ "token-label": "Personal Access Token",
+ "token-required": "GitHub token is required",
+ "url-description": "The GitHub repository URL",
+ "url-label": "Repository URL",
+ "url-pattern": "Must be a valid GitHub repository URL",
+ "url-required": "Repository URL is required"
+ },
+ "gitlab": {
+ "branch-description": "The branch to use for provisioning",
+ "branch-label": "Branch",
+ "branch-required": "Branch is required",
+ "path-description": "Optional subdirectory path within the repository",
+ "path-label": "Path",
+ "pr-workflow-description": "Allows users to choose whether to open a merge request when saving changes. If the repository does not allow direct changes to the main branch, a merge request may still be required.",
+ "pr-workflow-label": "Enable merge request option when saving",
+ "token-description": "GitLab Project Access Token with repository permissions",
+ "token-label": "Project Access Token",
+ "token-required": "GitLab token is required",
+ "url-description": "The GitLab repository URL",
+ "url-label": "Repository URL",
+ "url-pattern": "Must be a valid GitLab repository URL",
+ "url-required": "Repository URL is required"
+ },
"history-view": {
"not-found": "Not found"
},
@@ -10749,6 +10782,11 @@
},
"summary": "Summary"
},
+ "local": {
+ "path-description": "Local file system path to the repository",
+ "path-label": "Repository Path",
+ "path-required": "Repository path is required"
+ },
"mode-options": {
"folder": {
"description": "After setup, a new Grafana folder will be created and synced with external storage. If any resources are present in external storage, they will be provisioned to this new folder. All new resources created in this folder will be stored and versioned in external storage.",
@@ -10773,7 +10811,7 @@
},
"repository-actions": {
"settings": "Settings",
- "source-code": "Source Code"
+ "source-code": "Source code"
},
"repository-card": {
"get-repository-meta": {
@@ -10838,6 +10876,24 @@
"title-legacy-storage": "Legacy Storage",
"title-queued-for-deletion": "Queued for deletion"
},
+ "repository-type-cards": {
+ "choose-provider": "Choose a provider:",
+ "configure-file": "Configure file provisioning",
+ "configure-with-provider": "Configure with {{ provider }}",
+ "provider-not-listed": "If your provider is not listed:"
+ },
+ "repository-types": {
+ "bitbucket": "Bitbucket",
+ "bitbucket-description": "Connect to Bitbucket repositories",
+ "github": "GitHub",
+ "github-description": "Connect to GitHub repositories",
+ "gitlab": "GitLab",
+ "gitlab-description": "Connect to GitLab repositories",
+ "local": "Local",
+ "local-description": "Configure file provisioning",
+ "pure-git": "Pure Git",
+ "pure-git-description": "Connect to any Git repository"
+ },
"resource-view": {
"base": "Base",
"dashboard-preview": "Dashboard Preview",