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