mirror of https://github.com/grafana/grafana.git
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:
parent
a4d4ee1bc5
commit
a090480260
|
@ -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"],
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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(() => {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 };
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
@ -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 '';
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
};
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue