From 0bad0526f529a16ac1cc488e6a960e847d8c32b4 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Thu, 10 Apr 2025 22:54:56 +0300 Subject: [PATCH] Provisioning: Simplify component logic (#103816) --- .../provisioning/Config/ConfigForm.tsx | 46 +-- .../provisioning/Job/FinishedJobStatus.tsx | 24 +- .../features/provisioning/Job/JobStatus.tsx | 25 +- .../Repository/RepositoryLink.tsx | 18 +- .../provisioning/Wizard/BootstrapStep.tsx | 157 +++----- .../provisioning/Wizard/ConnectStep.tsx | 5 +- .../Wizard/ProvisioningWizard.tsx | 336 +++++++++++++----- .../provisioning/Wizard/SynchronizeStep.tsx | 49 +-- .../provisioning/Wizard/WizardContent.tsx | 262 -------------- .../features/provisioning/Wizard/actions.ts | 140 ++++---- .../app/features/provisioning/Wizard/types.ts | 9 +- .../provisioning/hooks/useStepStatus.ts | 25 -- public/locales/en-US/grafana.json | 24 +- 13 files changed, 440 insertions(+), 680 deletions(-) delete mode 100644 public/app/features/provisioning/Wizard/WizardContent.tsx delete mode 100644 public/app/features/provisioning/hooks/useStepStatus.ts diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 522a6c7f443..780cdca848e 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -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')} > - + )} @@ -303,8 +305,8 @@ export function ConfigForm({ data }: ConfigFormProps) { - diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx index 0f0277f510b..c7932eaecd1 100644 --- a/public/app/features/provisioning/Job/FinishedJobStatus.tsx +++ b/public/app/features/provisioning/Job/FinishedJobStatus.tsx @@ -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; @@ -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 ( diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index 1a80c742ab0..7ab68023055 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -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 ( - - ); + return ; } if (shouldCheckFinishedJobs) { return ( - + ); } diff --git a/public/app/features/provisioning/Repository/RepositoryLink.tsx b/public/app/features/provisioning/Repository/RepositoryLink.tsx index 3296dde6b7d..898c1eb7dab 100644 --- a/public/app/features/provisioning/Repository/RepositoryLink.tsx +++ b/public/app/features/provisioning/Repository/RepositoryLink.tsx @@ -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 ( @@ -32,11 +28,13 @@ export function RepositoryLink({ name }: RepositoryLinkProps) { and the external storage will be synchronized. - - - View repository - - + {repoHref && ( + + + View repository + + + )} ); } diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx index 6fb049a8539..f06e5764450 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx @@ -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(); - const selectedTarget = watch('repository.sync.target'); - const repoType = watch('repository.type'); - const resourceStats = useGetResourceStatsQuery(); const filesQuery = useGetRepositoryFilesQuery({ name: repoName }); - const [selectedOption, setSelectedOption] = useState(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 ( @@ -98,9 +83,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) - {state.resourceCount > 0 - ? state.resourceCountString - : t('provisioning.bootstrap-step.empty', 'Empty')} + {resourceCount > 0 ? resourceCountString : t('provisioning.bootstrap-step.empty', 'Empty')} @@ -109,8 +92,8 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) External storage - {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')} @@ -120,16 +103,16 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) ( + render={({ field: { ref, onChange, ...field } }) => ( <> - {state.actions.map((action, index) => ( + {options.map((action, index) => ( { - handleOptionSelect(action); + onChange(action.target); }} - autoFocus={index === 0} + {...field} > {action.label} @@ -144,54 +127,6 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) )} /> - {/* Add migration options */} - {selectedOption?.operation === 'migrate' && ( - <> - {Boolean(state.resourceCount) && ( - - - Dashboards will be unavailable while running this process - - - )} - {Boolean(state.fileCount) && Boolean(state.resourceCount) && ( - - - - )} - {canIncludeHistory && ( -
- - {canIncludeHistory && ( - - - - Include history - - - - - - )} - -
- )} - - )} - {/* Only show title field if folder sync */} {selectedTarget === 'folder' && ( )} diff --git a/public/app/features/provisioning/Wizard/ConnectStep.tsx b/public/app/features/provisioning/Wizard/ConnectStep.tsx index a85321ec151..722445f20e3 100644 --- a/public/app/features/provisioning/Wizard/ConnectStep.tsx +++ b/public/app/features/provisioning/Wizard/ConnectStep.tsx @@ -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' )} > - + )} diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx index e5e6d282ccf..dd0d5f167dd 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -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> => { + 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('connection'); const [completedSteps, setCompletedSteps] = useState([]); - const [stepSuccess, setStepSuccess] = useState(false); const [requiresMigration, setRequiresMigration] = useState(false); + const [stepStatusInfo, setStepStatusInfo] = useState({ 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>>( - () => [ - { - 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({ 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 ( - + + +
+
+ + + + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} + + {currentStepIndex + 1}. {currentStepConfig?.title} + + + + + +
+ {activeStep === 'connection' && } + {activeStep === 'bootstrap' && ( + + )} + {activeStep === 'synchronize' && ( + + )} + {activeStep === 'finish' && } +
+ + {stepStatusInfo.status === 'error' && ( + + )} + + + + + +
+ + ); } + +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), + }), +}); diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx index 207910c8eb3..2d860220380 100644 --- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -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(); - const [history, repoName] = getValues(['migrate.history', 'repositoryName']); const [job, setJob] = useState(); 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 ( - { - 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 ; } return ( diff --git a/public/app/features/provisioning/Wizard/WizardContent.tsx b/public/app/features/provisioning/Wizard/WizardContent.tsx deleted file mode 100644 index 25fe309730e..00000000000 --- a/public/app/features/provisioning/Wizard/WizardContent.tsx +++ /dev/null @@ -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>; - 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(); - 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('idle'); - const [stepError, setStepError] = useState(); - - 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 - 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 ( - - -
-
- - - - {/* eslint-disable-next-line @grafana/no-untranslated-strings */} - - {currentStepIndex + 1}. {currentStep?.title} - - - - - -
- {activeStep === 'connection' && } - {activeStep === 'bootstrap' && ( - - )} - {activeStep === 'synchronize' && ( - - )} - {activeStep === 'finish' && } -
- - {stepError && } - - - - - -
- - - ); -} - -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), - }), -}); diff --git a/public/app/features/provisioning/Wizard/actions.ts b/public/app/features/provisioning/Wizard/actions.ts index 1add1f04fdb..a80e092bfae 100644 --- a/public/app/features/provisioning/Wizard/actions.ts +++ b/public/app/features/provisioning/Wizard/actions.ts @@ -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; } diff --git a/public/app/features/provisioning/Wizard/types.ts b/public/app/features/provisioning/Wizard/types.ts index 39abf940ac5..67797a7c85a 100644 --- a/public/app/features/provisioning/Wizard/types.ts +++ b/public/app/features/provisioning/Wizard/types.ts @@ -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 }; diff --git a/public/app/features/provisioning/hooks/useStepStatus.ts b/public/app/features/provisioning/hooks/useStepStatus.ts deleted file mode 100644 index eb8e37451f2..00000000000 --- a/public/app/features/provisioning/hooks/useStepStatus.ts +++ /dev/null @@ -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, - }; -} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b40b2e2a119..55989b22263 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",