Provisioning: Add new providers (#108173)

* Add new providers

* Extract utils

* Configurable workflow fields

* translate fields

* Add translations

* Update cards

* Add isGitProvider

* i18n

* Dynamic fields for ConfigForm

* Use git fields

* Remove type dropdown for edit

* Display proper type groups

* Display field errors

* Improve error handling

* Refactor data

* Check for repositoryLister

* Fix workflow

* use state var

* betterer

* Prettier

* Prettier[2]

* i18n

* Remove showDropdown

* i18n

* Update step validation

* Add test

* Update provider list

* Cleanup

* Add tokenUser field

* Provider-specific source code link

* Review comments

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
This commit is contained in:
Alex Khomenko 2025-07-21 08:29:41 +03:00 committed by GitHub
parent a4d4ee1bc5
commit a090480260
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1724 additions and 536 deletions

View File

@ -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"],

View File

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

View File

@ -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,38 +75,23 @@ 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);
}
};
// NOTE: We do not want the lint option to be listed.
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: 700 }}>
<FormPrompt onDiscard={reset} confirmRedirect={isDirty} />
<Field label={t('provisioning.config-form.label-repository-type', 'Repository type')}>
<Controller
name={'type'}
control={control}
render={({ field: { ref, onChange, ...field } }) => {
return (
<Combobox
options={typeOptions}
onChange={(value) => onChange(value?.value)}
placeholder={t('provisioning.config-form.placeholder-select-repository-type', 'Select repository type')}
disabled={!!data?.spec}
{...field}
/>
);
}}
/>
<Stack direction="column" gap={2}>
<Field noMargin label={t('provisioning.config-form.label-repository-type', 'Repository type')}>
<Input value={getRepositoryTypeConfig(type)?.label || type} disabled />
</Field>
<Field
noMargin
label={t('provisioning.config-form.label-title', 'Title')}
description={t('provisioning.config-form.description-title', 'A human-readable name for the config')}
invalid={!!errors.title}
@ -120,29 +104,28 @@ export function ConfigForm({ data }: ConfigFormProps) {
placeholder={t('provisioning.config-form.placeholder-my-config', 'My config')}
/>
</Field>
{type === 'github' && (
{gitFields && (
<>
<Field
label={t('provisioning.config-form.label-github-token', 'GitHub token')}
required
noMargin
label={gitFields.tokenConfig.label}
required={gitFields.tokenConfig.required}
error={errors?.token?.message}
invalid={!!errors.token}
description={gitFields.tokenConfig.description}
>
<Controller
name={'token'}
control={control}
rules={{
required: isEdit ? false : t('provisioning.config-form.error-required', 'This field is required.'),
required: isEdit ? false : gitFields.tokenConfig.validation?.required,
}}
render={({ field: { ref, ...field } }) => {
return (
<SecretInput
{...field}
id={'token'}
placeholder={t(
'provisioning.config-form.placeholder-github-token',
'ghp_yourTokenHere1234567890abcdEFGHijklMNOP'
)}
placeholder={gitFields.tokenConfig.placeholder}
isConfigured={tokenConfigured}
onReset={() => {
setValue('token', '');
@ -153,59 +136,68 @@ export function ConfigForm({ data }: ConfigFormProps) {
}}
/>
</Field>
<TokenPermissionsInfo />
{gitFields.tokenUserConfig && (
<Field
label={t('provisioning.config-form.label-repository-url', 'Repository URL')}
noMargin
label={gitFields.tokenUserConfig.label}
required={gitFields.tokenUserConfig.required}
error={errors?.tokenUser?.message}
invalid={!!errors?.tokenUser}
description={gitFields.tokenUserConfig.description}
>
<Input
{...register('tokenUser', {
required: gitFields.tokenUserConfig.validation?.required,
})}
placeholder={gitFields.tokenUserConfig.placeholder}
/>
</Field>
)}
{type === 'github' && <TokenPermissionsInfo />}
<Field
noMargin
label={gitFields.urlConfig.label}
error={errors?.url?.message}
invalid={!!errors?.url}
description={t('provisioning.config-form.description-repository-url', 'Enter the GitHub repository URL')}
required
description={gitFields.urlConfig.description}
required={gitFields.urlConfig.required}
>
<Input
{...register('url', {
required: t('provisioning.config-form.error-required', 'This field is required.'),
pattern: {
value: /^(?:https:\/\/github\.com\/)?[^/]+\/[^/]+$/,
message: t(
'provisioning.config-form.error-valid-github-url',
'Please enter a valid GitHub repository URL'
),
},
required: gitFields.urlConfig.validation?.required,
pattern: gitFields.urlConfig.validation?.pattern,
})}
placeholder={t(
'provisioning.config-form.placeholder-github-url',
'https://github.com/username/repo-name'
)}
placeholder={gitFields.urlConfig.placeholder}
/>
</Field>
<Field label={t('provisioning.config-form.label-branch', 'Branch')}>
<Input {...register('branch')} placeholder={t('provisioning.config-form.placeholder-branch', 'main')} />
<Field noMargin label={gitFields.branchConfig.label} description={gitFields.branchConfig.description}>
<Input {...register('branch')} placeholder={gitFields.branchConfig.placeholder} />
</Field>
<Field
label={t('provisioning.config-form.label-path', 'Path')}
description={t('provisioning.config-form.description-path', 'Path to a subdirectory in the Git repository')}
>
<Field noMargin label={gitFields.pathConfig.label} description={gitFields.pathConfig.description}>
<Input {...register('path')} />
</Field>
</>
)}
{type === 'local' && (
{localFields && (
<Field
label={t('provisioning.config-form.label-local-path', 'Local path')}
noMargin
label={localFields.pathConfig.label}
error={errors?.path?.message}
invalid={!!errors?.path}
description={localFields.pathConfig.description}
required={localFields.pathConfig.required}
>
<Input
{...register('path', {
required: t('provisioning.config-form.error-required', 'This field is required.'),
required: localFields.pathConfig.validation?.required,
})}
placeholder={t('provisioning.config-form.placeholder-local-path', '/path/to/repo')}
placeholder={localFields.pathConfig.placeholder}
/>
</Field>
)}
<Field>
<Field noMargin>
<Checkbox
{...register('readOnly', {
onChange: (e) => {
@ -222,26 +214,26 @@ export function ConfigForm({ data }: ConfigFormProps) {
/>
</Field>
<Field>
{gitFields && (
<Field noMargin>
<Checkbox
disabled={readOnly}
{...register('prWorkflow')}
label={t('provisioning.config-form.label-pr-workflow', 'Enable pull request option when saving')}
description={
<Trans i18nKey="provisioning.finish-step.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.
</Trans>
}
label={gitFields.prWorkflowConfig.label}
description={gitFields.prWorkflowConfig.description}
/>
</Field>
)}
{type === 'github' && <ConfigFormGithubCollapse register={register} />}
{isGitBased && (
<ControlledCollapse
label={t('provisioning.config-form.label-automatic-pulling', 'Automatic pulling')}
isOpen={false}
>
<Stack direction="column" gap={2}>
<Field
noMargin
label={t('provisioning.config-form.label-enabled', 'Enabled')}
description={t(
'provisioning.config-form.description-enabled',
@ -251,6 +243,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
<Switch {...register('sync.enabled')} id={'sync.enabled'} />
</Field>
<Field
noMargin
label={t('provisioning.config-form.label-target', 'Target')}
required
error={errors?.sync?.target?.message}
@ -272,14 +265,16 @@ export function ConfigForm({ data }: ConfigFormProps) {
}}
/>
</Field>
<Field label={t('provisioning.config-form.label-interval-seconds', 'Interval (seconds)')}>
<Field noMargin label={t('provisioning.config-form.label-interval-seconds', 'Interval (seconds)')}>
<Input
{...register('sync.intervalSeconds', { valueAsNumber: true })}
type={'number'}
placeholder={t('provisioning.config-form.placeholder-interval-seconds', '60')}
/>
</Field>
</Stack>
</ControlledCollapse>
)}
<Stack gap={2}>
<Button type={'submit'} disabled={isLoading}>
@ -288,6 +283,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
: t('provisioning.config-form.button-save', 'Save')}
</Button>
</Stack>
</Stack>
</form>
);
}

View File

@ -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 (
<Stack direction="column" gap={3}>
<Text variant="h2">
<Trans i18nKey="provisioning.features-list.manage-your-dashboards-with-remote-provisioning">
Get started with GitSync
Get started with Git Sync
</Trans>
</Text>
<ul className={styles.featuresList}>
<li>
<Trans i18nKey="provisioning.features-list.manage-dashboards-provision-updates-automatically">
Manage dashboards as code in GitHub and provision updates automatically
Manage dashboards as code in Git and provision updates automatically
</Trans>
</li>
<li>
@ -45,7 +43,7 @@ export const FeaturesList = ({ repos, hasRequiredFeatures, onSetupFeatures }: Fe
</Box>
) : (
<Stack direction="row" alignItems="center" gap={2}>
<ConnectRepositoryButton items={repos} />
<RepositoryTypeCards />
</Stack>
)}
</Stack>

View File

@ -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) {
)}
<Stack direction="column" gap={6} wrap="wrap">
<Stack gap={10} alignItems="center">
<div className={styles.imageContainer}>
<img src={provisioningSvg} className={styles.image} alt={'Grafana provisioning'} />
</div>
<FeaturesList
repos={items}
hasRequiredFeatures={hasRequiredFeatures}
onSetupFeatures={() => {
setSetupType('required-features');
setShowModal(true);
}}
/>
<div className={styles.imageContainer}>
<img src={provisioningSvg} className={styles.image} alt={'Grafana provisioning'} />
</div>
</Stack>
{(!hasPublicAccess || !hasImageRenderer) && hasItems && (
<EnhancedFeatures

View File

@ -4,7 +4,8 @@ import { Repository } from 'app/api/clients/provisioning/v0alpha1';
import { StatusBadge } from '../Shared/StatusBadge';
import { PROVISIONING_URL } from '../constants';
import { getRepoHref } from '../utils/git';
import { getRepoHrefForProvider } from '../utils/git';
import { getRepositoryTypeConfig } from '../utils/repositoryTypes';
import { DeleteRepositoryButton } from './DeleteRepositoryButton';
import { SyncRepository } from './SyncRepository';
@ -15,14 +16,18 @@ interface RepositoryActionsProps {
export function RepositoryActions({ repository }: RepositoryActionsProps) {
const name = repository.metadata?.name ?? '';
const repoHref = getRepoHref(repository.spec?.github);
const repoHref = getRepoHrefForProvider(repository.spec);
const repoType = repository.spec?.type;
const repoConfig = repoType ? getRepositoryTypeConfig(repoType) : undefined;
const providerIcon = repoConfig?.icon || 'external-link-alt';
return (
<Stack>
<StatusBadge repo={repository} />
{repoHref && (
<Button variant="secondary" icon="github" onClick={() => window.open(repoHref, '_blank')}>
<Trans i18nKey="provisioning.repository-actions.source-code">Source Code</Trans>
<Button variant="secondary" icon={providerIcon} onClick={() => window.open(repoHref, '_blank')}>
<Trans i18nKey="provisioning.repository-actions.source-code">Source code</Trans>
</Button>
)}
<SyncRepository repository={repository} />

View File

@ -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,31 +28,30 @@ export function ConnectRepositoryButton({ items, showDropdown = false }: Props)
<Trans
i18nKey="provisioning.connect-repository-button.repository-limit-info-alert"
values={{ count: state.repoCount }}
defaults={'Repository limit reached ({{count}})'}
/>
>
Repository limit reached ({'{{count}}'})
</Trans>
</Alert>
);
}
if (showDropdown) {
const availableTypes = frontendSettings?.availableRepositoryTypes || DEFAULT_REPOSITORY_TYPES;
const { orderedConfigs } = getOrderedRepositoryConfigs(availableTypes);
return (
<Dropdown
overlay={
<Menu>
{orderedConfigs.map((config) => {
return (
<Menu.Item
icon="code-branch"
label={t('provisioning.connect-repository-button.configure-git-sync', 'Configure Git Sync')}
onClick={() => {
navigate(gitURL);
}}
/>
<Menu.Item
icon="file-alt"
label={t('provisioning.connect-repository-button.configure-file', 'Configure file provisioning')}
onClick={() => {
navigate(localURL);
}}
key={config.type}
icon={config.icon}
label={config.label}
onClick={() => navigate(`${CONNECT_URL}/${config.type}`)}
/>
);
})}
</Menu>
}
>
@ -68,16 +63,4 @@ export function ConnectRepositoryButton({ items, showDropdown = false }: Props)
</Button>
</Dropdown>
);
}
return (
<Stack gap={3}>
<LinkButton href={gitURL} variant="primary">
<Trans i18nKey="provisioning.connect-repository-button.configure-git-sync">Configure Git Sync</Trans>
</LinkButton>
<LinkButton href={localURL} variant="secondary">
<Trans i18nKey="provisioning.connect-repository-button.configure-file">Configure file provisioning</Trans>
</LinkButton>
</Stack>
);
}

View File

@ -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}
/>
<ConnectRepositoryButton items={items} showDropdown />
<ConnectRepositoryButton items={items} />
</Stack>
)}
<Stack direction={'column'}>
{filteredItems.length ? (
filteredItems.map((item) => <RepositoryCard key={item.metadata?.name} repository={item} />)
) : (
<EmptySearchResult>
<Trans i18nKey="provisioning.folder-repository-list.no-results-matching-your-query">
No results matching your query
</Trans>
</EmptySearchResult>
<EmptyState
variant="not-found"
message={t(
'provisioning.folder-repository-list.no-results-matching-your-query',
'No results matching your query'
)}
/>
)}
</Stack>
</Stack>

View File

@ -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 (
<Stack direction="column" gap={2}>
{gitProviders.length > 0 && (
<Stack direction="column">
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="provisioning.repository-type-cards.choose-provider">Choose a provider:</Trans>
</Text>
<Stack direction="row" gap={1} wrap>
{gitProviders.map((config) => (
<Card key={config.type} href={`${CONNECT_URL}/${config.type}`} className={styles.card} noMargin>
<Stack gap={2} alignItems="center">
<Icon name={config.icon} size="xxl" />
<Trans
i18nKey="provisioning.repository-type-cards.configure-with-provider"
values={{ provider: config.label }}
>
Configure with {'{{ provider }}'}
</Trans>
</Stack>
</Card>
))}
</Stack>
</Stack>
)}
<Stack direction="column">
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="provisioning.repository-type-cards.provider-not-listed">
If your provider is not listed:
</Trans>
</Text>
<Stack direction="row" gap={1} wrap>
{otherProviders.map((config) => (
<Card key={config.type} href={`${CONNECT_URL}/${config.type}`} className={styles.card} noMargin>
<Stack gap={2} alignItems="center">
<Icon name={config.icon} size="xxl" />
{config.type === 'local' ? (
<Trans i18nKey="provisioning.repository-type-cards.configure-file">Configure file provisioning</Trans>
) : (
<Trans
i18nKey="provisioning.repository-type-cards.configure-with-provider"
values={{ provider: config.label }}
>
Configure with {'{{ provider }}'}
</Trans>
)}
</Stack>
</Card>
))}
</Stack>
</Stack>
</Stack>
);
}
function getStyles() {
return {
card: css({
width: 220,
}),
};
}

View File

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

View File

@ -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() {
<Page
navId="provisioning"
pageNav={{
text: type === 'github' ? 'Configure Git Sync' : 'Configure local file path',
text: isGitProvider(type) ? 'Configure Git Sync' : 'Configure local file path',
subTitle: t(
'provisioning.connect-page.subTitle.connect-external-storage-manage-resources',
'Connect to an external storage to manage your resources'

View File

@ -1,11 +1,12 @@
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { t } from '@grafana/i18n';
import { Field, Input, SecretInput, Stack } from '@grafana/ui';
import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo';
import { isGitProvider } from '../utils/repositoryTypes';
import { getGitProviderFields, getLocalProviderFields } from './fields';
import { WizardFormData } from './types';
export function ConnectStep() {
@ -20,118 +21,123 @@ export function ConnectStep() {
const [tokenConfigured, setTokenConfigured] = useState(false);
const type = getValues('repository.type');
const isGithub = type === 'github';
const isGitBased = isGitProvider(type);
// Get field configurations based on provider type
const gitFields = isGitBased ? getGitProviderFields(type) : null;
const localFields = !isGitBased ? getLocalProviderFields(type) : null;
return (
<Stack direction="column">
{isGithub && (
<Stack direction="column" gap={2}>
{/*TODO: Add same permission info for other providers*/}
{type === 'github' && <TokenPermissionsInfo />}
{gitFields && (
<>
<TokenPermissionsInfo />
<Field
noMargin
label={t('provisioning.connect-step.label-access-token', 'GitHub access token')}
required
description={t(
'provisioning.connect-step.description-paste-your-git-hub-personal-access-token',
'Paste your GitHub personal access token'
)}
error={errors.repository?.token?.message}
invalid={!!errors.repository?.token}
label={gitFields.tokenConfig.label}
required={gitFields.tokenConfig.required}
description={gitFields.tokenConfig.description}
error={errors?.repository?.token?.message}
invalid={!!errors?.repository?.token?.message}
>
<Controller
name={'repository.token'}
name="repository.token"
control={control}
rules={{ required: t('provisioning.connect-step.error-field-required', 'This field is required.') }}
render={({ field: { ref, ...field } }) => {
return (
rules={gitFields.tokenConfig.validation}
render={({ field: { ref, ...field } }) => (
<SecretInput
{...field}
id={'token'}
placeholder={t(
'provisioning.connect-step.placeholder-github-token',
'github_pat_yourTokenHere1234567890abcdEFGHijklMNOP'
)}
id="token"
placeholder={gitFields.tokenConfig.placeholder}
isConfigured={tokenConfigured}
onReset={() => {
setValue('repository.token', '');
setTokenConfigured(false);
}}
/>
);
}}
)}
/>
</Field>
{gitFields.tokenUserConfig && (
<Field
noMargin
label={t('provisioning.connect-step.label-repository-url', 'GitHub repository URL')}
error={errors.repository?.url?.message}
invalid={!!errors.repository?.url}
description={t(
'provisioning.connect-step.description-repository-url',
'Paste the URL of your GitHub repository'
)}
required
label={gitFields.tokenUserConfig.label}
required={gitFields.tokenUserConfig.required}
description={gitFields.tokenUserConfig.description}
error={errors?.repository?.tokenUser?.message}
invalid={!!errors?.repository?.tokenUser?.message}
>
<Input
{...register('repository.url', {
required: t('provisioning.connect-step.error-field-required', 'This field is required.'),
pattern: {
// TODO: The regex is not correct when we support GHES.
value: /^(?:https:\/\/github\.com\/)?[^/]+\/[^/]+$/,
message: t(
'provisioning.connect-step.error-invalid-github-url',
'Please enter a valid GitHub repository URL'
),
},
})}
id={'repository-url'}
placeholder={t('provisioning.connect-step.placeholder-github-url', 'https://github.com/username/repo')}
{...register('repository.tokenUser', gitFields.tokenUserConfig.validation)}
id="tokenUser"
placeholder={gitFields.tokenUserConfig.placeholder}
/>
</Field>
)}
<Field
noMargin
label={t('provisioning.connect-step.label-branch', 'Branch name')}
description={t('provisioning.connect-step.description-branch', 'Branch to use for the GitHub repository')}
error={errors.repository?.branch?.message}
invalid={!!errors.repository?.branch}
label={gitFields.urlConfig.label}
description={gitFields.urlConfig.description}
error={errors?.repository?.url?.message}
invalid={!!errors?.repository?.url?.message}
required={gitFields.urlConfig.required}
>
<Input
{...register('repository.branch')}
id={'repository-branch'}
placeholder={t('provisioning.connect-step.placeholder-branch', 'main')}
{...register('repository.url', gitFields.urlConfig.validation)}
id="url"
placeholder={gitFields.urlConfig.placeholder}
/>
</Field>
<Field
noMargin
label={t('provisioning.connect-step.label-path', 'Path to subdirectory in repository')}
error={errors.repository?.path?.message}
invalid={!!errors.repository?.path}
description={t(
'provisioning.connect-step.description-github-path',
'This is the path to a subdirectory in your GitHub repository where dashboards will be stored and provisioned from'
)}
label={gitFields.branchConfig.label}
description={gitFields.branchConfig.description}
error={errors?.repository?.branch?.message}
invalid={!!errors?.repository?.branch?.message}
required={gitFields.branchConfig.required}
>
<Input {...register('repository.path')} id="repository-path" />
<Input
{...register('repository.branch', gitFields.branchConfig.validation)}
id="branch"
placeholder={gitFields.branchConfig.placeholder}
/>
</Field>
<Field
noMargin
label={gitFields.pathConfig.label}
description={gitFields.pathConfig.description}
error={errors?.repository?.path?.message}
invalid={!!errors?.repository?.path?.message}
required={gitFields.pathConfig.required}
>
<Input
{...register('repository.path', gitFields.pathConfig.validation)}
id="git-path"
placeholder={gitFields.pathConfig.placeholder}
/>
</Field>
</>
)}
{type === 'local' && (
{localFields && (
<Field
noMargin
label={t('provisioning.connect-step.label-local-path', 'Local path')}
error={errors.repository?.path?.message}
invalid={!!errors.repository?.path}
label={localFields.pathConfig.label}
description={localFields.pathConfig.description}
error={errors?.repository?.path?.message}
invalid={!!errors?.repository?.path?.message}
required={localFields.pathConfig.required}
>
<Input
{...register('repository.path', {
required: t('provisioning.connect-step.error-field-required', 'This field is required.'),
})}
id="repository-local-path"
placeholder={t('provisioning.connect-step.placeholder-local-path', '/path/to/repo')}
{...register('repository.path', localFields.pathConfig.validation)}
id="local-path"
placeholder={localFields.pathConfig.placeholder}
/>
</Field>
)}

View File

@ -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<WizardFormData>();
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 (
<Stack direction="column">
{isGithub && (
<Stack direction="column" gap={2}>
{isGitBased && (
<Field
label={t(
'provisioning.finish-step.label-update-instance-interval-seconds',
'Update instance interval (seconds)'
)}
noMargin
label={t('provisioning.finish-step.label-sync-interval', 'Sync Interval (seconds)')}
description={t(
'provisioning.finish-step.description-often-shall-instance-updates-git-hub',
'How often shall the instance pull updates from GitHub?'
'provisioning.finish-step.description-sync-interval',
'How often to sync changes from the repository'
)}
required
>
<Input
{...register('repository.sync.intervalSeconds', { valueAsNumber: true })}
{...register('repository.sync.intervalSeconds', {
valueAsNumber: true,
required: t('provisioning.finish-step.error-sync-interval-required', 'Sync interval is required'),
})}
type="number"
placeholder={t('provisioning.finish-step.placeholder', '60')}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="60"
/>
</Field>
)}
<Field>
<Field noMargin>
<Checkbox
{...register('repository.readOnly', {
onChange: (e) => {
@ -60,63 +69,46 @@ export function FinishStep() {
/>
</Field>
{isGithub && (
<>
<Field>
{gitFields && (
<Field noMargin>
<Checkbox
{...register('repository.prWorkflow')}
disabled={readOnly}
label={t('provisioning.finish-step.label-pr-workflow', 'Enable pull request option when saving')}
description={
<Trans i18nKey="provisioning.finish-step.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.
</Trans>
}
label={gitFields.prWorkflowConfig.label}
description={gitFields.prWorkflowConfig.description}
/>
</Field>
<Stack direction="column" gap={2}>
<Stack direction="column" gap={0}>
<Text element="h4">
<Trans i18nKey="provisioning.finish-step.title-enhance-github">Enhance your GitHub experience</Trans>
</Text>
<Text color="secondary" variant="bodySmall">
<Trans i18nKey="provisioning.finish-step.text-setup-later">You can always set this up later</Trans>
</Text>
</Stack>
<Field>
<Checkbox
disabled={!hasImageRenderer || !isPublic}
label={t(
'provisioning.finish-step.label-enable-previews',
'Enable dashboard previews in pull requests'
)}
{isGithub && (
<Field noMargin>
<Checkbox
{...register('repository.generateDashboardPreviews')}
label={t('provisioning.finish-step.label-generate-dashboard-previews', 'Generate Dashboard Previews')}
description={
<>
<Trans i18nKey="provisioning.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.
</Trans>{' '}
<Text italic>
<Trans i18nKey="provisioning.finish-step.description-image-rendering">
Requires image rendering.{' '}
<TextLink
variant="bodySmall"
external
href="https://grafana.com/grafana/plugins/grafana-image-renderer"
>
Set up image rendering
</TextLink>
<Trans i18nKey="provisioning.finish-step.description-generate-dashboard-previews">
Create preview links for pull requests
</Trans>
{(!isPublic || !hasImageRenderer) && (
<>
{' '}
<Text color="secondary">
<Trans i18nKey="provisioning.finish-step.description-preview-requirements">
(requires{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/setup-grafana/image-rendering/">
image rendering
</TextLink>{' '}
and public access enabled)
</Trans>
</Text>
</>
)}
</>
}
{...register('repository.generateDashboardPreviews')}
disabled={!isPublic || !hasImageRenderer}
/>
</Field>
</Stack>
</>
)}
</Stack>
);

View File

@ -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<typeof useGetResourceStatsQuery>;
const mockUseCreateRepositoryJobsMutation = useCreateRepositoryJobsMutation as jest.MockedFunction<
typeof useCreateRepositoryJobsMutation
>;
function setup(jsx: JSX.Element) {
return render(<StepStatusProvider>{jsx}</StepStatusProvider>);
}
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<typeof useGetFrontendSettingsQuery>);
mockUseGetRepositoryFilesQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: jest.fn(),
} as ReturnType<typeof useGetRepositoryFilesQuery>);
mockUseGetResourceStatsQuery.mockReturnValue({
data: {
dashboards: 0,
datasources: 0,
folders: 0,
libraryPanels: 0,
alertRules: 0,
},
isLoading: false,
error: null,
refetch: jest.fn(),
} as ReturnType<typeof useGetResourceStatsQuery>);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="gitlab" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="github" />);
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(<ProvisioningWizard type="gitlab" />);
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(<ProvisioningWizard type="bitbucket" />);
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(<ProvisioningWizard type="git" />);
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(<ProvisioningWizard type="local" />);
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(<ProvisioningWizard type="bitbucket" />);
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(<ProvisioningWizard type="git" />);
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();
});
});
});

View File

@ -165,12 +165,13 @@ 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']);
const fieldsToValidate =
activeStep === 'connection' ? (['repository'] as const) : (['repository', 'repository.title'] as const);
const isValid = await trigger(fieldsToValidate);
if (!isValid) {
return;
}
}
setIsSubmitting(true);
try {

View File

@ -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<WizardFormData>();
const repoType = watch('repository.type');
const supportsHistory = repoType === 'github' && isLegacyStorage;
const supportsHistory = isGitProvider(repoType) && isLegacyStorage;
const [job, setJob] = useState<Job>();
const startSynchronization = async () => {

View File

@ -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<RepoType, Record<string, FieldConfig>> => ({
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,
};
};

View File

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

View File

@ -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'];

View File

@ -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<RepositorySpec, 'github' | 'local' | 'workflows'> &
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<RepositoryFormData>; // Optional nested field path, e.g., 'sync.intervalSeconds'
validation?: {
required?: boolean | string;
message?: string;
validate?: (value: unknown) => boolean | string;
};
defaultValue?: SelectableValue<string> | string | boolean;
options?: Array<SelectableValue<string>>;
multi?: boolean;
allowCustomValue?: boolean;
hidden?: boolean;
content?: (setValue: UseFormReturn<RepositoryFormData>['setValue']) => ReactNode; // For custom fields
}
export type RepositoryFormData = Omit<RepositorySpec, 'workflows' | RepositorySpec['type']> &
BitbucketRepositoryConfig &
GitRepositoryConfig &
GitHubRepositoryConfig &
GitLabRepositoryConfig &
LocalRepositoryConfig & {
readOnly: boolean;
prWorkflow: boolean;
};
export type RepositorySettingsField = Path<RepositoryFormData>;
// 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<string, unknown>;
items?: HistoryItem[];
};

View File

@ -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),
};
switch (data.type) {
case 'github':
spec.github = {
generateDashboardPreviews: data.generateDashboardPreviews,
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,
};
// 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<RepositoryFormData, 'type' | 'url' | 'path'>): 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 '';
}
};

View File

@ -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<string, RepositoryFormPath> = {
path: 'repository.path',
branch: 'repository.branch',

View File

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

View File

@ -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],
};
};

View File

@ -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 build</2> 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 webhooks</0> 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 rendering</2>",
"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 rendering</2> 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",