Provisioning: Skip sync step when it's not needed (#108309)
* Skip sync * Remove onOptionSelect * Format * Create job if skipping sync * Extract job sync into a hook * i18n * Cleanup + i18n * Review comments * Skip requests and update tests * Fix sync error
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<WizardFormData> }) {
|
||||
const methods = useForm<WizardFormData>({
|
||||
@@ -53,7 +56,6 @@ function setup(props: Partial<Props> = {}, formDefaultValues?: Partial<WizardFor
|
||||
const user = userEvent.setup();
|
||||
|
||||
const defaultProps: Props = {
|
||||
onOptionSelect: jest.fn(),
|
||||
repoName: 'test-repo',
|
||||
settingsData: undefined,
|
||||
...props,
|
||||
@@ -86,10 +88,15 @@ describe('BootstrapStep', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<WizardFormData>();
|
||||
|
||||
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)
|
||||
<Trans i18nKey="provisioning.bootstrap-step.grafana">Grafana instance</Trans>
|
||||
</Text>
|
||||
<Stack direction="row" gap={2}>
|
||||
<Text variant="h4">
|
||||
{resourceCount > 0 ? resourceCountString : t('provisioning.bootstrap-step.empty', 'Empty')}
|
||||
</Text>
|
||||
<Text variant="h4">{resourceCountString}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="column" gap={1} alignItems="center">
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="provisioning.bootstrap-step.ext-storage">External storage</Trans>
|
||||
</Text>
|
||||
<Text variant="h4">
|
||||
{fileCount > 0
|
||||
? t('provisioning.bootstrap-step.files-count', '{{count}} files', { count: fileCount })
|
||||
: t('provisioning.bootstrap-step.empty', 'Empty')}
|
||||
</Text>
|
||||
<Text variant="h4">{fileCountString}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -115,27 +115,23 @@ describe('ProvisioningWizard', () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
} as ReturnType<typeof useGetFrontendSettingsQuery>);
|
||||
});
|
||||
|
||||
mockUseGetRepositoryFilesQuery.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
} as ReturnType<typeof useGetRepositoryFilesQuery>);
|
||||
});
|
||||
|
||||
mockUseGetResourceStatsQuery.mockReturnValue({
|
||||
data: {
|
||||
dashboards: 0,
|
||||
datasources: 0,
|
||||
folders: 0,
|
||||
libraryPanels: 0,
|
||||
alertRules: 0,
|
||||
instance: [],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
} as ReturnType<typeof useGetResourceStatsQuery>);
|
||||
});
|
||||
|
||||
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(<ProvisioningWizard type="github" />);
|
||||
|
||||
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<typeof useGetRepositoryFilesQuery>);
|
||||
|
||||
const { user } = setup(<ProvisioningWizard type="github" />);
|
||||
|
||||
await fillConnectionForm(user, 'github', {
|
||||
token: 'test-token',
|
||||
url: 'https://github.com/test/repo',
|
||||
branch: 'main',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Choose what to synchronize/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /2\. Choose what to synchronize/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 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(<ProvisioningWizard type="github" />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Choose what to synchronize/i })).toBeInTheDocument();
|
||||
|
||||
@@ -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<Step<WizardStep>> => {
|
||||
export function ProvisioningWizard({ type }: { type: RepoType }) {
|
||||
const [activeStep, setActiveStep] = useState<WizardStep>('connection');
|
||||
const [completedSteps, setCompletedSteps] = useState<WizardStep[]>([]);
|
||||
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 }) {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{hasStepError && <Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />}
|
||||
{hasStepError && 'error' in stepStatusInfo && stepStatusInfo.error && (
|
||||
<Alert severity="error" title={stepStatusInfo.error} />
|
||||
)}
|
||||
|
||||
<div className={styles.content}>
|
||||
{activeStep === 'connection' && <ConnectStep />}
|
||||
{activeStep === 'bootstrap' && (
|
||||
<BootstrapStep
|
||||
onOptionSelect={setRequiresMigration}
|
||||
settingsData={settingsQuery.data}
|
||||
repoName={repoName ?? ''}
|
||||
/>
|
||||
)}
|
||||
{activeStep === 'synchronize' && (
|
||||
<SynchronizeStep
|
||||
requiresMigration={requiresMigration}
|
||||
isLegacyStorage={Boolean(settingsQuery.data?.legacyStorage)}
|
||||
/>
|
||||
)}
|
||||
{activeStep === 'bootstrap' && <BootstrapStep settingsData={data} repoName={repoName} />}
|
||||
{activeStep === 'synchronize' && <SynchronizeStep isLegacyStorage={isLegacyStorage} />}
|
||||
{activeStep === 'finish' && <FinishStep />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<WizardFormData>();
|
||||
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<Job>();
|
||||
|
||||
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'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
-37
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user