diff --git a/.betterer.results b/.betterer.results index 14b13e60763..cdb1c49806f 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5749,7 +5749,8 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "7"] ], "public/app/features/provisioning/FileHistoryPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], @@ -5882,6 +5883,53 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] ], + "public/app/features/provisioning/Wizard/ConnectionStep.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "6"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "7"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "8"] + ], + "public/app/features/provisioning/Wizard/MigrateStep.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "8"] + ], + "public/app/features/provisioning/Wizard/ProvisioningWizard.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/provisioning/Wizard/RepositoryStep.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "6"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "7"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "11"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "12"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "13"] + ], + "public/app/features/provisioning/Wizard/Stepper.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/provisioning/Wizard/WizardPage.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/provisioning/api/index.ts:5381": [ [0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/pkg/registry/apis/provisioning/jobs/migrate/worker.go b/pkg/registry/apis/provisioning/jobs/migrate/worker.go index 7e53f4f568c..7cec1f7931d 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/worker.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/worker.go @@ -98,8 +98,16 @@ func (w *MigrationWorker) Process(ctx context.Context, repo repository.Repositor if err != nil { return fmt.Errorf("unable to read currnet tree: %w", err) } - if err = verifyEmptyRepo(tree); err != nil { - return err + + if true { // configurable? + for _, v := range tree { + if v.Blob && !resources.ShouldIgnorePath(v.Path) { + err = repo.Delete(ctx, v.Path, "", "initial cleanup") + if err != nil { + return fmt.Errorf("initial cleanup error: %w", err) + } + } + } } dynamicClient, _, err := w.clients.New(repo.Config().Namespace) diff --git a/public/app/features/provisioning/ConfigForm.tsx b/public/app/features/provisioning/ConfigForm.tsx index b9a9d061259..db40f170c55 100644 --- a/public/app/features/provisioning/ConfigForm.tsx +++ b/public/app/features/provisioning/ConfigForm.tsx @@ -38,7 +38,7 @@ const workflowOptions: Array> = [ const appEvents = getAppEvents(); -function getDefaultValues(repository?: RepositorySpec): RepositoryFormData { +export function getDefaultValues(repository?: RepositorySpec): RepositoryFormData { if (!repository) { return { type: 'github', diff --git a/public/app/features/provisioning/ExportToRepository.tsx b/public/app/features/provisioning/ExportToRepository.tsx index fcef1260853..e2008c82c1d 100644 --- a/public/app/features/provisioning/ExportToRepository.tsx +++ b/public/app/features/provisioning/ExportToRepository.tsx @@ -1,6 +1,7 @@ +import { useState } from 'react'; import { useForm } from 'react-hook-form'; -import { Box, Button, Field, Input, Switch } from '@grafana/ui'; +import { Box, Button, Field, Input, Stack, Switch } from '@grafana/ui'; import { JobStatus } from './JobStatus'; import { Repository, useCreateRepositoryExportMutation, ExportJobOptions } from './api'; @@ -11,6 +12,7 @@ interface Props { export function ExportToRepository({ repo }: Props) { const [exportRepo, exportQuery] = useCreateRepositoryExportMutation(); + const [showExportStatus, setShowExportStatus] = useState(false); const exportJob = exportQuery.data?.metadata?.name; const { register, formState, handleSubmit } = useForm({ @@ -19,14 +21,32 @@ export function ExportToRepository({ repo }: Props) { }, }); - const onSubmit = (body: ExportJobOptions) => - exportRepo({ + const onSubmit = async (body: ExportJobOptions) => { + await exportRepo({ name: repo.metadata?.name ?? '', body, // << the form }); + setShowExportStatus(true); + }; - if (exportJob) { - return ; + const onAbort = () => { + exportQuery.reset(); + setShowExportStatus(false); + }; + + if (showExportStatus && exportJob) { + return ( + + + + + + Abort export + + + + + ); } const isGit = repo.spec?.type === 'github'; diff --git a/public/app/features/provisioning/RepositoryListPage.tsx b/public/app/features/provisioning/RepositoryListPage.tsx index 14c9f716a1e..e6bf0a0bfce 100644 --- a/public/app/features/provisioning/RepositoryListPage.tsx +++ b/public/app/features/provisioning/RepositoryListPage.tsx @@ -1,4 +1,5 @@ import { ReactNode, useState } from 'react'; +import { useNavigate } from 'react-router-dom-v5-compat'; import { Card, @@ -26,13 +27,20 @@ import { useRepositoryList } from './hooks'; export default function RepositoryListPage() { const [items, isLoading] = useRepositoryList({ watch: true }); const settings = useGetFrontendSettingsQuery(); - + const navigate = useNavigate(); return ( {settings.data?.legacyStorage && ( - + Use provisioning wizard to configure a repository.>} + onRemove={() => { + navigate('/admin/provisioning/setup'); + }} + > Require running the onboarding wizard to convert from legacy to unified )} @@ -82,7 +90,7 @@ function RepositoryListPageContent({ items }: { items?: Repository[] }) { const url = item.spec.github?.url ?? ''; let branch = url; if (spec?.branch) { - branch += `tree/` + spec?.branch; + branch += `/tree/` + spec?.branch; } meta.push( @@ -91,7 +99,7 @@ function RepositoryListPageContent({ items }: { items?: Repository[] }) { ); if (item.status?.webhook?.id) { - const hook = url + `settings/hooks/${item.status?.webhook?.id}`; + const hook = url + `/settings/hooks/${item.status?.webhook?.id}`; meta.push( Webhook diff --git a/public/app/features/provisioning/RepositoryResources.tsx b/public/app/features/provisioning/RepositoryResources.tsx index 980a85e0a87..6104c409f82 100644 --- a/public/app/features/provisioning/RepositoryResources.tsx +++ b/public/app/features/provisioning/RepositoryResources.tsx @@ -10,8 +10,8 @@ import { InteractiveTable, LinkButton, ConfirmModal, + Button, } from '@grafana/ui'; -import { Button } from '@grafana/ui'; import { Repository, diff --git a/public/app/features/provisioning/Wizard/ConnectionStep.tsx b/public/app/features/provisioning/Wizard/ConnectionStep.tsx new file mode 100644 index 00000000000..885260b2b61 --- /dev/null +++ b/public/app/features/provisioning/Wizard/ConnectionStep.tsx @@ -0,0 +1,98 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { Field, Input, Combobox, Stack, Card, FieldSet, Alert } from '@grafana/ui'; + +import { WizardFormData } from './types'; + +const typeOptions = [ + { label: 'GitHub', value: 'github' }, + { label: 'Local', value: 'local' }, +]; + +const modeOptions = [ + { + value: 'instance', + label: 'Connect your Grafana instance to an empty repository', + description: + 'Export all dashboards from this instance to a new, empty repository. After setup, all dashboards in this instance will be saved and managed exclusively through this repository.', + }, + // { + // value: 'import', + // label: 'Import dashboards from an existing repository', + // description: + // 'Use dashboards from your GitHub repository to populate an empty Grafana instance. After setup, all dashboards in the repository will be automatically provisioned into this instance.', + // }, + { + value: 'folder', + label: 'Connect a specific folder to your repository', + description: + 'Save and manage dashboards from a selected folder while keeping others separate. You can create multiple connections between different folders and repositories.', + }, +]; + +export function ConnectionStep() { + const { + register, + control, + formState: { errors }, + } = useFormContext(); + + return ( + + + + ( + onChange(value?.value)} + placeholder="Select repository type" + /> + )} + /> + + + + Dashboards app/Grafana will be unavailable when starting this process. + + + + + + + + ( + <> + {modeOptions.map((option) => ( + onChange(option.value)}> + {option.label} + {option.description} + + ))} + > + )} + /> + + + + ); +} diff --git a/public/app/features/provisioning/Wizard/MigrateStep.tsx b/public/app/features/provisioning/Wizard/MigrateStep.tsx new file mode 100644 index 00000000000..f70120ed5b1 --- /dev/null +++ b/public/app/features/provisioning/Wizard/MigrateStep.tsx @@ -0,0 +1,108 @@ +import { skipToken } from '@reduxjs/toolkit/query/react'; +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Button, FieldSet, Stack, Text, Switch, Field } from '@grafana/ui'; + +import { JobStatus } from '../JobStatus'; +import { useCreateRepositoryMigrateMutation, useGetRepositoryQuery } from '../api'; + +import { WizardFormData } from './types'; + +export interface MigrateStepProps { + onMigrationStatusChange: (success: boolean) => void; +} + +export function MigrateStep({ onMigrationStatusChange }: MigrateStepProps) { + const [migrateRepo, migrateQuery] = useCreateRepositoryMigrateMutation(); + const [showMigrateStatus, setShowMigrateStatus] = useState(false); + const { watch, register } = useFormContext(); + const [repositoryName, history, identifier] = watch(['repositoryName', 'migrate.history', 'migrate.identifier']); + const migrateName = migrateQuery.data?.metadata?.name; + const repositoryQuery = useGetRepositoryQuery(repositoryName ? { name: repositoryName } : skipToken); + const stats = repositoryQuery?.data?.status?.stats || []; + + const handleMigrate = async () => { + if (!repositoryName) { + return; + } + + try { + await migrateRepo({ + name: repositoryName, + body: { + identifier, + history, + }, + }); + setShowMigrateStatus(true); + onMigrationStatusChange(true); + } catch (error) { + onMigrationStatusChange(false); + } + }; + + const onAbort = () => { + migrateQuery.reset(); + setShowMigrateStatus(false); + onMigrationStatusChange(false); + }; + + if (showMigrateStatus && migrateName) { + return ( + + + + + Abort migration + + + + ); + } + + return ( + + + + Migrate all dashboards from this instance to your repository. After this one-time migration, all future + updates will be automatically saved to the repository. + + + {Boolean(stats.length) && ( + + Resources to migrate: + {stats.map((stat) => { + return ( + + {stat.resource}: + {stat.count} + + ); + })} + + )} + + + + + + + + + + + + + + {migrateQuery.isLoading ? 'Migrating...' : 'Start migration'} + + + + + ); +} diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx new file mode 100644 index 00000000000..3033ae8484c --- /dev/null +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -0,0 +1,121 @@ +import { css } from '@emotion/css'; +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Stack, useStyles2 } from '@grafana/ui'; + +import { getDefaultValues } from '../ConfigForm'; +import { RepositorySpec } from '../api'; + +import { ConnectionStep } from './ConnectionStep'; +import { MigrateStep } from './MigrateStep'; +import { RepositoryStep } from './RepositoryStep'; +import { Stepper, Step } from './Stepper'; +import { WizardFormData, WizardStep } from './types'; + +const steps: Array> = [ + { id: 'connection', name: 'Repository connection' }, + { id: 'repository', name: 'Repository configuration' }, + { id: 'migrate', name: 'Migrate dashboards' }, +]; + +const nextButtonText = { + connection: 'Connect to your repository', + repository: 'Configure migration', + migrate: 'Finish', +} as const; + +export interface WizardProps { + data?: RepositorySpec; + onSubmit: (data: RepositorySpec) => void; +} + +export function ProvisioningWizard({ data, onSubmit }: WizardProps) { + const navigate = useNavigate(); + const [activeStep, setActiveStep] = useState('connection'); + const [completedSteps, setCompletedSteps] = useState([]); + const [migrationSuccess, setMigrationSuccess] = useState(false); + const methods = useForm({ + defaultValues: { + repository: getDefaultValues(data), + migrate: { + history: true, + identifier: false, + }, + }, + }); + const styles = useStyles2(getStyles); + + const handleNext = async () => { + const currentStepIndex = steps.findIndex((s) => s.id === activeStep); + if (currentStepIndex < steps.length - 1) { + if (['connection', 'repository'].includes(activeStep)) { + // Validate repository form data before proceeding + const isValid = await methods.trigger('repository'); + if (!isValid) { + return; + } + } + + setCompletedSteps([...completedSteps, activeStep]); + setActiveStep(steps[currentStepIndex + 1].id); + } else if (activeStep === 'migrate' && migrationSuccess) { + navigate('/dashboards'); + } + }; + + const handleBack = () => { + const currentStepIndex = steps.findIndex((s) => s.id === activeStep); + if (currentStepIndex > 0) { + setActiveStep(steps[currentStepIndex - 1].id); + } + }; + + return ( + + + {}} + validationResults={{ + connection: { valid: true }, + repository: { valid: true }, + migrate: { valid: true }, + }} + getNextUrl={() => ''} + /> + + + {activeStep === 'connection' && } + {activeStep === 'repository' && } + {activeStep === 'migrate' && } + + + + {activeStep !== 'connection' && ( + + Back + + )} + + {nextButtonText[activeStep]} + + + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + form: css({ + maxWidth: '900px', + }), + content: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + paddingBottom: theme.spacing(4), + marginBottom: theme.spacing(4), + }), +}); diff --git a/public/app/features/provisioning/Wizard/RepositoryStep.tsx b/public/app/features/provisioning/Wizard/RepositoryStep.tsx new file mode 100644 index 00000000000..5a438facd01 --- /dev/null +++ b/public/app/features/provisioning/Wizard/RepositoryStep.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { AppEvents } from '@grafana/data'; +import { getAppEvents } from '@grafana/runtime'; +import { Button, Field, FieldSet, Input, MultiCombobox, SecretInput, Stack, Switch } from '@grafana/ui'; + +import { TokenPermissionsInfo } from '../TokenPermissionsInfo'; +import { useCreateOrUpdateRepository } from '../hooks'; +import { dataToSpec } from '../utils/data'; + +import { WizardFormData } from './types'; + +const workflowOptions = [ + { label: 'Push', value: 'push' }, + { label: 'Branch', value: 'branch' }, +]; + +const appEvents = getAppEvents(); + +export function RepositoryStep() { + const { + register, + control, + watch, + setValue, + getValues, + formState: { errors }, + } = useFormContext(); + const [submitData, request] = useCreateOrUpdateRepository(); + + const type = watch('repository.type'); + const [tokenConfigured, setTokenConfigured] = useState(false); + + const handleConnect = async () => { + const formData = getValues(); + const response = await submitData(dataToSpec(formData.repository)); + + if (response.data?.metadata?.name) { + setValue('repositoryName', response.data.metadata.name); + } + }; + + useEffect(() => { + if (request.isSuccess) { + appEvents.publish({ + type: AppEvents.alertSuccess.name, + payload: ['Repository settings saved'], + }); + } + + if (request.isError) { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: ['Failed to save repository settings', request.error], + }); + } + }, [request.error, request.isError, request.isSuccess]); + + if (type === 'github') { + return ( + + + + + + { + return ( + { + setValue('repository.token', ''); + setTokenConfigured(false); + }} + /> + ); + }} + /> + + + + + + + + + + + + ( + + )} + /> + + + + + + + + + {request.isLoading ? 'Connecting...' : 'Connect & verify'} + + + + + ); + } + + if (type === 'local') { + return ( + + + + + + ); + } + + return null; +} diff --git a/public/app/features/provisioning/Wizard/Stepper.tsx b/public/app/features/provisioning/Wizard/Stepper.tsx new file mode 100644 index 00000000000..a5725fe2d86 --- /dev/null +++ b/public/app/features/provisioning/Wizard/Stepper.tsx @@ -0,0 +1,117 @@ +import { css, cx } from '@emotion/css'; +import { MouseEvent } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Icon } from '@grafana/ui'; + +import { ValidationResult } from './types'; + +export interface Step { + id: T; + name: string; +} + +export interface Props { + activeStep?: T; + onStepChange: (step: T, event: MouseEvent) => void; + reportId?: string; + visitedSteps?: T[]; + steps: Array>; + validationResults: Record; + getNextUrl: (step: T) => string; +} + +export function Stepper({ + validationResults, + onStepChange, + visitedSteps = [], + getNextUrl, + steps, + activeStep = steps[0]?.id, +}: Props) { + const styles = useStyles2(getStyles); + const lastStep = steps[steps.length - 1]; + + return ( + + {steps.map((step) => { + const isLast = step.id === lastStep.id; + const isActive = step.id === activeStep; + const isVisited = visitedSteps.includes(step.id) || activeStep === lastStep.id; + const hasMissingFields = !isLast && !validationResults[step.id].valid; + const showIndicator = !isActive && !isLast && isVisited; + const successField = showIndicator && !hasMissingFields; + const warnField = showIndicator && hasMissingFields; + const itemStyles = cx(styles.item, { + [styles.active]: isActive, + [styles.successItem]: successField, + [styles.warnItem]: warnField, + }); + + return ( + + {successField && } + {warnField && } + {step.name} + {!isLast && —} + + ); + })} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + counterReset: 'item', + listStyleType: 'none', + width: '100%', + position: 'relative', + display: 'flex', + justifyContent: 'center', + border: `1px solid ${theme.colors.border.weak}`, + margin: theme.spacing(4, 0), + }), + item: css({ + color: theme.colors.text.secondary, + display: 'flex', + alignItems: 'center', + }), + successItem: css({ + 'a::before': { + content: '""', + }, + svg: { + color: theme.colors.success.text, + margin: theme.spacing(0, 0.5, 0, -1), + }, + }), + warnItem: css({ + 'a::before': { + content: '""', + }, + svg: { + color: theme.colors.warning.text, + margin: theme.spacing(0, 1, 0.5, -0.5), + }, + }), + link: css({ + color: 'inherit', + '&::before': { + content: 'counter(item) " "', + counterIncrement: 'item', + }, + }), + active: css({ + fontWeight: 500, + color: theme.colors.text.maxContrast, + '&::before': { + fontWeight: 500, + }, + }), + divider: css({ + padding: theme.spacing(2), + }), + }; +}; diff --git a/public/app/features/provisioning/Wizard/WizardPage.tsx b/public/app/features/provisioning/Wizard/WizardPage.tsx new file mode 100644 index 00000000000..23695b12c04 --- /dev/null +++ b/public/app/features/provisioning/Wizard/WizardPage.tsx @@ -0,0 +1,23 @@ +import { Page } from 'app/core/components/Page/Page'; + +import { SetupWarnings } from '../SetupWarnings'; + +import { ProvisioningWizard } from './ProvisioningWizard'; + +export default function NewRepositoryPage() { + return ( + + + + { + console.log('d', data); + }} + /> + + + ); +} diff --git a/public/app/features/provisioning/Wizard/types.ts b/public/app/features/provisioning/Wizard/types.ts new file mode 100644 index 00000000000..44fb00042fa --- /dev/null +++ b/public/app/features/provisioning/Wizard/types.ts @@ -0,0 +1,21 @@ +import { RepositoryFormData } from '../types'; + +export type WizardStep = 'connection' | 'repository' | 'migrate'; + +export interface MigrateFormData { + dashboards: string[]; + folders: string[]; + history: boolean; + identifier: boolean; +} + +export interface WizardFormData { + repository: RepositoryFormData; + migrate?: MigrateFormData; + repositoryName?: string; +} + +export type ValidationResult = { + valid: boolean; + errors?: string[]; +}; diff --git a/public/app/features/provisioning/utils/routes.ts b/public/app/features/provisioning/utils/routes.ts index 72f2e4f8e81..821dee51171 100644 --- a/public/app/features/provisioning/utils/routes.ts +++ b/public/app/features/provisioning/utils/routes.ts @@ -24,6 +24,12 @@ export function getProvisioningRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "RepositoryListPage"*/ 'app/features/provisioning/RepositoryListPage') ), }, + { + path: PROVISIONING_URL + '/setup', + component: SafeDynamicImport( + () => import(/* webpackChunkName: "ProvisioningWizardPage"*/ 'app/features/provisioning/Wizard/WizardPage') + ), + }, { path: PROVISIONING_URL + '/new', component: SafeDynamicImport(