diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx index 77c2daefdf7..e51b79ebb5c 100644 --- a/public/app/features/provisioning/Job/FinishedJobStatus.tsx +++ b/public/app/features/provisioning/Job/FinishedJobStatus.tsx @@ -35,8 +35,14 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps) }, 1000); } - if (finishedQuery.isSuccess && job?.status?.state !== 'error') { - setStepStatusInfo({ status: 'success' }); + if (finishedQuery.isSuccess && job?.status) { + if (job.status.state === 'error') { + setStepStatusInfo({ + status: 'error', + }); + } else if (job.status.state === 'success') { + setStepStatusInfo({ status: 'success' }); + } } return () => { diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx index 29ad097ae63..59dd57f2840 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; @@ -7,7 +7,7 @@ import { useGetRepositoryFilesQuery, useGetResourceStatsQuery } from 'app/api/cl import { BootstrapStep, Props } from './BootstrapStep'; import { StepStatusProvider } from './StepStatusContext'; -import { getResourceStats, useModeOptions } from './actions'; +import { useModeOptions } from './hooks/useModeOptions'; import { WizardFormData } from './types'; jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ @@ -15,11 +15,14 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useGetResourceStatsQuery: jest.fn(), })); -jest.mock('./actions', () => ({ - getResourceStats: jest.fn(), +jest.mock('./hooks/useModeOptions', () => ({ useModeOptions: jest.fn(), })); +jest.mock('./hooks/useResourceStats', () => ({ + useResourceStats: jest.fn(), +})); + // Wrapper component to provide form context function FormWrapper({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial }) { const methods = useForm({ @@ -53,7 +56,6 @@ function setup(props: Partial = {}, formDefaultValues?: Partial { isLoading: false, }); - (getResourceStats as jest.Mock).mockReturnValue({ + const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; + mockUseResourceStats.mockReturnValue({ fileCount: 0, resourceCount: 0, - resourceCountString: '', + resourceCountString: 'Empty', + fileCountString: 'Empty', + isLoading: false, + requiresMigration: false, + shouldSkipSync: true, }); (useModeOptions as jest.Mock).mockReturnValue([ @@ -115,6 +122,17 @@ describe('BootstrapStep', () => { isLoading: true, }); + const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; + mockUseResourceStats.mockReturnValue({ + fileCount: 0, + resourceCount: 0, + resourceCountString: 'Empty', + fileCountString: 'Empty', + isLoading: true, + requiresMigration: false, + shouldSkipSync: true, + }); + setup(); expect(screen.getByText('Loading resource information...')).toBeInTheDocument(); @@ -139,10 +157,15 @@ describe('BootstrapStep', () => { isLoading: false, }); - (getResourceStats as jest.Mock).mockReturnValue({ + const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; + mockUseResourceStats.mockReturnValue({ fileCount: 2, resourceCount: 0, - resourceCountString: '', + resourceCountString: 'Empty', + fileCountString: '2 files', + isLoading: false, + requiresMigration: false, + shouldSkipSync: false, }); setup(); @@ -161,10 +184,15 @@ describe('BootstrapStep', () => { isLoading: false, }); - (getResourceStats as jest.Mock).mockReturnValue({ + const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; + mockUseResourceStats.mockReturnValue({ fileCount: 0, resourceCount: 7, resourceCountString: '7 resources', + fileCountString: 'Empty', + isLoading: false, + requiresMigration: true, + shouldSkipSync: false, }); setup(); @@ -173,17 +201,16 @@ describe('BootstrapStep', () => { }); }); - describe('option selection', () => { - it('should call onOptionSelect with correct argument when no migration needed', async () => { - const { props } = setup(); + describe('hook integration', () => { + it('should use useResourceStats hook correctly', async () => { + setup(); - await waitFor(() => { - expect(props.onOptionSelect).toHaveBeenCalledWith(false); - }); + const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; + expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', undefined); }); - it('should call onOptionSelect with true when legacy storage exists', async () => { - const { props } = setup({ + it('should use useResourceStats hook with legacy storage flag', async () => { + setup({ settingsData: { legacyStorage: true, items: [], @@ -191,30 +218,8 @@ describe('BootstrapStep', () => { }, }); - await waitFor(() => { - expect(props.onOptionSelect).toHaveBeenCalledWith(true); - }); - }); - - it('should call onOptionSelect with true when resources exist', async () => { - (useGetResourceStatsQuery as jest.Mock).mockReturnValue({ - data: { - instance: [{ group: 'dashboard.grafana.app', count: 1 }], - }, - isLoading: false, - }); - - (getResourceStats as jest.Mock).mockReturnValue({ - fileCount: 0, - resourceCount: 1, - resourceCountString: '1 resource', - }); - - const { props } = setup(); - - await waitFor(() => { - expect(props.onOptionSelect).toHaveBeenCalledWith(true); - }); + const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats; + expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', true); }); }); diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx index a22f631dde0..642ba1fc430 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx @@ -1,26 +1,22 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { Trans, t } from '@grafana/i18n'; import { Box, Card, Field, Input, LoadingPlaceholder, Stack, Text } from '@grafana/ui'; -import { - RepositoryViewList, - useGetRepositoryFilesQuery, - useGetResourceStatsQuery, -} from 'app/api/clients/provisioning/v0alpha1'; +import { RepositoryViewList } from 'app/api/clients/provisioning/v0alpha1'; import { generateRepositoryTitle } from 'app/features/provisioning/utils/data'; import { useStepStatus } from './StepStatusContext'; -import { getResourceStats, useModeOptions } from './actions'; +import { useModeOptions } from './hooks/useModeOptions'; +import { useResourceStats } from './hooks/useResourceStats'; import { WizardFormData } from './types'; export interface Props { - onOptionSelect: (requiresMigration: boolean) => void; settingsData?: RepositoryViewList; repoName: string; } -export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) { +export function BootstrapStep({ settingsData, repoName }: Props) { const { setStepStatusInfo } = useStepStatus(); const { register, @@ -31,17 +27,10 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) formState: { errors }, } = useFormContext(); - const resourceStats = useGetResourceStatsQuery(); - const filesQuery = useGetRepositoryFilesQuery({ name: repoName }); const selectedTarget = watch('repository.sync.target'); const options = useModeOptions(repoName, settingsData); const { target } = options[0]; - const { resourceCount, resourceCountString, fileCount } = useMemo( - () => getResourceStats(filesQuery.data, resourceStats.data), - [filesQuery.data, resourceStats.data] - ); - const requiresMigration = settingsData?.legacyStorage || resourceCount > 0; - const isLoading = resourceStats.isLoading || filesQuery.isLoading; + const { resourceCountString, fileCountString, isLoading } = useResourceStats(repoName, settingsData?.legacyStorage); useEffect(() => { // Pick a name nice name based on type+settings @@ -56,8 +45,7 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) useEffect(() => { setValue('repository.sync.target', target); - onOptionSelect(requiresMigration); - }, [target, setValue, onOptionSelect, requiresMigration]); + }, [target, setValue]); if (isLoading) { return ( @@ -79,20 +67,14 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) Grafana instance - - {resourceCount > 0 ? resourceCountString : t('provisioning.bootstrap-step.empty', 'Empty')} - + {resourceCountString} External storage - - {fileCount > 0 - ? t('provisioning.bootstrap-step.files-count', '{{count}} files', { count: fileCount }) - : t('provisioning.bootstrap-step.empty', 'Empty')} - + {fileCountString} diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx index 0e26638c310..83fc75ddfb9 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.test.tsx @@ -115,27 +115,23 @@ describe('ProvisioningWizard', () => { isLoading: false, error: null, refetch: jest.fn(), - } as ReturnType); + }); mockUseGetRepositoryFilesQuery.mockReturnValue({ data: [], isLoading: false, error: null, refetch: jest.fn(), - } as ReturnType); + }); mockUseGetResourceStatsQuery.mockReturnValue({ data: { - dashboards: 0, - datasources: 0, - folders: 0, - libraryPanels: 0, - alertRules: 0, + instance: [], }, isLoading: false, error: null, refetch: jest.fn(), - } as ReturnType); + }); const mockCreateJob = jest.fn(); mockUseCreateRepositoryJobsMutation.mockReturnValue([ @@ -194,6 +190,14 @@ describe('ProvisioningWizard', () => { }); it('should progress through first 3 steps successfully', async () => { + mockUseGetResourceStatsQuery.mockReturnValue({ + data: { + instance: [{ group: 'dashboard.grafana.app', count: 1 }], + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); const { user } = setup(); await fillConnectionForm(user, 'github', { @@ -218,6 +222,49 @@ describe('ProvisioningWizard', () => { expect(screen.getByRole('button', { name: /Begin synchronization/i })).toBeInTheDocument(); }); }); + + it('should skip sync step when there are no resources', async () => { + mockUseGetResourceStatsQuery.mockReturnValue({ + data: { + instance: [], // No resources + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + mockUseGetRepositoryFilesQuery.mockReturnValue({ + data: { + items: [], // No files + }, + isLoading: false, + error: null, + refetch: jest.fn(), + } as ReturnType); + + const { user } = setup(); + + await fillConnectionForm(user, 'github', { + token: 'test-token', + url: 'https://github.com/test/repo', + branch: 'main', + path: '/', + }); + + await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i })); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /2\. Choose what to synchronize/i })).toBeInTheDocument(); + }); + + // Should show "Choose additional settings" button instead of "Synchronize with external storage" + expect(screen.getByRole('button', { name: /Choose additional settings/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Synchronize with external storage/i })).not.toBeInTheDocument(); + + // Verify that the sync step (step 3) would be skipped in the button text logic + const nextButton = screen.getByRole('button', { name: /Choose additional settings/i }); + expect(nextButton).toBeInTheDocument(); + }); }); describe('Error Handling', () => { @@ -424,6 +471,16 @@ describe('ProvisioningWizard', () => { }); it('should show button text changes based on current step', async () => { + // Mock resources to ensure sync step is not skipped + mockUseGetResourceStatsQuery.mockReturnValue({ + data: { + instance: [{ group: 'dashboard.grafana.app', count: 1 }], + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + const { user } = setup(); expect(screen.getByRole('button', { name: /Choose what to synchronize/i })).toBeInTheDocument(); diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx index 706b6e52a32..edf8b5aea3e 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -22,6 +22,8 @@ import { FinishStep } from './FinishStep'; import { useStepStatus } from './StepStatusContext'; import { Step, Stepper } from './Stepper'; import { SynchronizeStep } from './SynchronizeStep'; +import { useCreateSyncJob } from './hooks/useCreateSyncJob'; +import { useResourceStats } from './hooks/useResourceStats'; import { RepoType, WizardFormData, WizardStep } from './types'; const appEvents = getAppEvents(); @@ -58,14 +60,14 @@ const getSteps = (): Array> => { export function ProvisioningWizard({ type }: { type: RepoType }) { const [activeStep, setActiveStep] = useState('connection'); const [completedSteps, setCompletedSteps] = useState([]); - const [requiresMigration, setRequiresMigration] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isCancelling, setIsCancelling] = useState(false); const { stepStatusInfo, setStepStatusInfo, isStepSuccess, isStepRunning, hasStepError } = useStepStatus(); - const settingsQuery = useGetFrontendSettingsQuery(); + const { data } = useGetFrontendSettingsQuery(); + const isLegacyStorage = Boolean(data?.legacyStorage); const navigate = useNavigate(); const steps = getSteps(); @@ -91,16 +93,24 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { handleSubmit, } = methods; - const repoName = watch('repositoryName'); + const [repoName = '', repoType] = watch(['repositoryName', 'repository.type']); const [submitData] = useCreateOrUpdateRepository(repoName); const [deleteRepository] = useDeleteRepositoryMutation(); + const { shouldSkipSync, requiresMigration } = useResourceStats(repoName, isLegacyStorage); + const { createSyncJob, isLoading: isCreatingSkipJob } = useCreateSyncJob({ + repoName: repoName, + requiresMigration, + repoType, + isLegacyStorage, + setStepStatusInfo, + }); const currentStepIndex = steps.findIndex((s) => s.id === activeStep); const currentStepConfig = steps[currentStepIndex]; // A different repository is marked with instance target -- nothing will succeed useEffect(() => { - if (settingsQuery.data?.items.some((item) => item.target === 'instance' && item.name !== repoName)) { + if (data?.items.some((item) => item.target === 'instance' && item.name !== repoName)) { appEvents.publish({ type: AppEvents.alertError.name, payload: [ @@ -110,7 +120,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { navigate(PROVISIONING_URL); } - }, [navigate, repoName, settingsQuery.data?.items]); + }, [navigate, repoName, data?.items]); const handleRepositoryDeletion = async (name: string) => { try { @@ -144,9 +154,18 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { return t('provisioning.wizard.button-next', 'Finish'); } + // If on bootstrap step and should skip sync, show finish step name + if (currentStep === 'bootstrap' && shouldSkipSync) { + const finishStepIndex = stepIndex + 2; + if (finishStepIndex < steps.length) { + return steps[finishStepIndex].name; + } + return t('provisioning.wizard.button-next', 'Finish'); + } + return steps[stepIndex + 1].name; }, - [steps] + [steps, shouldSkipSync] ); const handleNext = async () => { @@ -156,7 +175,27 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { if (isLastStep) { navigate(PROVISIONING_URL); } else { - setActiveStep(steps[currentStepIndex + 1].id); + let nextStepIndex = currentStepIndex + 1; + + // Skip synchronize step if no sync is needed + if (activeStep === 'bootstrap' && shouldSkipSync) { + nextStepIndex = currentStepIndex + 2; // Skip to finish step + + // Create a pull job to initialize the repository + if (repoName) { + const job = await createSyncJob(); + if (!job) { + return; // Don't proceed if job creation fails + } + } + } + + if (nextStepIndex >= steps.length) { + navigate(PROVISIONING_URL); + return; + } + + setActiveStep(steps[nextStepIndex].id); setCompletedSteps((prev) => [...new Set([...prev, activeStep])]); setStepStatusInfo({ status: 'idle' }); } @@ -227,7 +266,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { if (activeStep === 'synchronize') { return !isStepSuccess; // Disable next button if the step is not successful } - return isSubmitting || isCancelling || isStepRunning; + return isSubmitting || isCancelling || isStepRunning || isCreatingSkipJob; }; return ( @@ -244,23 +283,14 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { - {hasStepError && } + {hasStepError && 'error' in stepStatusInfo && stepStatusInfo.error && ( + + )}
{activeStep === 'connection' && } - {activeStep === 'bootstrap' && ( - - )} - {activeStep === 'synchronize' && ( - - )} + {activeStep === 'bootstrap' && } + {activeStep === 'synchronize' && } {activeStep === 'finish' && }
diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx index 459ae8c9183..64ae9ee8732 100644 --- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -3,68 +3,38 @@ import { useFormContext } from 'react-hook-form'; import { Trans, t } from '@grafana/i18n'; import { Button, Text, Stack, Alert, TextLink, Field, Checkbox } from '@grafana/ui'; -import { Job, useCreateRepositoryJobsMutation } from 'app/api/clients/provisioning/v0alpha1'; +import { Job } from 'app/api/clients/provisioning/v0alpha1'; import { JobStatus } from '../Job/JobStatus'; -import { isGitProvider } from '../utils/repositoryTypes'; import { useStepStatus } from './StepStatusContext'; +import { useCreateSyncJob } from './hooks/useCreateSyncJob'; +import { useResourceStats } from './hooks/useResourceStats'; import { WizardFormData } from './types'; export interface SynchronizeStepProps { - requiresMigration: boolean; isLegacyStorage?: boolean; } -export function SynchronizeStep({ requiresMigration, isLegacyStorage }: SynchronizeStepProps) { - const { setStepStatusInfo } = useStepStatus(); - const [createJob] = useCreateRepositoryJobsMutation(); +export function SynchronizeStep({ isLegacyStorage }: SynchronizeStepProps) { const { getValues, register, watch } = useFormContext(); - const repoType = watch('repository.type'); - const supportsHistory = isGitProvider(repoType) && isLegacyStorage; + const { setStepStatusInfo } = useStepStatus(); + const [repoName = '', repoType] = watch(['repositoryName', 'repository.type']); + const { requiresMigration } = useResourceStats(repoName, isLegacyStorage); + const { createSyncJob, supportsHistory } = useCreateSyncJob({ + repoName, + requiresMigration, + repoType, + isLegacyStorage, + setStepStatusInfo, + }); const [job, setJob] = useState(); const startSynchronization = async () => { - const [history, repoName] = getValues(['migrate.history', 'repositoryName']); - if (!repoName) { - setStepStatusInfo({ - status: 'error', - error: t('provisioning.synchronize-step.error-no-repository-name', 'No repository name provided'), - }); - return; - } - - try { - setStepStatusInfo({ status: 'running' }); - const jobSpec = requiresMigration - ? { - migrate: { - history: history && supportsHistory, - }, - } - : { - pull: { - incremental: false, // will queue a full resync job - }, - }; - - const response = await createJob({ - name: repoName, - jobSpec, - }).unwrap(); - - if (!response?.metadata?.name) { - return setStepStatusInfo({ - status: 'error', - error: t('provisioning.synchronize-step.error-no-job-id', 'Failed to start job'), - }); - } + const [history] = getValues(['migrate.history']); + const response = await createSyncJob({ history }); + if (response) { setJob(response); - } catch (error) { - setStepStatusInfo({ - status: 'error', - error: t('provisioning.synchronize-step.error-starting-job', 'Error starting job'), - }); } }; diff --git a/public/app/features/provisioning/Wizard/hooks/useCreateSyncJob.ts b/public/app/features/provisioning/Wizard/hooks/useCreateSyncJob.ts new file mode 100644 index 00000000000..94b457bf97e --- /dev/null +++ b/public/app/features/provisioning/Wizard/hooks/useCreateSyncJob.ts @@ -0,0 +1,78 @@ +import { t } from '@grafana/i18n'; +import { useCreateRepositoryJobsMutation } from 'app/api/clients/provisioning/v0alpha1'; + +import { isGitProvider } from '../../utils/repositoryTypes'; +import { RepoType, StepStatusInfo } from '../types'; + +export interface UseCreateSyncJobParams { + repoName: string; + requiresMigration: boolean; + repoType: RepoType; + isLegacyStorage?: boolean; + setStepStatusInfo?: (info: StepStatusInfo) => void; +} + +export function useCreateSyncJob({ + repoName, + requiresMigration, + repoType, + isLegacyStorage, + setStepStatusInfo, +}: UseCreateSyncJobParams) { + const [createJob, { isLoading }] = useCreateRepositoryJobsMutation(); + const supportsHistory = isGitProvider(repoType) && isLegacyStorage; + + const createSyncJob = async (options?: { history?: boolean }) => { + if (!repoName) { + setStepStatusInfo?.({ + status: 'error', + error: t('provisioning.sync-job.error-no-repository-name', 'No repository name provided'), + }); + return null; + } + + try { + setStepStatusInfo?.({ status: 'running' }); + + const jobSpec = requiresMigration + ? { + migrate: { + history: (options?.history || false) && supportsHistory, + }, + } + : { + pull: { + incremental: false, + }, + }; + + const response = await createJob({ + name: repoName, + jobSpec, + }).unwrap(); + + if (!response?.metadata?.name) { + setStepStatusInfo?.({ + status: 'error', + error: t('provisioning.sync-job.error-no-job-id', 'Failed to start job'), + }); + return null; + } + + setStepStatusInfo?.({ status: 'success' }); + return response; + } catch (error) { + setStepStatusInfo?.({ + status: 'error', + error: t('provisioning.sync-job.error-starting-job', 'Error starting job'), + }); + return null; + } + }; + + return { + createSyncJob, + isLoading, + supportsHistory, + }; +} diff --git a/public/app/features/provisioning/Wizard/actions.ts b/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts similarity index 70% rename from public/app/features/provisioning/Wizard/actions.ts rename to public/app/features/provisioning/Wizard/hooks/useModeOptions.ts index 5c0340edc45..a70f190b508 100644 --- a/public/app/features/provisioning/Wizard/actions.ts +++ b/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts @@ -1,13 +1,9 @@ import { useMemo } from 'react'; import { t } from '@grafana/i18n'; -import { - GetRepositoryFilesApiResponse, - GetResourceStatsApiResponse, - RepositoryViewList, -} from 'app/api/clients/provisioning/v0alpha1'; +import { RepositoryViewList } from 'app/api/clients/provisioning/v0alpha1'; -import { ModeOption } from './types'; +import { ModeOption } from '../types'; /** * Filters available mode options based on system state @@ -68,34 +64,3 @@ export function useModeOptions(repoName: string, settings?: RepositoryViewList) 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 ?? ''; - return path.endsWith('.json') || path.endsWith('.yaml') ? count + 1 : count; - }, 0) ?? 0; - - let counts: string[] = []; - let resourceCount = 0; - - stats?.instance?.forEach((stat) => { - switch (stat.group) { - case 'folders': - case 'folder.grafana.app': - resourceCount += stat.count; - counts.push(`${stat.count} ${stat.count > 1 ? 'folders' : 'folder'}`); - break; - case 'dashboard.grafana.app': - resourceCount += stat.count; - counts.push(`${stat.count} ${stat.count > 1 ? 'dashboards' : 'dashboard'}`); - break; - } - }); - - return { - fileCount, - resourceCount, - resourceCountString: counts.join(',\n'), - }; -} diff --git a/public/app/features/provisioning/Wizard/hooks/useResourceStats.ts b/public/app/features/provisioning/Wizard/hooks/useResourceStats.ts new file mode 100644 index 00000000000..03d259303b8 --- /dev/null +++ b/public/app/features/provisioning/Wizard/hooks/useResourceStats.ts @@ -0,0 +1,83 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { useMemo } from 'react'; + +import { t } from '@grafana/i18n'; +import { + GetRepositoryFilesApiResponse, + GetResourceStatsApiResponse, + useGetRepositoryFilesQuery, + useGetResourceStatsQuery, +} from 'app/api/clients/provisioning/v0alpha1'; + +/** + * Calculates resource statistics from API responses + */ +function getResourceStats(files?: GetRepositoryFilesApiResponse, stats?: GetResourceStatsApiResponse) { + const isSupportedFile = (path: string) => path.endsWith('.json') || path.endsWith('.yaml'); + + const items = files?.items ?? []; + + const fileCount = items.filter((file) => { + const path = file.path ?? ''; + return isSupportedFile(path); + }).length; + + let counts: string[] = []; + let resourceCount = 0; + + stats?.instance?.forEach((stat) => { + switch (stat.group) { + case 'folders': + case 'folder.grafana.app': + resourceCount += stat.count; + counts.push(t('provisioning.bootstrap-step.folders-count', '{{count}} folder', { count: stat.count })); + break; + case 'dashboard.grafana.app': + resourceCount += stat.count; + counts.push(t('provisioning.bootstrap-step.dashboards-count', '{{count}} dashboard', { count: stat.count })); + break; + } + }); + + return { + fileCount, + resourceCount, + resourceCountString: counts.join(',\n'), + }; +} + +/** + * Hook that provides resource statistics and sync logic + */ +export function useResourceStats(repoName?: string, isLegacyStorage?: boolean) { + const resourceStatsQuery = useGetResourceStatsQuery(repoName ? undefined : skipToken); + const filesQuery = useGetRepositoryFilesQuery(repoName ? { name: repoName } : skipToken); + + const isLoading = resourceStatsQuery.isLoading || filesQuery.isLoading; + + const { resourceCount, resourceCountString, fileCount } = useMemo( + () => getResourceStats(filesQuery.data, resourceStatsQuery.data), + [filesQuery.data, resourceStatsQuery.data] + ); + + const requiresMigration = isLegacyStorage || resourceCount > 0; + const shouldSkipSync = !requiresMigration && resourceCount === 0 && fileCount === 0; + + // Format display strings + const resourceCountDisplay = + resourceCount > 0 ? resourceCountString : t('provisioning.bootstrap-step.empty', 'Empty'); + const fileCountDisplay = + fileCount > 0 + ? t('provisioning.bootstrap-step.files-count', '{{count}} files', { count: fileCount }) + : t('provisioning.bootstrap-step.empty', 'Empty'); + + return { + resourceCount, + resourceCountString: resourceCountDisplay, + fileCount, + fileCountString: fileCountDisplay, + isLoading, + requiresMigration, + shouldSkipSync, + }; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bc908e38a00..98c2721d47f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -10570,12 +10570,16 @@ "url-required": "Repository URL is required" }, "bootstrap-step": { + "dashboards-count_one": "{{count}} dashboard", + "dashboards-count_other": "{{count}} dashboards", "description-clear-repository-connection": "Add a clear name for this repository connection", "empty": "Empty", "error-field-required": "This field is required.", "ext-storage": "External storage", "files-count_one": "{{count}} files", "files-count_other": "{{count}} files", + "folders-count_one": "{{count}} folder", + "folders-count_other": "{{count}} folders", "grafana": "Grafana instance", "label-display-name": "Display name", "placeholder-my-repository-connection": "My repository connection", @@ -10943,6 +10947,11 @@ "label-current-step": "Current step", "label-pending-step": "Pending step" }, + "sync-job": { + "error-no-job-id": "Failed to start job", + "error-no-repository-name": "No repository name provided", + "error-starting-job": "Error starting job" + }, "sync-repository": { "body-edit-configuration": "Edit the configuration", "button-edit": "Edit", @@ -10953,9 +10962,6 @@ "tooltip-unhealthy-repository": "Unable to pull an unhealthy repository" }, "synchronize-step": { - "error-no-job-id": "Failed to start job", - "error-no-repository-name": "No repository name provided", - "error-starting-job": "Error starting job", "synchronization-description": "Include commits for each historical value", "synchronization-options": "Synchronization options" },