Provisioning: Simplify component logic (#103816)

This commit is contained in:
Alex Khomenko
2025-04-10 22:54:56 +03:00
committed by GitHub
parent 73ba19a98e
commit 0bad0526f5
13 changed files with 440 additions and 680 deletions
@@ -39,7 +39,7 @@ export function getDefaultValues(repository?: RepositorySpec): RepositoryFormDat
path: 'grafana/',
sync: {
enabled: false,
target: 'folder',
target: 'instance',
intervalSeconds: 60,
},
};
@@ -47,6 +47,20 @@ export function getDefaultValues(repository?: RepositorySpec): RepositoryFormDat
return specToData(repository);
}
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 = [
{ 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 {
data?: Repository;
}
@@ -66,22 +80,8 @@ export function ConfigForm({ data }: ConfigFormProps) {
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
const navigate = useNavigate();
const [type, readOnly] = watch(['type', 'readOnly']);
const typeOptions = useMemo(
() => [
{ value: 'github', label: t('provisioning.config-form.option-github', 'GitHub') },
{ value: 'local', label: t('provisioning.config-form.option-local', 'Local') },
],
[]
);
const targetOptions = useMemo(
() => [
{ value: 'instance', label: t('provisioning.config-form.option-entire-instance', 'Entire instance') },
{ value: 'folder', label: t('provisioning.config-form.option-managed-folder', 'Managed folder') },
],
[]
);
const [typeOptions, targetOptions] = useMemo(() => getOptions(), []);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (request.isSuccess) {
@@ -93,14 +93,16 @@ export function ConfigForm({ data }: ConfigFormProps) {
}
}, [request.isSuccess, reset, getValues, navigate]);
const onSubmit = (form: RepositoryFormData) => {
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;
}
submitData(spec);
await submitData(spec);
setIsLoading(false);
};
// NOTE: We do not want the lint option to be listed.
@@ -202,7 +204,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
label={t('provisioning.config-form.label-path', 'Path')}
description={t('provisioning.config-form.description-path', 'Path to a subdirectory in the Git repository')}
>
<Input {...register('path')} placeholder={t('provisioning.config-form.placeholder-path', 'grafana/')} />
<Input {...register('path')} />
</Field>
</>
)}
@@ -303,8 +305,8 @@ export function ConfigForm({ data }: ConfigFormProps) {
</ControlledCollapse>
<Stack gap={2}>
<Button type={'submit'} disabled={request.isLoading}>
{request.isLoading
<Button type={'submit'} disabled={isLoading}>
{isLoading
? t('provisioning.config-form.button-saving', 'Saving...')
: t('provisioning.config-form.button-save', 'Save')}
</Button>
@@ -4,24 +4,17 @@ import { Alert, Spinner, Stack, Text } from '@grafana/ui';
import { useGetRepositoryJobsWithPathQuery } from 'app/api/clients/provisioning';
import { Trans, t } from 'app/core/internationalization';
import { StepStatusInfo } from '../Wizard/types';
import { JobContent } from './JobContent';
import { useJobStatusEffect } from './hooks';
export interface FinishedJobProps {
jobUid: string;
repositoryName: string;
onStatusChange?: (success: boolean) => void;
onRunningChange?: (isRunning: boolean) => void;
onErrorChange?: (error: string | null) => void;
onStatusChange: (status: StepStatusInfo, error?: string) => void;
}
export function FinishedJobStatus({
jobUid,
repositoryName,
onStatusChange,
onRunningChange,
onErrorChange,
}: FinishedJobProps) {
export function FinishedJobStatus({ jobUid, repositoryName, onStatusChange }: FinishedJobProps) {
const hasRetried = useRef(false);
const finishedQuery = useGetRepositoryJobsWithPathQuery({
name: repositoryName,
@@ -31,8 +24,6 @@ export function FinishedJobStatus({
const job = finishedQuery.data;
useJobStatusEffect(job, onStatusChange, onRunningChange, onErrorChange);
useEffect(() => {
const shouldRetry = !job && !hasRetried.current && !finishedQuery.isFetching;
let timeoutId: ReturnType<typeof setTimeout>;
@@ -44,14 +35,19 @@ export function FinishedJobStatus({
}, 1000);
}
if (finishedQuery.isSuccess) {
onStatusChange({ status: 'success' });
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [finishedQuery, job]);
}, [finishedQuery, job, onStatusChange]);
if (retryFailed) {
onStatusChange({ status: 'error' });
return (
<Alert severity="error" title={t('provisioning.job-status.no-job-found', 'No job found')}>
<Trans i18nKey="provisioning.job-status.no-job-found-message">
@@ -2,17 +2,17 @@ import { Spinner, Stack, Text } from '@grafana/ui';
import { Job, useListJobQuery } from 'app/api/clients/provisioning';
import { Trans } from 'app/core/internationalization';
import { StepStatusInfo } from '../Wizard/types';
import { ActiveJobStatus } from './ActiveJobStatus';
import { FinishedJobStatus } from './FinishedJobStatus';
export interface JobStatusProps {
watch: Job;
onStatusChange?: (success: boolean) => void;
onRunningChange?: (isRunning: boolean) => void;
onErrorChange?: (error: string | null) => void;
onStatusChange: (status: StepStatusInfo, error?: string) => void;
}
export function JobStatus({ watch, onStatusChange, onRunningChange, onErrorChange }: JobStatusProps) {
export function JobStatus({ watch, onStatusChange }: JobStatusProps) {
const activeQuery = useListJobQuery({
fieldSelector: `metadata.name=${watch.metadata?.name}`,
watch: true,
@@ -36,25 +36,12 @@ export function JobStatus({ watch, onStatusChange, onRunningChange, onErrorChang
}
if (activeJob) {
return (
<ActiveJobStatus
job={activeJob}
onStatusChange={onStatusChange}
onRunningChange={onRunningChange}
onErrorChange={onErrorChange}
/>
);
return <ActiveJobStatus job={activeJob} />;
}
if (shouldCheckFinishedJobs) {
return (
<FinishedJobStatus
jobUid={watch.metadata?.uid!}
repositoryName={repoLabel}
onStatusChange={onStatusChange}
onRunningChange={onRunningChange}
onErrorChange={onErrorChange}
/>
<FinishedJobStatus jobUid={watch.metadata?.uid!} repositoryName={repoLabel} onStatusChange={onStatusChange} />
);
}
@@ -14,16 +14,12 @@ export function RepositoryLink({ name }: RepositoryLinkProps) {
const repoQuery = useGetRepositoryQuery(name ? { name } : skipToken);
const repo = repoQuery.data;
if (!repo || repoQuery.isLoading || repo.spec?.type !== 'github' || !repo.spec?.github?.url) {
if (!repo || repoQuery.isLoading) {
return null;
}
const repoHref = getRepoHref(repo.spec?.github);
if (!repoHref) {
return null;
}
return (
<Stack direction="column" gap={1}>
<Text>
@@ -32,11 +28,13 @@ export function RepositoryLink({ name }: RepositoryLinkProps) {
and the external storage will be synchronized.
</Trans>
</Text>
<Stack direction="row" gap={2}>
<TextLink href={repoHref} external>
<Trans i18nKey="provisioning.repository-link.view-repository">View repository</Trans>
</TextLink>
</Stack>
{repoHref && (
<Stack direction="row" gap={2}>
<TextLink href={repoHref} external>
<Trans i18nKey="provisioning.repository-link.view-repository">View repository</Trans>
</TextLink>
</Stack>
)}
</Stack>
);
}
@@ -1,78 +1,66 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import {
Alert,
Box,
Card,
Field,
FieldSet,
Icon,
Input,
LoadingPlaceholder,
Stack,
Switch,
Text,
Tooltip,
} from '@grafana/ui';
import { Box, Card, Field, Input, LoadingPlaceholder, Stack, Text } from '@grafana/ui';
import { RepositoryViewList, useGetRepositoryFilesQuery, useGetResourceStatsQuery } from 'app/api/clients/provisioning';
import { t, Trans } from 'app/core/internationalization';
import { StepStatus } from '../hooks/useStepStatus';
import { getState } from './actions';
import { ModeOption, WizardFormData } from './types';
import { getResourceStats, useModeOptions } from './actions';
import { StepStatusInfo, WizardFormData } from './types';
interface Props {
onOptionSelect: (requiresMigration: boolean) => void;
onStepUpdate: (status: StepStatus, error?: string) => void;
onStepStatusUpdate: (info: StepStatusInfo) => void;
settingsData?: RepositoryViewList;
repoName: string;
}
export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) {
export function BootstrapStep({ onOptionSelect, settingsData, repoName, onStepStatusUpdate }: Props) {
const {
register,
control,
setValue,
watch,
getValues,
formState: { errors },
} = useFormContext<WizardFormData>();
const selectedTarget = watch('repository.sync.target');
const repoType = watch('repository.type');
const resourceStats = useGetResourceStatsQuery();
const filesQuery = useGetRepositoryFilesQuery({ name: repoName });
const [selectedOption, setSelectedOption] = useState<ModeOption | null>(null);
const state = useMemo(() => {
return getState(repoName, settingsData, filesQuery.data, resourceStats.data);
}, [repoName, settingsData, resourceStats.data, filesQuery.data]);
const selectedTarget = watch('repository.sync.target');
const options = useModeOptions(repoName, settingsData);
const { resourceCount, resourceCountString, fileCount } = useMemo(
() => getResourceStats(filesQuery.data, resourceStats.data),
[filesQuery.data, resourceStats.data]
);
useEffect(() => {
if (state.actions.length && !selectedOption) {
const first = state.actions[0];
setSelectedOption(first);
onOptionSelect(first.operation === 'migrate');
setValue('repository.sync.target', first.target);
// 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;
}
}, [state, selectedOption, setValue, onOptionSelect]);
}, [getValues, setValue]);
const handleOptionSelect = useCallback(
(option: ModeOption) => {
// Select the new option and update form state
setSelectedOption(option);
setValue('repository.sync.target', option.target);
useEffect(() => {
const isLoading = resourceStats.isLoading || filesQuery.isLoading;
onStepStatusUpdate({ status: isLoading ? 'running' : 'idle' });
}, [filesQuery.isLoading, onStepStatusUpdate, resourceStats.isLoading]);
if (option.operation === 'migrate') {
setValue('migrate.history', true);
setValue('migrate.identifier', true);
}
onOptionSelect(option.operation === 'migrate');
},
[setValue, onOptionSelect]
);
// Auto select the first option on mount
useEffect(() => {
const { target } = options[0];
setValue('repository.sync.target', target);
onOptionSelect(target !== 'folder' || resourceCount > 0);
// Only run this effect on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (resourceStats.isLoading || filesQuery.isLoading) {
return (
@@ -84,9 +72,6 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
);
}
// Show the history selection
const canIncludeHistory = repoType === 'github' && settingsData?.legacyStorage;
return (
<Stack direction="column" gap={2}>
<Stack direction="column" gap={2}>
@@ -98,9 +83,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
</Text>
<Stack direction="row" gap={2}>
<Text variant="h4">
{state.resourceCount > 0
? state.resourceCountString
: t('provisioning.bootstrap-step.empty', 'Empty')}
{resourceCount > 0 ? resourceCountString : t('provisioning.bootstrap-step.empty', 'Empty')}
</Text>
</Stack>
</Stack>
@@ -109,8 +92,8 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
<Trans i18nKey="provisioning.bootstrap-step.ext-storage">External storage</Trans>
</Text>
<Text variant="h4">
{state.fileCount > 0
? t('provisioning.bootstrap-step.files-count', '{{count}} files', { count: state.fileCount })
{fileCount > 0
? t('provisioning.bootstrap-step.files-count', '{{count}} files', { count: fileCount })
: t('provisioning.bootstrap-step.empty', 'Empty')}
</Text>
</Stack>
@@ -120,16 +103,16 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
<Controller
name="repository.sync.target"
control={control}
render={() => (
render={({ field: { ref, onChange, ...field } }) => (
<>
{state.actions.map((action, index) => (
{options.map((action, index) => (
<Card
key={`${action.target}-${action.operation}`}
isSelected={action === selectedOption}
key={action.target}
isSelected={action.target === selectedTarget}
onClick={() => {
handleOptionSelect(action);
onChange(action.target);
}}
autoFocus={index === 0}
{...field}
>
<Card.Heading>{action.label}</Card.Heading>
<Card.Description>
@@ -144,54 +127,6 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
)}
/>
{/* Add migration options */}
{selectedOption?.operation === 'migrate' && (
<>
{Boolean(state.resourceCount) && (
<Alert severity="info" title={t('provisioning.bootstrap-step.title-note', 'Note')}>
<Trans i18nKey="provisioning.bootstrap-step.dashboards-unavailable-while-running-process">
Dashboards will be unavailable while running this process
</Trans>
</Alert>
)}
{Boolean(state.fileCount) && Boolean(state.resourceCount) && (
<Alert
title={t('provisioning.bootstrap-step.title-files-exist-in-the-target', 'Files exist in the target')}
severity="info"
>
<Trans
i18nKey="provisioning.bootstrap-step.resources-will-be-added"
defaults="The {{count}} resources in grafana will be added to the repository. Grafana will then include both the current resources and anything from the repository when done."
values={{ count: state.resourceCount }}
/>
</Alert>
)}
{canIncludeHistory && (
<FieldSet label={t('provisioning.bootstrap-step.label-migrate-options', 'Migrate options')}>
<Stack direction="column" gap={2}>
{canIncludeHistory && (
<Stack direction="row" gap={2} alignItems="center">
<Switch {...register('migrate.history')} defaultChecked={true} />
<Text>
<Trans i18nKey="provisioning.bootstrap-step.include-history">Include history</Trans>
</Text>
<Tooltip
content={t(
'provisioning.bootstrap-step.tooltip-include-history',
'Include complete dashboard version history'
)}
placement="top"
>
<Icon name="info-circle" />
</Tooltip>
</Stack>
)}
</Stack>
</FieldSet>
)}
</>
)}
{/* Only show title field if folder sync */}
{selectedTarget === 'folder' && (
<Field
@@ -213,7 +148,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props)
'My repository connection'
)}
// Autofocus the title field if it's the only available option
autoFocus={state.actions.length === 1 && state.actions[0].target === 'folder'}
autoFocus={options.length === 1 && options[0].target === 'folder'}
/>
</Field>
)}
@@ -107,10 +107,7 @@ export function ConnectStep() {
'This is the path to a subdirectory in your GitHub repository where dashboards will be stored and provisioned from'
)}
>
<Input
{...register('repository.path')}
placeholder={t('provisioning.connect-step.placeholder-github-path', 'grafana/')}
/>
<Input {...register('repository.path')} />
</Field>
</>
)}
@@ -1,75 +1,134 @@
import { useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { useCallback, useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { Alert, Box, Button, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { t } from 'app/core/internationalization';
import { getDefaultValues } from '../Config/ConfigForm';
import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { dataToSpec } from '../utils/data';
import { Step } from './Stepper';
import { WizardContent } from './WizardContent';
import { RepoType, WizardFormData, WizardStep } from './types';
import { BootstrapStep } from './BootstrapStep';
import { ConnectStep } from './ConnectStep';
import { FinishStep } from './FinishStep';
import { RequestErrorAlert } from './RequestErrorAlert';
import { Step, Stepper } from './Stepper';
import { SynchronizeStep } from './SynchronizeStep';
import { RepoType, StepStatusInfo, WizardFormData, WizardStep } from './types';
const appEvents = getAppEvents();
const getSteps = (): Array<Step<WizardStep>> => {
return [
{
id: 'connection',
name: t('provisioning.wizard.step-connect', 'Connect'),
title: t('provisioning.wizard.title-connect', 'Connect to external storage'),
submitOnNext: true,
},
{
id: 'bootstrap',
name: t('provisioning.wizard.step-bootstrap', 'Choose what to synchronize'),
title: t('provisioning.wizard.title-bootstrap', 'Choose what to synchronize'),
submitOnNext: true,
},
{
id: 'synchronize',
name: t('provisioning.wizard.step-synchronize', 'Synchronize'),
title: t('provisioning.wizard.title-synchronize', 'Synchronize with external storage'),
submitOnNext: false,
},
{
id: 'finish',
name: t('provisioning.wizard.step-finish', 'Choose additional settings'),
title: t('provisioning.wizard.title-finish', 'Choose additional settings'),
submitOnNext: true,
},
];
};
export function ProvisioningWizard({ type }: { type: RepoType }) {
const [activeStep, setActiveStep] = useState<WizardStep>('connection');
const [completedSteps, setCompletedSteps] = useState<WizardStep[]>([]);
const [stepSuccess, setStepSuccess] = useState(false);
const [requiresMigration, setRequiresMigration] = useState(false);
const [stepStatusInfo, setStepStatusInfo] = useState<StepStatusInfo>({ status: 'idle' });
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const settingsQuery = useGetFrontendSettingsQuery();
const navigate = useNavigate();
const steps = getSteps();
const styles = useStyles2(getStyles);
const values = getDefaultValues();
const steps = useMemo<Array<Step<WizardStep>>>(
() => [
{
id: 'connection',
name: t('provisioning.wizard.step-connect', 'Connect'),
title: t('provisioning.wizard.title-connect', 'Connect to external storage'),
submitOnNext: true,
},
{
id: 'bootstrap',
name: t('provisioning.wizard.step-bootstrap', 'Choose what to synchronize'),
title: t('provisioning.wizard.title-bootstrap', 'Choose what to synchronize'),
submitOnNext: true,
},
{
id: 'synchronize',
name: t('provisioning.wizard.step-synchronize', 'Synchronize'),
title: t('provisioning.wizard.title-synchronize', 'Synchronize with external storage'),
submitOnNext: false,
},
{
id: 'finish',
name: t('provisioning.wizard.step-finish', 'Choose additional settings'),
title: t('provisioning.wizard.title-finish', 'Choose additional settings'),
submitOnNext: true,
},
],
[]
);
const methods = useForm<WizardFormData>({
defaultValues: {
repository: { ...values, type },
migrate: {
history: true,
identifier: true, // Keep the same URLs
},
},
});
const handleStatusChange = useCallback(
(success: boolean) => {
setStepSuccess(success);
if (success) {
setCompletedSteps((prev) => [...prev, activeStep]);
}
},
[activeStep]
);
const {
watch,
setValue,
getValues,
trigger,
formState: { isDirty },
} = methods;
const repoName = watch('repositoryName');
const [submitData, saveRequest] = useCreateOrUpdateRepository(repoName);
const [deleteRepository] = useDeleteRepositoryMutation();
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
const currentStepConfig = steps[currentStepIndex];
const isStepSuccess = stepStatusInfo.status === 'success';
// A different repository is marked with instance target -- nothing will succeed
useEffect(() => {
if (settingsQuery.data?.items.some((item) => item.target === 'instance' && item.name !== repoName)) {
appEvents.publish({
type: AppEvents.alertError.name,
payload: [
t('provisioning.wizard-content.error-instance-repository-exists', 'Instance repository already exists'),
],
});
navigate(PROVISIONING_URL);
}
}, [navigate, repoName, settingsQuery.data?.items]);
const handleRepositoryDeletion = async (name: string) => {
try {
await deleteRepository({ name });
// Wait before redirecting to ensure deletion is processed
setTimeout(() => {
navigate(PROVISIONING_URL);
}, 1000);
} catch (error) {
setIsCancelling(false);
}
};
const handleCancel = async () => {
// For the first step, do not delete anything — just go back.
if (activeStep === 'connection' || !repoName) {
navigate(PROVISIONING_URL);
return;
}
setIsCancelling(true);
handleRepositoryDeletion(repoName);
};
// Calculate button text based on current step position
const getNextButtonText = useCallback(
@@ -87,61 +146,154 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
);
const handleNext = async () => {
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
const isLastStep = currentStepIndex === steps.length - 1;
if (activeStep === 'connection') {
// Validate repository form data before proceeding
const isValid = await methods.trigger('repository');
if (!isValid) {
return;
}
// Pick a name nice name based on type+settings
const current = methods.getValues();
switch (current.repository.type) {
case 'github':
const name = current.repository.url ?? 'github';
methods.setValue('repository.title', name.replace('https://github.com/', ''));
break;
case 'local':
methods.setValue('repository.title', current.repository.path ?? 'local');
break;
}
}
// Only navigate to provisioning URL if we're on the actual last step and it's completed
if (isLastStep && stepSuccess) {
settingsQuery.refetch();
// Only navigate to provisioning URL if we're on the actual last step
if (isLastStep) {
navigate(PROVISIONING_URL);
return;
}
// For all other cases, proceed to next step
if (currentStepIndex < steps.length - 1) {
} else {
setActiveStep(steps[currentStepIndex + 1].id);
setStepSuccess(false);
// Update completed steps only if the current step was successful
if (stepSuccess) {
setCompletedSteps((prev) => [...prev, activeStep]);
setCompletedSteps((prev) => [...new Set([...prev, activeStep])]);
setStepStatusInfo({ status: 'idle' });
}
};
const onSubmit = async () => {
if (currentStepConfig?.submitOnNext) {
// Validate form data before proceeding
if (activeStep === 'connection' || activeStep === 'bootstrap') {
const isValid = await trigger(['repository', 'repository.title']);
if (!isValid) {
return;
}
}
setIsSubmitting(true);
try {
const formData = getValues();
const spec = dataToSpec(formData.repository);
const rsp = await submitData(spec);
if (rsp.error) {
setStepStatusInfo({
status: 'error',
error: 'Repository request failed',
});
return;
}
// Fill in the k8s name from the initial POST response
const name = rsp.data?.metadata?.name;
if (name) {
setValue('repositoryName', name);
setStepStatusInfo({ status: 'success' });
handleNext();
} else {
console.error('Saved repository without a name:', rsp);
}
} catch (error) {
setStepStatusInfo({
status: 'error',
error: 'Repository connection failed',
});
} finally {
setIsSubmitting(false);
}
} else {
// only proceed if the job was successful
if (isStepSuccess) {
handleNext();
}
}
};
const isNextButtonDisabled = () => {
if (activeStep === 'synchronize') {
return stepStatusInfo.status !== 'success';
}
return isSubmitting || isCancelling || stepStatusInfo.status === 'running' || stepStatusInfo.status === 'error';
};
return (
<FormProvider {...methods}>
<WizardContent
activeStep={activeStep}
completedSteps={completedSteps}
availableSteps={steps}
requiresMigration={requiresMigration}
handleStatusChange={handleStatusChange}
handleNext={handleNext}
getNextButtonText={getNextButtonText}
onOptionSelect={setRequiresMigration}
stepSuccess={stepSuccess}
settingsData={settingsQuery.data}
/>
<Stack gap={6} direction="row" alignItems="flex-start">
<Stepper steps={steps} activeStep={activeStep} visitedSteps={completedSteps} />
<div className={styles.divider} />
<form className={styles.form}>
<FormPrompt onDiscard={handleCancel} confirmRedirect={isDirty && activeStep !== 'finish' && !isCancelling} />
<Stack direction="column">
<Box marginBottom={2}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Text element="h2">
{currentStepIndex + 1}. {currentStepConfig?.title}
</Text>
</Box>
<RequestErrorAlert
request={saveRequest}
title={t(
'provisioning.wizard-content.title-repository-verification-failed',
'Repository verification failed'
)}
/>
<div className={styles.content}>
{activeStep === 'connection' && <ConnectStep />}
{activeStep === 'bootstrap' && (
<BootstrapStep
onOptionSelect={setRequiresMigration}
onStepStatusUpdate={setStepStatusInfo}
settingsData={settingsQuery.data}
repoName={repoName ?? ''}
/>
)}
{activeStep === 'synchronize' && (
<SynchronizeStep onStepStatusUpdate={setStepStatusInfo} requiresMigration={requiresMigration} />
)}
{activeStep === 'finish' && <FinishStep />}
</div>
{stepStatusInfo.status === 'error' && (
<Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />
)}
<Stack gap={2} justifyContent="flex-end">
<Button
variant={stepStatusInfo.status === 'error' ? 'primary' : 'secondary'}
onClick={handleCancel}
disabled={isSubmitting || isCancelling}
>
{isCancelling
? t('provisioning.wizard-content.button-cancelling', 'Cancelling...')
: t('provisioning.wizard-content.button-cancel', 'Cancel')}
</Button>
<Button onClick={onSubmit} disabled={isNextButtonDisabled()}>
{isSubmitting
? t('provisioning.wizard-content.button-submitting', 'Submitting...')
: getNextButtonText(activeStep)}
</Button>
</Stack>
</Stack>
</form>
</Stack>
</FormProvider>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
form: css({
maxWidth: '900px',
flexGrow: 1,
}),
divider: css({
width: 1,
alignSelf: 'stretch',
backgroundColor: theme.colors.border.weak,
// align with the button row
marginBottom: theme.spacing(13),
}),
content: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
paddingBottom: theme.spacing(4),
marginBottom: theme.spacing(4),
}),
});
@@ -6,29 +6,31 @@ import { Job, useCreateRepositoryJobsMutation } from 'app/api/clients/provisioni
import { t, Trans } from 'app/core/internationalization';
import { JobStatus } from '../Job/JobStatus';
import { StepStatus } from '../hooks/useStepStatus';
import { WizardFormData } from './types';
import { StepStatusInfo, WizardFormData } from './types';
export interface SynchronizeStepProps {
onStepUpdate: (status: StepStatus, error?: string) => void;
onStepStatusUpdate: (info: StepStatusInfo) => void;
requiresMigration: boolean;
}
export function SynchronizeStep({ onStepUpdate, requiresMigration }: SynchronizeStepProps) {
export function SynchronizeStep({ onStepStatusUpdate, requiresMigration }: SynchronizeStepProps) {
const [createJob] = useCreateRepositoryJobsMutation();
const { getValues, register } = useFormContext<WizardFormData>();
const [history, repoName] = getValues(['migrate.history', 'repositoryName']);
const [job, setJob] = useState<Job>();
const startSynchronization = async () => {
const [history, repoName] = getValues(['migrate.history', 'repositoryName']);
if (!repoName) {
onStepUpdate('error', t('provisioning.synchronize-step.error-no-repository-name', 'No repository name provided'));
onStepStatusUpdate({
status: 'error',
error: t('provisioning.synchronize-step.error-no-repository-name', 'No repository name provided'),
});
return;
}
try {
onStepUpdate('running');
onStepStatusUpdate({ status: 'running' });
const jobSpec = requiresMigration
? {
migrate: {
@@ -47,37 +49,22 @@ export function SynchronizeStep({ onStepUpdate, requiresMigration }: Synchronize
}).unwrap();
if (!response?.metadata?.name) {
return onStepUpdate('error', t('provisioning.synchronize-step.error-no-job-id', 'Failed to start job'));
return onStepStatusUpdate({
status: 'error',
error: t('provisioning.synchronize-step.error-no-job-id', 'Failed to start job'),
});
}
setJob(response);
} catch (error) {
onStepUpdate('error', t('provisioning.synchronize-step.error-starting-job', 'Error starting job'));
onStepStatusUpdate({
status: 'error',
error: t('provisioning.synchronize-step.error-starting-job', 'Error starting job'),
});
}
};
if (job) {
return (
<JobStatus
watch={job}
onStatusChange={(success) => {
if (success) {
onStepUpdate('success');
} else {
onStepUpdate('error', t('provisioning.synchronize-step.error-job-failed', 'Job failed'));
}
}}
onRunningChange={(isRunning) => {
if (isRunning) {
onStepUpdate('running');
}
}}
onErrorChange={(error) => {
if (error) {
onStepUpdate('error', error);
}
}}
/>
);
return <JobStatus watch={job} onStatusChange={onStepStatusUpdate} />;
}
return (
@@ -1,262 +0,0 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { Alert, Box, Button, Stack, Text, useStyles2 } from '@grafana/ui';
import {
RepositoryViewList,
useDeleteRepositoryMutation,
useGetFrontendSettingsQuery,
} from 'app/api/clients/provisioning';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { t } from 'app/core/internationalization';
import { PROVISIONING_URL } from '../constants';
import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository';
import { StepStatus } from '../hooks/useStepStatus';
import { dataToSpec } from '../utils/data';
import { BootstrapStep } from './BootstrapStep';
import { ConnectStep } from './ConnectStep';
import { FinishStep } from './FinishStep';
import { RequestErrorAlert } from './RequestErrorAlert';
import { Step, Stepper } from './Stepper';
import { SynchronizeStep } from './SynchronizeStep';
import { WizardFormData, WizardStep } from './types';
const appEvents = getAppEvents();
interface WizardContentProps {
activeStep: WizardStep;
completedSteps: WizardStep[];
availableSteps: Array<Step<WizardStep>>;
requiresMigration: boolean;
handleStatusChange: (success: boolean) => void;
handleNext: () => void;
getNextButtonText: (step: WizardStep) => string;
onOptionSelect: (requiresMigration: boolean) => void;
stepSuccess: boolean;
settingsData?: RepositoryViewList;
}
export function WizardContent({
activeStep,
completedSteps,
availableSteps,
requiresMigration,
handleStatusChange,
handleNext,
getNextButtonText,
onOptionSelect,
stepSuccess,
settingsData,
}: WizardContentProps) {
const {
watch,
setValue,
getValues,
trigger,
formState: { isDirty },
} = useFormContext<WizardFormData>();
const navigate = useNavigate();
const repoName = watch('repositoryName');
const [submitData, saveRequest] = useCreateOrUpdateRepository(repoName);
const [deleteRepository] = useDeleteRepositoryMutation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [stepStatus, setStepStatus] = useState<StepStatus>('idle');
const [stepError, setStepError] = useState<string | undefined>();
const styles = useStyles2(getStyles);
const settingsQuery = useGetFrontendSettingsQuery();
const currentStep = availableSteps.find((s) => s.id === activeStep);
const currentStepIndex = availableSteps.findIndex((s) => s.id === activeStep);
const handleStepUpdate = useCallback((status: StepStatus, error?: string) => {
setStepStatus(status);
setStepError(error);
}, []);
// A different repository is marked with instance target -- nothing will succeed
if (settingsQuery.data?.items.some((item) => item.target === 'instance' && item.name !== repoName)) {
appEvents.publish({
type: AppEvents.alertError.name,
payload: [
t('provisioning.wizard-content.error-instance-repository-exists', 'Instance repository already exists'),
],
});
if (repoName) {
console.warn('Should we delete the pending repo?', repoName);
}
navigate(PROVISIONING_URL);
return null;
}
const handleRepositoryDeletion = async (name: string) => {
try {
await deleteRepository({ name });
// Wait before redirecting to ensure deletion is processed
setTimeout(() => {
navigate(PROVISIONING_URL);
}, 1000);
} catch (error) {
setIsCancelling(false);
}
};
const handleCancel = async () => {
// For the first step, do not delete anything—just go back.
if (activeStep === 'connection' || !repoName) {
navigate(PROVISIONING_URL);
return;
}
setIsCancelling(true);
void handleRepositoryDeletion(repoName);
};
const handleNextWithSubmit = async () => {
if (currentStep?.submitOnNext) {
// Validate form data before proceeding
if (activeStep === 'connection' || activeStep === 'bootstrap') {
const isValid = await trigger(['repository', 'repository.title']);
if (!isValid) {
return;
}
}
setIsSubmitting(true);
try {
const formData = getValues();
const spec = dataToSpec(formData.repository);
const rsp = await submitData(spec);
if (rsp.error) {
// Error is displayed in <RequestErrorAlert/>
return;
}
// Fill in the k8s name from the initial POST response
const name = rsp.data?.metadata?.name;
if (name) {
setValue('repositoryName', name);
handleNext();
} else {
console.error('Saved repository without a name:', rsp);
}
} catch (error) {
console.error('Repository connection failed:', error);
handleStatusChange(false);
} finally {
setIsSubmitting(false);
}
} else {
// only proceed if the job was successful
if (stepSuccess || stepStatus === 'success') {
handleNext();
}
}
};
useEffect(() => {
if (saveRequest.isSuccess) {
const newName = saveRequest.data?.metadata?.name;
if (newName) {
setValue('repositoryName', newName);
handleStatusChange(true);
}
} else if (saveRequest.isError) {
handleStatusChange(false);
}
}, [saveRequest, setValue, handleStatusChange]);
const isNextButtonDisabled = () => {
if (activeStep === 'synchronize') {
return stepStatus !== 'success';
}
return isSubmitting || isCancelling || stepStatus === 'running' || stepStatus === 'error';
};
return (
<Stack gap={6} direction="row" alignItems="flex-start">
<Stepper steps={availableSteps} activeStep={activeStep} visitedSteps={completedSteps} />
<div className={styles.divider} />
<form className={styles.form}>
<FormPrompt onDiscard={handleCancel} confirmRedirect={isDirty && activeStep !== 'finish' && !isCancelling} />
<Stack direction="column">
<Box marginBottom={2}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Text element="h2">
{currentStepIndex + 1}. {currentStep?.title}
</Text>
</Box>
<RequestErrorAlert
request={saveRequest}
title={t(
'provisioning.wizard-content.title-repository-verification-failed',
'Repository verification failed'
)}
/>
<div className={styles.content}>
{activeStep === 'connection' && <ConnectStep />}
{activeStep === 'bootstrap' && (
<BootstrapStep
onOptionSelect={onOptionSelect}
onStepUpdate={handleStepUpdate}
settingsData={settingsData}
repoName={repoName ?? ''}
/>
)}
{activeStep === 'synchronize' && (
<SynchronizeStep onStepUpdate={handleStepUpdate} requiresMigration={requiresMigration} />
)}
{activeStep === 'finish' && <FinishStep />}
</div>
{stepError && <Alert severity="error" title={stepError} />}
<Stack gap={2} justifyContent="flex-end">
<Button
variant={stepStatus === 'error' ? 'primary' : 'secondary'}
onClick={handleCancel}
disabled={isSubmitting || isCancelling}
>
{isCancelling
? t('provisioning.wizard-content.button-cancelling', 'Cancelling...')
: t('provisioning.wizard-content.button-cancel', 'Cancel')}
</Button>
<Button onClick={handleNextWithSubmit} disabled={isNextButtonDisabled()}>
{isSubmitting
? t('provisioning.wizard-content.button-submitting', 'Submitting...')
: getNextButtonText(activeStep)}
</Button>
</Stack>
</Stack>
</form>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
form: css({
maxWidth: '900px',
flexGrow: 1,
}),
divider: css({
width: 1,
alignSelf: 'stretch',
backgroundColor: theme.colors.border.weak,
// align with the button row
marginBottom: theme.spacing(13),
}),
content: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
paddingBottom: theme.spacing(4),
marginBottom: theme.spacing(4),
}),
});
@@ -1,59 +1,75 @@
import { useMemo } from 'react';
import {
GetRepositoryFilesApiResponse,
GetResourceStatsApiResponse,
RepositoryViewList,
} from 'app/api/clients/provisioning';
import { t } from 'app/core/internationalization';
import { ModeOption, SystemState } from './types';
import { ModeOption } from './types';
const migrateInstance: ModeOption = {
target: 'instance',
operation: 'migrate',
label: 'Sync all resources with external storage',
description:
'Resources will be synced with external storage and provisioned into this instance. Existing Grafana resources will be migrated and merged if needed. After setup, all new resources and changes will be saved to external storage and automatically provisioned back into the instance.',
subtitle: 'Use this option if you want to sync and manage your entire Grafana instance through external storage.',
};
const pullFolder: ModeOption = {
target: 'folder',
operation: 'pull',
label: 'Sync external storage to a new Grafana 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.',
subtitle:
'Use this option to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 folders.',
};
function getDisabledReason(action: ModeOption, resourceCount: number, folderConnected?: boolean) {
// Disable pull instance if there are existing dashboards or folders
if (action.target === 'instance' && action.operation === 'pull' && resourceCount > 0) {
return 'Cannot pull to instance when you have existing resources. Please migrate your existing resources first.';
}
if (!folderConnected) {
return undefined;
}
if (action.operation === 'migrate') {
return 'Cannot migrate when a folder is already mounted.';
}
if (action.target === 'instance') {
return 'Instance-wide connection is disabled because folders are connected to repositories.';
}
return undefined;
}
export function getState(
repoName: string,
settings?: RepositoryViewList,
files?: GetRepositoryFilesApiResponse,
stats?: GetResourceStatsApiResponse
): SystemState {
/**
* Filters available mode options based on system state
*/
function filterModeOptions(modeOptions: ModeOption[], repoName: string, settings?: RepositoryViewList): ModeOption[] {
const folderConnected = settings?.items?.some((item) => item.target === 'folder' && item.name !== repoName);
return modeOptions.filter((option) => {
if (settings?.legacyStorage) {
return option.target === 'instance';
}
if (option.target === 'folder') {
return true;
}
if (option.target === 'instance') {
return !folderConnected;
}
return false;
});
}
/**
* Hook that provides filtered mode options
* This needs to be a hook, so we can add translations
*/
export function useModeOptions(repoName: string, settings?: RepositoryViewList) {
return useMemo(() => {
const modeOptions: ModeOption[] = [
{
target: 'instance',
label: t('provisioning.mode-options.instance.label', 'Sync all resources with external storage'),
description: t(
'provisioning.mode-options.instance.description',
'Resources will be synced with external storage and provisioned into this instance. Existing Grafana resources will be migrated and merged if needed. After setup, all new resources and changes will be saved to external storage and automatically provisioned back into the instance.'
),
subtitle: t(
'provisioning.mode-options.instance.subtitle',
'Use this option if you want to sync and manage your entire Grafana instance through external storage.'
),
},
{
target: 'folder',
label: t('provisioning.mode-options.folder.label', 'Sync external storage to a new Grafana folder'),
description: t(
'provisioning.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.'
),
subtitle: t(
'provisioning.mode-options.folder.subtitle',
'Use this option to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 folders.'
),
},
];
return filterModeOptions(modeOptions, repoName, settings);
}, [repoName, settings]);
}
export function getResourceStats(files?: GetRepositoryFilesApiResponse, stats?: GetResourceStatsApiResponse) {
const fileCount =
files?.items?.reduce((count, file) => {
const path = file.path ?? '';
@@ -62,9 +78,10 @@ export function getState(
let counts: string[] = [];
let resourceCount = 0;
stats?.instance?.forEach((stat) => {
switch (stat.group) {
case 'folders': // fallthrough
case 'folders':
case 'folder.grafana.app':
resourceCount += stat.count;
counts.push(`${stat.count} ${stat.count > 1 ? 'folders' : 'folder'}`);
@@ -76,32 +93,9 @@ export function getState(
}
});
const state: SystemState = {
return {
fileCount,
resourceCount,
resourceCountString: counts.join(',\n'),
fileCount,
actions: [],
disabled: [],
folderConnected,
};
// Legacy storage can only migrate
if (settings?.legacyStorage) {
const disabledReason = 'Instance must be migrated first';
state.actions = [migrateInstance];
state.disabled = [{ ...pullFolder, disabledReason }];
return state;
}
const actionsToEvaluate = [migrateInstance, pullFolder];
actionsToEvaluate.forEach((action) => {
const reason = getDisabledReason(action, resourceCount, folderConnected);
if (reason) {
state.disabled.push({ ...action, disabledReason: reason });
} else {
state.actions.push(action);
}
});
return state;
}
@@ -18,23 +18,20 @@ export interface WizardFormData {
}
export type Target = SyncOptions['target'];
export type Operation = 'pull' | 'migrate';
export interface ModeOption {
target: Target;
operation: Operation;
label: string;
description: string;
disabledReason?: string;
subtitle: string;
}
export interface SystemState {
resourceCount: number;
resourceCountString: string;
fileCount: number;
actions: ModeOption[];
disabled: ModeOption[];
folderConnected?: boolean;
}
export type StepStatus = 'idle' | 'running' | 'error' | 'success';
export type StepStatusInfo = { status: StepStatus } | { status: 'error'; error: string };
@@ -1,25 +0,0 @@
import { useCallback } from 'react';
export type StepStatus = 'idle' | 'running' | 'error' | 'success';
export interface StepStatusProps {
onStepUpdate: (status: StepStatus, error?: string) => void;
}
export interface StepStatusActions {
setRunning: () => void;
setError: (error: string) => void;
setSuccess: () => void;
}
export function useStepStatus({ onStepUpdate }: StepStatusProps): StepStatusActions {
const setRunning = useCallback(() => onStepUpdate('running'), [onStepUpdate]);
const setError = useCallback((error: string) => onStepUpdate('error', error), [onStepUpdate]);
const setSuccess = useCallback(() => onStepUpdate('success'), [onStepUpdate]);
return {
setRunning,
setError,
setSuccess,
};
}
+13 -11
View File
@@ -6424,7 +6424,6 @@
},
"provisioning": {
"bootstrap-step": {
"dashboards-unavailable-while-running-process": "Dashboards will be unavailable while running this process",
"description-clear-repository-connection": "Add a clear name for this repository connection",
"empty": "Empty",
"error-field-required": "This field is required.",
@@ -6432,15 +6431,9 @@
"files-count_one": "{{count}} files",
"files-count_other": "{{count}} files",
"grafana": "Grafana instance",
"include-history": "Include history",
"label-display-name": "Display name",
"label-migrate-options": "Migrate options",
"placeholder-my-repository-connection": "My repository connection",
"resources-will-be-added": "The {{count}} resources in grafana will be added to the repository. Grafana will then include both the current resources and anything from the repository when done.",
"text-loading-resource-information": "Loading resource information...",
"title-files-exist-in-the-target": "Files exist in the target",
"title-note": "Note",
"tooltip-include-history": "Include complete dashboard version history"
"text-loading-resource-information": "Loading resource information..."
},
"check-repository": {
"check": "Check"
@@ -6480,7 +6473,6 @@
"placeholder-interval-seconds": "60",
"placeholder-local-path": "/path/to/repo",
"placeholder-my-config": "My config",
"placeholder-path": "grafana/",
"placeholder-select-repository-type": "Select repository type"
},
"config-form-github-collapse": {
@@ -6516,7 +6508,6 @@
"label-path": "Path to subdirectory in repository",
"label-repository-url": "GitHub repository URL",
"placeholder-branch": "main",
"placeholder-github-path": "grafana/",
"placeholder-github-token": "github_pat_yourTokenHere1234567890abcdEFGHijklMNOP",
"placeholder-github-url": "https://github.com/username/repo",
"placeholder-local-path": "/path/to/repo"
@@ -6644,6 +6635,18 @@
},
"summary": "Summary"
},
"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.",
"label": "Sync external storage to a new Grafana folder",
"subtitle": "Use this option to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 folders."
},
"instance": {
"description": "Resources will be synced with external storage and provisioned into this instance. Existing Grafana resources will be migrated and merged if needed. After setup, all new resources and changes will be saved to external storage and automatically provisioned back into the instance.",
"label": "Sync all resources with external storage",
"subtitle": "Use this option if you want to sync and manage your entire Grafana instance through external storage."
}
},
"recent-jobs": {
"active-jobs": "active jobs",
"column-action": "Action",
@@ -6752,7 +6755,6 @@
"tooltip-unhealthy-repository": "Unable to pull an unhealthy repository"
},
"synchronize-step": {
"error-job-failed": "Job failed",
"error-no-job-id": "Failed to start job",
"error-no-repository-name": "No repository name provided",
"error-starting-job": "Error starting job",