POC/Provisioning: Add wizard (#100596)
This commit is contained in:
1 parent
1bf564c014
commit
0a4595002d
14 files changed
+756
-14
No files matched your search
+49
-1
@@ -5749,7 +5749,8 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"]
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
|
||||
],
|
||||
"public/app/features/provisioning/FileHistoryPage.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
@@ -5882,6 +5883,53 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
|
||||
],
|
||||
"public/app/features/provisioning/Wizard/ConnectionStep.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "6"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"]
|
||||
],
|
||||
"public/app/features/provisioning/Wizard/MigrateStep.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"]
|
||||
],
|
||||
"public/app/features/provisioning/Wizard/ProvisioningWizard.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/provisioning/Wizard/RepositoryStep.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "6"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "7"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "8"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "9"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "10"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "11"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "12"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "13"]
|
||||
],
|
||||
"public/app/features/provisioning/Wizard/Stepper.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,7 +38,7 @@ const workflowOptions: Array<ComboboxOption<WorkflowOption>> = [
|
||||
|
||||
const appEvents = getAppEvents();
|
||||
|
||||
function getDefaultValues(repository?: RepositorySpec): RepositoryFormData {
|
||||
export function getDefaultValues(repository?: RepositorySpec): RepositoryFormData {
|
||||
if (!repository) {
|
||||
return {
|
||||
type: 'github',
|
||||
|
||||
@@ -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<ExportJobOptions>({
|
||||
@@ -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 <JobStatus name={exportJob} />;
|
||||
const onAbort = () => {
|
||||
exportQuery.reset();
|
||||
setShowExportStatus(false);
|
||||
};
|
||||
|
||||
if (showExportStatus && exportJob) {
|
||||
return (
|
||||
<Box paddingTop={2}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<JobStatus name={exportJob} />
|
||||
<Stack gap={2}>
|
||||
<Button variant="secondary" onClick={onAbort}>
|
||||
Abort export
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isGit = repo.spec?.type === 'github';
|
||||
|
||||
@@ -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 (
|
||||
<Page navId="provisioning" subTitle="View and manage your configured repositories">
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<SetupWarnings />
|
||||
{settings.data?.legacyStorage && (
|
||||
<Alert title="Legacy Storage" severity="error">
|
||||
<Alert
|
||||
title="Legacy Storage"
|
||||
severity="error"
|
||||
buttonContent={<>Use provisioning wizard to configure a repository.</>}
|
||||
onRemove={() => {
|
||||
navigate('/admin/provisioning/setup');
|
||||
}}
|
||||
>
|
||||
Require running the onboarding wizard to convert from legacy to unified
|
||||
</Alert>
|
||||
)}
|
||||
@@ -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(
|
||||
<TextLink key={'link'} external style={{ color: 'inherit' }} href={branch}>
|
||||
@@ -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(
|
||||
<TextLink key={'webhook'} style={{ color: 'inherit' }} href={hook}>
|
||||
Webhook <Icon name={'check'} />
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
InteractiveTable,
|
||||
LinkButton,
|
||||
ConfirmModal,
|
||||
Button,
|
||||
} from '@grafana/ui';
|
||||
import { Button } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
Repository,
|
||||
|
||||
@@ -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<WizardFormData>();
|
||||
|
||||
return (
|
||||
<FieldSet label="1. Set up your repository connection details">
|
||||
<Stack direction="column" gap={2}>
|
||||
<Field
|
||||
label="Repository type"
|
||||
description="Choose the type of repository where your dashboards will be stored."
|
||||
required
|
||||
>
|
||||
<Controller
|
||||
name="repository.type"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Combobox
|
||||
{...field}
|
||||
options={typeOptions}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
placeholder="Select repository type"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Alert severity="info" title="Note">
|
||||
Dashboards app/Grafana will be unavailable when starting this process.
|
||||
</Alert>
|
||||
|
||||
<Field
|
||||
label="Display name"
|
||||
description="Add a clear name for this configuration"
|
||||
error={errors.repository?.title?.message}
|
||||
invalid={!!errors.repository?.title}
|
||||
>
|
||||
<Input
|
||||
{...register('repository.title', { required: 'This field is required.' })}
|
||||
placeholder="My repository connection"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Stack direction="column" gap={2}>
|
||||
<Controller
|
||||
name="repository.sync.target"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
{modeOptions.map((option) => (
|
||||
<Card key={option.value} isSelected={value === option.value} onClick={() => onChange(option.value)}>
|
||||
<Card.Heading>{option.label}</Card.Heading>
|
||||
<Card.Description>{option.description}</Card.Description>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
@@ -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<WizardFormData>();
|
||||
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 (
|
||||
<Stack direction="column" gap={2}>
|
||||
<JobStatus name={migrateName} />
|
||||
<Stack gap={2}>
|
||||
<Button variant="secondary" onClick={onAbort}>
|
||||
Abort migration
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldSet label="3. Migrate dashboards">
|
||||
<Stack direction={'column'} gap={2}>
|
||||
<Text color="secondary">
|
||||
Migrate all dashboards from this instance to your repository. After this one-time migration, all future
|
||||
updates will be automatically saved to the repository.
|
||||
</Text>
|
||||
|
||||
{Boolean(stats.length) && (
|
||||
<Stack direction={'column'} width={'300px'}>
|
||||
<Text>Resources to migrate:</Text>
|
||||
{stats.map((stat) => {
|
||||
return (
|
||||
<Stack justifyContent={'space-between'} key={stat.group}>
|
||||
<Text>{stat.resource}:</Text>
|
||||
<Text>{stat.count}</Text>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FieldSet>
|
||||
<Field label="Identifier" description="Include the current identifier in exported metadata">
|
||||
<Switch {...register('migrate.identifier')} />
|
||||
</Field>
|
||||
|
||||
<Field label="History" description="Include commits for each historical value">
|
||||
<Switch {...register('migrate.history')} />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
<Stack alignItems={'flex-start'}>
|
||||
<Button
|
||||
onClick={handleMigrate}
|
||||
disabled={migrateQuery.isLoading || !repositoryName}
|
||||
icon={migrateQuery.isLoading ? 'spinner' : undefined}
|
||||
>
|
||||
{migrateQuery.isLoading ? 'Migrating...' : 'Start migration'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
@@ -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<Step<WizardStep>> = [
|
||||
{ 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<WizardStep>('connection');
|
||||
const [completedSteps, setCompletedSteps] = useState<WizardStep[]>([]);
|
||||
const [migrationSuccess, setMigrationSuccess] = useState(false);
|
||||
const methods = useForm<WizardFormData>({
|
||||
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 (
|
||||
<FormProvider {...methods}>
|
||||
<form className={styles.form}>
|
||||
<Stepper
|
||||
steps={steps}
|
||||
activeStep={activeStep}
|
||||
onStepChange={() => {}}
|
||||
validationResults={{
|
||||
connection: { valid: true },
|
||||
repository: { valid: true },
|
||||
migrate: { valid: true },
|
||||
}}
|
||||
getNextUrl={() => ''}
|
||||
/>
|
||||
|
||||
<div className={styles.content}>
|
||||
{activeStep === 'connection' && <ConnectionStep />}
|
||||
{activeStep === 'repository' && <RepositoryStep />}
|
||||
{activeStep === 'migrate' && <MigrateStep onMigrationStatusChange={setMigrationSuccess} />}
|
||||
</div>
|
||||
|
||||
<Stack gap={2} justifyContent="flex-end">
|
||||
{activeStep !== 'connection' && (
|
||||
<Button variant="secondary" onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleNext} disabled={activeStep === 'migrate' && !migrationSuccess}>
|
||||
{nextButtonText[activeStep]}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
});
|
||||
@@ -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<WizardFormData>();
|
||||
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 (
|
||||
<FieldSet label="2. Configure repository">
|
||||
<div>
|
||||
<TokenPermissionsInfo />
|
||||
|
||||
<Field
|
||||
label={'Token'}
|
||||
required
|
||||
error={errors.repository?.token?.message}
|
||||
invalid={!!errors.repository?.token}
|
||||
>
|
||||
<Controller
|
||||
name={'repository.token'}
|
||||
control={control}
|
||||
rules={{ required: 'This field is required.' }}
|
||||
render={({ field: { ref, ...field } }) => {
|
||||
return (
|
||||
<SecretInput
|
||||
{...field}
|
||||
id={'token'}
|
||||
placeholder={'ghp_yourTokenHere1234567890abcdEFGHijklMNOP'}
|
||||
isConfigured={tokenConfigured}
|
||||
onReset={() => {
|
||||
setValue('repository.token', '');
|
||||
setTokenConfigured(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={'Repository URL'}
|
||||
error={errors.repository?.url?.message}
|
||||
invalid={!!errors.repository?.url}
|
||||
description={'Enter the GitHub repository URL'}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('repository.url', {
|
||||
required: 'This field is required.',
|
||||
pattern: {
|
||||
value: /^(?:https:\/\/github\.com\/)?[^/]+\/[^/]+$/,
|
||||
message: 'Please enter a valid GitHub repository URL',
|
||||
},
|
||||
})}
|
||||
placeholder={'https://github.com/username/repo-name'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={'Branch'} error={errors.repository?.branch?.message} invalid={!!errors.repository?.branch}>
|
||||
<Input {...register('repository.branch')} placeholder={'main'} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={'Workflows'}
|
||||
required
|
||||
error={errors.repository?.workflows?.message}
|
||||
invalid={!!errors.repository?.workflows}
|
||||
>
|
||||
<Controller
|
||||
name={'repository.workflows'}
|
||||
control={control}
|
||||
rules={{ required: 'This field is required.' }}
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<MultiCombobox options={workflowOptions} placeholder={'Select workflows'} {...field} />
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={'Show dashboard previews'}>
|
||||
<Switch {...register('repository.generateDashboardPreviews')} />
|
||||
</Field>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={request.isLoading}
|
||||
icon={request.isLoading ? 'spinner' : 'check-circle'}
|
||||
>
|
||||
{request.isLoading ? 'Connecting...' : 'Connect & verify'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'local') {
|
||||
return (
|
||||
<FieldSet label="2. Configure repository">
|
||||
<Field label={'Local path'} error={errors.repository?.path?.message} invalid={!!errors.repository?.path}>
|
||||
<Input
|
||||
{...register('repository.path', { required: 'This field is required.' })}
|
||||
placeholder={'/path/to/repo'}
|
||||
/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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<T> {
|
||||
id: T;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Props<T extends string | number> {
|
||||
activeStep?: T;
|
||||
onStepChange: (step: T, event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
reportId?: string;
|
||||
visitedSteps?: T[];
|
||||
steps: Array<Step<T>>;
|
||||
validationResults: Record<T, ValidationResult>;
|
||||
getNextUrl: (step: T) => string;
|
||||
}
|
||||
|
||||
export function Stepper<T extends string | number>({
|
||||
validationResults,
|
||||
onStepChange,
|
||||
visitedSteps = [],
|
||||
getNextUrl,
|
||||
steps,
|
||||
activeStep = steps[0]?.id,
|
||||
}: Props<T>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const lastStep = steps[steps.length - 1];
|
||||
|
||||
return (
|
||||
<ol className={styles.container}>
|
||||
{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 (
|
||||
<li key={step.id} className={itemStyles}>
|
||||
{successField && <Icon name={'check'} size={'xl'} className={styles.successItem} />}
|
||||
{warnField && <Icon name={'exclamation-triangle'} className={styles.warnItem} />}
|
||||
<div className={styles.link}>{step.name}</div>
|
||||
{!isLast && <div className={styles.divider}>—</div>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<Page
|
||||
navId="provisioning"
|
||||
pageNav={{ text: 'Configure repository', subTitle: 'Configure a repository for storing your resources.' }}
|
||||
>
|
||||
<Page.Contents>
|
||||
<SetupWarnings />
|
||||
<ProvisioningWizard
|
||||
onSubmit={(data: any) => {
|
||||
console.log('d', data);
|
||||
}}
|
||||
/>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user