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:
Alex Khomenko
2025-07-21 18:41:31 +03:00
committed by GitHub
parent 20d6702e50
commit 193471c6ae
10 changed files with 370 additions and 188 deletions
@@ -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,
};
}
@@ -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,
};
}
+9 -3
View File
@@ -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"
},