mirror of
https://github.com/portainer/portainer.git
synced 2025-08-09 15:55:23 +02:00
refactor(wizard): migrate to react [EE-2305] (#6957)
This commit is contained in:
parent
3aacaa7caf
commit
01dc9066b7
125 changed files with 2994 additions and 1744 deletions
|
@ -0,0 +1,67 @@
|
|||
import { useState } from 'react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
|
||||
|
||||
import {
|
||||
EnvironmentSelector,
|
||||
EnvironmentSelectorValue,
|
||||
} from './EnvironmentSelector';
|
||||
import { environmentTypes } from './environment-types';
|
||||
|
||||
export function EnvironmentTypeSelectView() {
|
||||
const [types, setTypes] = useState<EnvironmentSelectorValue[]>([]);
|
||||
const { trackEvent } = useAnalytics();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Quick Setup"
|
||||
breadcrumbs={[{ label: 'Environment Wizard' }]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetTitle icon="fa-magic" title="Environment Wizard" />
|
||||
<WidgetBody>
|
||||
<EnvironmentSelector value={types} onChange={setTypes} />
|
||||
<Button
|
||||
disabled={types.length === 0}
|
||||
onClick={() => startWizard()}
|
||||
>
|
||||
Start Wizard
|
||||
</Button>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function startWizard() {
|
||||
if (types.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = _.compact(
|
||||
types.map((id) => environmentTypes.find((eType) => eType.id === id))
|
||||
);
|
||||
|
||||
trackEvent('endpoint-wizard-endpoint-select', {
|
||||
category: 'portainer',
|
||||
metadata: {
|
||||
environment: steps.map((step) => step.title).join('/'),
|
||||
},
|
||||
});
|
||||
|
||||
router.stateService.go('portainer.wizard.endpoints.create', {
|
||||
envType: types,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import { FormSection } from '@/portainer/components/form-components/FormSection';
|
||||
|
||||
import { Option } from '../components/Option';
|
||||
|
||||
import { environmentTypes } from './environment-types';
|
||||
|
||||
export type EnvironmentSelectorValue = typeof environmentTypes[number]['id'];
|
||||
|
||||
interface Props {
|
||||
value: EnvironmentSelectorValue[];
|
||||
onChange(value: EnvironmentSelectorValue[]): void;
|
||||
}
|
||||
|
||||
export function EnvironmentSelector({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="row">
|
||||
<FormSection title="Select your environment(s)">
|
||||
<p className="text-muted small">
|
||||
You can onboard different types of environments, select all that
|
||||
apply.
|
||||
</p>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{environmentTypes.map((eType) => (
|
||||
<Option
|
||||
key={eType.id}
|
||||
title={eType.title}
|
||||
description={eType.description}
|
||||
icon={eType.icon}
|
||||
active={value.includes(eType.id)}
|
||||
onClick={() => handleClick(eType.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleClick(eType: EnvironmentSelectorValue) {
|
||||
if (value.includes(eType)) {
|
||||
onChange(value.filter((v) => v !== eType));
|
||||
return;
|
||||
}
|
||||
|
||||
onChange([...value, eType]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
export const environmentTypes = [
|
||||
{
|
||||
id: 'docker',
|
||||
title: 'Docker',
|
||||
icon: 'fab fa-docker',
|
||||
description:
|
||||
'Connect to Docker Standalone / Swarm via URL/IP, API or Socket',
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
title: 'Kubernetes',
|
||||
icon: 'fas fa-dharmachakra',
|
||||
description: 'Connect to a kubernetes environment via URL/IP',
|
||||
},
|
||||
{
|
||||
id: 'aci',
|
||||
title: 'ACI',
|
||||
description: 'Connect to ACI environment via API',
|
||||
icon: 'fab fa-microsoft',
|
||||
},
|
||||
] as const;
|
|
@ -0,0 +1 @@
|
|||
export { EnvironmentTypeSelectView } from './EndpointTypeView';
|
|
@ -0,0 +1,16 @@
|
|||
.wizard-step-action {
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
text-align: right;
|
||||
border-top: 1px solid #777;
|
||||
}
|
||||
|
||||
.wizard-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-areas:
|
||||
'main sidebar'
|
||||
'footer sidebar';
|
||||
gap: 10px;
|
||||
margin: 0 15px;
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Stepper } from '@/react/components/Stepper';
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { Environment, EnvironmentId } from '@/portainer/environments/types';
|
||||
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
|
||||
import { FormSection } from '@/portainer/components/form-components/FormSection';
|
||||
|
||||
import { environmentTypes } from '../EnvironmentTypeSelectView/environment-types';
|
||||
import { EnvironmentSelectorValue } from '../EnvironmentTypeSelectView/EnvironmentSelector';
|
||||
|
||||
import { WizardDocker } from './WizardDocker';
|
||||
import { WizardAzure } from './WizardAzure';
|
||||
import { WizardKubernetes } from './WizardKubernetes';
|
||||
import { AnalyticsState, AnalyticsStateKey } from './types';
|
||||
import styles from './EnvironmentsCreationView.module.css';
|
||||
import { WizardEndpointsList } from './WizardEndpointsList';
|
||||
|
||||
export function EnvironmentCreationView() {
|
||||
const {
|
||||
params: { localEndpointId: localEndpointIdParam },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const [environmentIds, setEnvironmentIds] = useState<EnvironmentId[]>(() => {
|
||||
const localEndpointId = parseInt(localEndpointIdParam, 10);
|
||||
|
||||
if (!localEndpointId || Number.isNaN(localEndpointId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [localEndpointId];
|
||||
});
|
||||
|
||||
const envTypes = useParamEnvironmentTypes();
|
||||
const { trackEvent } = useAnalytics();
|
||||
const router = useRouter();
|
||||
const steps = _.compact(
|
||||
envTypes.map((id) => environmentTypes.find((eType) => eType.id === id))
|
||||
);
|
||||
const { analytics, setAnalytics } = useAnalyticsState();
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
onNextClick,
|
||||
onPreviousClick,
|
||||
currentStepIndex,
|
||||
Component,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
} = useStepper(steps, handleFinish);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Quick Setup"
|
||||
breadcrumbs={[{ label: 'Environment Wizard' }]}
|
||||
/>
|
||||
|
||||
<div className={styles.wizardWrapper}>
|
||||
<Widget>
|
||||
<WidgetTitle icon="fa-magic" title="Environment Wizard" />
|
||||
<WidgetBody>
|
||||
<Stepper steps={steps} currentStep={currentStepIndex + 1} />
|
||||
|
||||
<div className="mt-12">
|
||||
<FormSection
|
||||
title={`Connect to your ${currentStep.title}
|
||||
environment`}
|
||||
>
|
||||
<Component onCreate={handleCreateEnvironment} />
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
styles.wizardStepAction,
|
||||
'flex justify-between'
|
||||
)}
|
||||
>
|
||||
<Button disabled={isFirstStep} onClick={onPreviousClick}>
|
||||
<i className="fas fa-arrow-left space-right" /> Previous
|
||||
</Button>
|
||||
<Button onClick={onNextClick}>
|
||||
{isLastStep ? 'Finish' : 'Next'}
|
||||
<i className="fas fa-arrow-right space-left" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<div>
|
||||
<WizardEndpointsList environmentIds={environmentIds} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function handleCreateEnvironment(
|
||||
environment: Environment,
|
||||
analytics: AnalyticsStateKey
|
||||
) {
|
||||
setEnvironmentIds((prev) => [...prev, environment.Id]);
|
||||
setAnalytics(analytics);
|
||||
}
|
||||
|
||||
function handleFinish() {
|
||||
trackEvent('endpoint-wizard-environment-add-finish', {
|
||||
category: 'portainer',
|
||||
metadata: Object.fromEntries(
|
||||
Object.entries(analytics).map(([key, value]) => [
|
||||
_.kebabCase(key),
|
||||
value,
|
||||
])
|
||||
),
|
||||
});
|
||||
router.stateService.go('portainer.home');
|
||||
}
|
||||
}
|
||||
|
||||
function useParamEnvironmentTypes(): EnvironmentSelectorValue[] {
|
||||
const {
|
||||
params: { envType },
|
||||
} = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
|
||||
if (!envType) {
|
||||
notifyError('No environment type provided');
|
||||
router.stateService.go('portainer.wizard.endpoints');
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.isArray(envType) ? envType : [envType];
|
||||
}
|
||||
|
||||
function useStepper(
|
||||
steps: typeof environmentTypes[number][],
|
||||
onFinish: () => void
|
||||
) {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
onNextClick,
|
||||
onPreviousClick,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
currentStepIndex,
|
||||
Component: getComponent(currentStep.id),
|
||||
};
|
||||
|
||||
function onNextClick() {
|
||||
if (!isLastStep) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
onFinish();
|
||||
}
|
||||
|
||||
function onPreviousClick() {
|
||||
setCurrentStepIndex(currentStepIndex - 1);
|
||||
}
|
||||
|
||||
function getComponent(id: EnvironmentSelectorValue) {
|
||||
switch (id) {
|
||||
case 'docker':
|
||||
return WizardDocker;
|
||||
case 'aci':
|
||||
return WizardAzure;
|
||||
case 'kubernetes':
|
||||
return WizardKubernetes;
|
||||
default:
|
||||
throw new Error(`Unknown environment type ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useAnalyticsState() {
|
||||
const [analytics, setAnalyticsState] = useState<AnalyticsState>({
|
||||
dockerAgent: 0,
|
||||
dockerApi: 0,
|
||||
kubernetesAgent: 0,
|
||||
kubernetesEdgeAgent: 0,
|
||||
kaasAgent: 0,
|
||||
aciApi: 0,
|
||||
localEndpoint: 0,
|
||||
nomadEdgeAgent: 0,
|
||||
});
|
||||
|
||||
return { analytics, setAnalytics };
|
||||
|
||||
function setAnalytics(key: AnalyticsStateKey) {
|
||||
setAnalyticsState((prevState) => ({
|
||||
...prevState,
|
||||
[key]: prevState[key] + 1,
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
import { Field, Form, Formik } from 'formik';
|
||||
import { useReducer, useState } from 'react';
|
||||
import { object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { useCreateAzureEnvironmentMutation } from '@/portainer/environments/queries/useCreateEnvironmentMutation';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
|
||||
|
||||
import { NameField, nameValidation } from '../shared/NameField';
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
import { MetadataFieldset } from '../shared/MetadataFieldset';
|
||||
import { metadataValidation } from '../shared/MetadataFieldset/validation';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
applicationId: string;
|
||||
tenantId: string;
|
||||
authenticationKey: string;
|
||||
meta: EnvironmentMetadata;
|
||||
}
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
applicationId: '',
|
||||
tenantId: '',
|
||||
authenticationKey: '',
|
||||
meta: {
|
||||
groupId: 1,
|
||||
tagIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const options = [buildOption('api', 'fa fa-bolt', 'API', '', 'api')];
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
|
||||
}
|
||||
|
||||
export function WizardAzure({ onCreate }: Props) {
|
||||
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
|
||||
|
||||
const [creationType, setCreationType] = useState(options[0].id);
|
||||
const mutation = useCreateAzureEnvironmentMutation();
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<BoxSelector
|
||||
options={options}
|
||||
radioName="creation-type"
|
||||
onChange={(value) => setCreationType(value)}
|
||||
value={creationType}
|
||||
/>
|
||||
|
||||
<Formik<FormValues>
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
key={formKey}
|
||||
validateOnMount
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
{({ errors, dirty, isValid }) => (
|
||||
<Form>
|
||||
<NameField />
|
||||
|
||||
<FormControl
|
||||
label="Application ID"
|
||||
errors={errors.applicationId}
|
||||
inputId="applicationId-input"
|
||||
required
|
||||
>
|
||||
<Field
|
||||
name="applicationId"
|
||||
id="applicationId-input"
|
||||
as={Input}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Tenant ID"
|
||||
errors={errors.tenantId}
|
||||
inputId="tenantId-input"
|
||||
required
|
||||
>
|
||||
<Field
|
||||
name="tenantId"
|
||||
id="tenantId-input"
|
||||
as={Input}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Authentication Key"
|
||||
errors={errors.authenticationKey}
|
||||
inputId="authenticationKey-input"
|
||||
required
|
||||
>
|
||||
<Field
|
||||
name="authenticationKey"
|
||||
id="authenticationKey-input"
|
||||
as={Input}
|
||||
placeholder="cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<MetadataFieldset />
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={mutation.isLoading}
|
||||
disabled={!dirty || !isValid}
|
||||
>
|
||||
<i className="fa fa-plug" aria-hidden="true" /> Connect
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleSubmit({
|
||||
applicationId,
|
||||
authenticationKey,
|
||||
meta,
|
||||
name,
|
||||
tenantId,
|
||||
}: typeof initialValues) {
|
||||
mutation.mutate(
|
||||
{
|
||||
name,
|
||||
azure: {
|
||||
applicationId,
|
||||
authenticationKey,
|
||||
tenantId,
|
||||
},
|
||||
meta,
|
||||
},
|
||||
{
|
||||
onSuccess(environment) {
|
||||
notifySuccess('Environment created', environment.Name);
|
||||
clearForm();
|
||||
onCreate(environment, 'aciApi');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validationSchema(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
name: nameValidation(),
|
||||
applicationId: string().required('Application ID is required'),
|
||||
tenantId: string().required('Tenant ID is required'),
|
||||
authenticationKey: string().required('Authentication Key is required'),
|
||||
meta: metadataValidation(),
|
||||
});
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { WizardAzure } from './WizardAzure';
|
|
@ -0,0 +1,131 @@
|
|||
import { Field, Form, Formik } from 'formik';
|
||||
import { useReducer } from 'react';
|
||||
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { useCreateRemoteEnvironmentMutation } from '@/portainer/environments/queries/useCreateEnvironmentMutation';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentCreationTypes,
|
||||
} from '@/portainer/environments/types';
|
||||
|
||||
import { NameField } from '../../shared/NameField';
|
||||
import { MetadataFieldset } from '../../shared/MetadataFieldset';
|
||||
|
||||
import { validation } from './APIForm.validation';
|
||||
import { FormValues } from './types';
|
||||
import { TLSFieldset } from './TLSFieldset';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function APIForm({ onCreate }: Props) {
|
||||
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
|
||||
const initialValues: FormValues = {
|
||||
url: '',
|
||||
name: '',
|
||||
tls: false,
|
||||
meta: {
|
||||
groupId: 1,
|
||||
tagIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
const mutation = useCreateRemoteEnvironmentMutation(
|
||||
EnvironmentCreationTypes.LocalDockerEnvironment
|
||||
);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
key={formKey}
|
||||
>
|
||||
{({ isValid, dirty }) => (
|
||||
<Form>
|
||||
<NameField />
|
||||
|
||||
<FormControl
|
||||
inputId="url-field"
|
||||
label="Docker API URL"
|
||||
required
|
||||
tooltip="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
id="url-field"
|
||||
name="url"
|
||||
placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<TLSFieldset />
|
||||
|
||||
<MetadataFieldset />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="wizard-connect-button"
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={mutation.isLoading}
|
||||
disabled={!dirty || !isValid}
|
||||
>
|
||||
<i className="fa fa-plug" aria-hidden="true" /> Connect
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
const tls = getTlsValues();
|
||||
|
||||
mutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
options: {
|
||||
tls,
|
||||
meta: values.meta,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess(environment) {
|
||||
notifySuccess('Environment created', environment.Name);
|
||||
clearForm();
|
||||
onCreate(environment);
|
||||
},
|
||||
}
|
||||
);
|
||||
function getTlsValues() {
|
||||
if (!values.tls) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
skipVerify: values.skipVerify,
|
||||
...getCertFiles(),
|
||||
};
|
||||
|
||||
function getCertFiles() {
|
||||
if (values.skipVerify) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
caCertFile: values.caCertFile,
|
||||
certFile: values.certFile,
|
||||
keyFile: values.keyFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { boolean, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
|
||||
import { nameValidation } from '../../shared/NameField';
|
||||
|
||||
import { validation as certsValidation } from './TLSFieldset';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function validation(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
name: nameValidation(),
|
||||
url: string().required('This field is required.'),
|
||||
tls: boolean().default(false),
|
||||
skipVerify: boolean(),
|
||||
meta: metadataValidation(),
|
||||
...certsValidation(),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
import { APIForm } from './APIForm';
|
||||
import { DeploymentScripts } from './DeploymentScripts';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function APITab({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DeploymentScripts />
|
||||
|
||||
<div className="wizard-form">
|
||||
<APIForm onCreate={onCreate} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@/portainer/components/Button/CopyButton';
|
||||
import { Code } from '@/portainer/components/Code';
|
||||
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
|
||||
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
|
||||
|
||||
const deployments = [
|
||||
{
|
||||
id: 'linux',
|
||||
label: 'Linux',
|
||||
command: `-v "/var/run/docker.sock:/var/run/docker.sock"`,
|
||||
},
|
||||
{
|
||||
id: 'win',
|
||||
label: 'Windows',
|
||||
command: '-v \\.\\pipe\\docker_engine:\\.\\pipe\\docker_engine',
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentScripts() {
|
||||
const [deployType, setDeployType] = useState(deployments[0].id);
|
||||
|
||||
const agentDetailsQuery = useAgentDetails();
|
||||
|
||||
if (!agentDetailsQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = deployments.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
children: <DeployCode code={c.command} />,
|
||||
}));
|
||||
|
||||
return (
|
||||
<NavTabs
|
||||
options={options}
|
||||
onSelect={(id: string) => setDeployType(id)}
|
||||
selectedId={deployType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeployCodeProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
function DeployCode({ code }: DeployCodeProps) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted small">
|
||||
When using the socket, ensure that you have started the Portainer
|
||||
container with the following Docker flag:
|
||||
</span>
|
||||
|
||||
<Code>{code}</Code>
|
||||
<CopyButton copyText={code} className="my-6">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { FileUploadField } from '@/portainer/components/form-components/FileUpload';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import {
|
||||
file,
|
||||
withFileSize,
|
||||
withFileType,
|
||||
} from '@/portainer/helpers/yup-file-validation';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function TLSFieldset() {
|
||||
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="TLS"
|
||||
checked={values.tls}
|
||||
onChange={(checked) => setFieldValue('tls', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{values.tls && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Skip Certification Verification"
|
||||
checked={!!values.skipVerify}
|
||||
onChange={(checked) => setFieldValue('skipVerify', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!values.skipVerify && (
|
||||
<>
|
||||
<FormControl
|
||||
label="TLS CA certificate"
|
||||
inputId="ca-cert-field"
|
||||
errors={errors.caCertFile}
|
||||
>
|
||||
<FileUploadField
|
||||
inputId="ca-cert-field"
|
||||
onChange={(file) => setFieldValue('caCertFile', file)}
|
||||
value={values.caCertFile}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
label="TLS certificate"
|
||||
inputId="cert-field"
|
||||
errors={errors.certFile}
|
||||
>
|
||||
<FileUploadField
|
||||
inputId="cert-field"
|
||||
onChange={(file) => setFieldValue('certFile', file)}
|
||||
value={values.certFile}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
label="TLS key"
|
||||
inputId="tls-key-field"
|
||||
errors={errors.keyFile}
|
||||
>
|
||||
<FileUploadField
|
||||
inputId="tls-key-field"
|
||||
onChange={(file) => setFieldValue('keyFile', file)}
|
||||
value={values.keyFile}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5_242_880; // 5MB
|
||||
const ALLOWED_FILE_TYPES = [
|
||||
'application/x-x509-ca-cert',
|
||||
'application/x-pem-file',
|
||||
];
|
||||
|
||||
function certValidation() {
|
||||
return withFileType(
|
||||
withFileSize(file(), MAX_FILE_SIZE),
|
||||
ALLOWED_FILE_TYPES
|
||||
).when(['tls', 'skipVerify'], {
|
||||
is: (tls: boolean, skipVerify: boolean) => tls && !skipVerify,
|
||||
then: (schema) => schema.required('File is required'),
|
||||
});
|
||||
}
|
||||
|
||||
export function validation() {
|
||||
return {
|
||||
caCertFile: certValidation(),
|
||||
certFile: certValidation(),
|
||||
keyFile: certValidation(),
|
||||
};
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { APITab } from './APITab';
|
|
@ -0,0 +1,12 @@
|
|||
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
url: string;
|
||||
tls: boolean;
|
||||
skipVerify?: boolean;
|
||||
caCertFile?: File;
|
||||
certFile?: File;
|
||||
keyFile?: File;
|
||||
meta: EnvironmentMetadata;
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
import { AgentForm } from '../../shared/AgentForm/AgentForm';
|
||||
|
||||
import { DeploymentScripts } from './DeploymentScripts';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function AgentTab({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DeploymentScripts />
|
||||
|
||||
<div className="wizard-form">
|
||||
<AgentForm onCreate={onCreate} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@/portainer/components/Button/CopyButton';
|
||||
import { Code } from '@/portainer/components/Code';
|
||||
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
|
||||
import { getAgentShortVersion } from '@/portainer/views/endpoints/helpers';
|
||||
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
|
||||
|
||||
const deployments = [
|
||||
{
|
||||
id: 'linux',
|
||||
label: 'Linux',
|
||||
command: linuxCommand,
|
||||
},
|
||||
{
|
||||
id: 'win',
|
||||
label: 'Windows',
|
||||
command: winCommand,
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentScripts() {
|
||||
const [deployType, setDeployType] = useState(deployments[0].id);
|
||||
|
||||
const agentDetailsQuery = useAgentDetails();
|
||||
|
||||
if (!agentDetailsQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { agentVersion } = agentDetailsQuery;
|
||||
|
||||
const options = deployments.map((c) => {
|
||||
const code = c.command(agentVersion);
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
children: <DeployCode code={code} />,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<NavTabs
|
||||
options={options}
|
||||
onSelect={(id: string) => setDeployType(id)}
|
||||
selectedId={deployType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeployCodeProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
function DeployCode({ code }: DeployCodeProps) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted small">
|
||||
CLI script for installing agent on your environment with Docker Swarm:
|
||||
</span>
|
||||
<Code>{code}</Code>
|
||||
<CopyButton copyText={code}>Copy command</CopyButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function linuxCommand(agentVersion: string) {
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
|
||||
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent`;
|
||||
}
|
||||
|
||||
function winCommand(agentVersion: string) {
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
|
||||
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack-windows.yml -o agent-stack-windows.yml && docker stack deploy --compose-file=agent-stack-windows.yml portainer-agent `;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { AgentTab } from './AgentTab';
|
|
@ -0,0 +1,112 @@
|
|||
import { Field, Form, Formik, useFormikContext } from 'formik';
|
||||
import { useReducer } from 'react';
|
||||
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { useCreateLocalDockerEnvironmentMutation } from '@/portainer/environments/queries/useCreateEnvironmentMutation';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
import { NameField } from '../../shared/NameField';
|
||||
import { MetadataFieldset } from '../../shared/MetadataFieldset';
|
||||
|
||||
import { validation } from './SocketForm.validation';
|
||||
import { FormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function SocketForm({ onCreate }: Props) {
|
||||
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
socketPath: '',
|
||||
overridePath: false,
|
||||
meta: { groupId: 1, tagIds: [] },
|
||||
};
|
||||
|
||||
const mutation = useCreateLocalDockerEnvironmentMutation();
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
key={formKey}
|
||||
>
|
||||
{({ isValid, dirty }) => (
|
||||
<Form>
|
||||
<NameField />
|
||||
|
||||
<OverrideSocketFieldset />
|
||||
|
||||
<MetadataFieldset />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="wizard-connect-button"
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={mutation.isLoading}
|
||||
disabled={!dirty || !isValid}
|
||||
>
|
||||
<i className="fa fa-plug" aria-hidden="true" /> Connect
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
mutation.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
socketPath: values.overridePath ? values.socketPath : '',
|
||||
},
|
||||
{
|
||||
onSuccess(environment) {
|
||||
notifySuccess('Environment created', environment.Name);
|
||||
clearForm();
|
||||
onCreate(environment);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function OverrideSocketFieldset() {
|
||||
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={values.overridePath}
|
||||
onChange={(checked) => setFieldValue('overridePath', checked)}
|
||||
label="Override default socket path"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{values.overridePath && (
|
||||
<FormControl
|
||||
label="Socket Path"
|
||||
tooltip="Path to the Docker socket. Remember to bind-mount the socket, see the important notice above for more information."
|
||||
errors={errors.socketPath}
|
||||
>
|
||||
<Field
|
||||
name="socketPath"
|
||||
as={Input}
|
||||
placeholder="e.g. /var/run/docker.sock (on Linux) or //./pipe/docker_engine (on Windows)"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { boolean, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
|
||||
import { nameValidation } from '../../shared/NameField';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function validation(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
name: nameValidation(),
|
||||
meta: metadataValidation(),
|
||||
overridePath: boolean().default(false),
|
||||
socketPath: string()
|
||||
.default('')
|
||||
.when('overridePath', (overridePath, schema) =>
|
||||
overridePath
|
||||
? schema.required(
|
||||
'Socket Path is required when override path is enabled'
|
||||
)
|
||||
: schema
|
||||
),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
import { DeploymentScripts } from '../APITab/DeploymentScripts';
|
||||
|
||||
import { SocketForm } from './SocketForm';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
export function SocketTab({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DeploymentScripts />
|
||||
|
||||
<div className="wizard-form">
|
||||
<SocketForm onCreate={onCreate} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { SocketTab } from './SocketTab';
|
|
@ -0,0 +1,8 @@
|
|||
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
socketPath: string;
|
||||
overridePath: boolean;
|
||||
meta: EnvironmentMetadata;
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
|
||||
import { AgentTab } from './AgentTab';
|
||||
import { APITab } from './APITab';
|
||||
import { SocketTab } from './SocketTab';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
|
||||
}
|
||||
|
||||
const options = [
|
||||
buildOption('Agent', 'fa fa-bolt', 'Agent', '', 'agent'),
|
||||
buildOption('API', 'fa fa-cloud', 'API', '', 'api'),
|
||||
buildOption('Socket', 'fab fa-docker', 'Socket', '', 'socket'),
|
||||
];
|
||||
|
||||
export function WizardDocker({ onCreate }: Props) {
|
||||
const [creationType, setCreationType] = useState(options[0].value);
|
||||
|
||||
const form = getForm(creationType);
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<BoxSelector
|
||||
onChange={(v) => setCreationType(v)}
|
||||
options={options}
|
||||
value={creationType}
|
||||
radioName="creation-type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form}
|
||||
</div>
|
||||
);
|
||||
|
||||
function getForm(creationType: 'agent' | 'api' | 'socket') {
|
||||
switch (creationType) {
|
||||
case 'agent':
|
||||
return (
|
||||
<AgentTab
|
||||
onCreate={(environment) => onCreate(environment, 'dockerAgent')}
|
||||
/>
|
||||
);
|
||||
case 'api':
|
||||
return (
|
||||
<APITab
|
||||
onCreate={(environment) => onCreate(environment, 'dockerApi')}
|
||||
/>
|
||||
);
|
||||
case 'socket':
|
||||
return (
|
||||
<SocketTab
|
||||
onCreate={(environment) => onCreate(environment, 'localEndpoint')}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { WizardDocker } from './WizardDocker';
|
|
@ -0,0 +1,41 @@
|
|||
.wizard-list-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
grid-template-areas:
|
||||
'image title'
|
||||
'image subtitle'
|
||||
'image type';
|
||||
border: 1px solid rgb(221, 221, 221);
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 3px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 20%);
|
||||
}
|
||||
|
||||
.wizard-list-image {
|
||||
grid-area: image;
|
||||
font-size: 35px;
|
||||
color: #337ab7;
|
||||
}
|
||||
|
||||
.wizard-list-title {
|
||||
grid-column: title;
|
||||
padding: 0px 5px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wizard-list-subtitle {
|
||||
grid-column: subtitle;
|
||||
padding: 0px 5px;
|
||||
font-size: 10px;
|
||||
color: rgb(129, 129, 129);
|
||||
}
|
||||
|
||||
.wizard-list-type {
|
||||
grid-column: type;
|
||||
padding: 0px 5px;
|
||||
font-size: 10px;
|
||||
color: rgb(129, 129, 129);
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||
import {
|
||||
environmentTypeIcon,
|
||||
endpointTypeName,
|
||||
stripProtocol,
|
||||
} from '@/portainer/filters/filters';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { EdgeIndicator } from '@/portainer/home/EnvironmentList/EnvironmentItem';
|
||||
import {
|
||||
isEdgeEnvironment,
|
||||
isUnassociatedEdgeEnvironment,
|
||||
} from '@/portainer/environments/utils';
|
||||
import {
|
||||
ENVIRONMENTS_POLLING_INTERVAL,
|
||||
useEnvironmentList,
|
||||
} from '@/portainer/environments/queries/useEnvironmentList';
|
||||
|
||||
import styles from './WizardEndpointsList.module.css';
|
||||
|
||||
interface Props {
|
||||
environmentIds: EnvironmentId[];
|
||||
}
|
||||
|
||||
export function WizardEndpointsList({ environmentIds }: Props) {
|
||||
const { environments } = useEnvironmentList(
|
||||
{ endpointIds: environmentIds },
|
||||
(environments) => {
|
||||
if (!environments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!environments.value.some(isUnassociatedEdgeEnvironment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ENVIRONMENTS_POLLING_INTERVAL;
|
||||
},
|
||||
0,
|
||||
environmentIds.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetTitle icon="fa-plug" title="Connected Environments" />
|
||||
<WidgetBody>
|
||||
{environments.map((environment) => (
|
||||
<div className={styles.wizardListWrapper} key={environment.Id}>
|
||||
<div className={styles.wizardListImage}>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'space-right',
|
||||
environmentTypeIcon(environment.Type)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.wizardListTitle}>{environment.Name}</div>
|
||||
<div className={styles.wizardListSubtitle}>
|
||||
URL: {stripProtocol(environment.URL)}
|
||||
</div>
|
||||
<div className={styles.wizardListType}>
|
||||
Type: {endpointTypeName(environment.Type)}
|
||||
</div>
|
||||
{isEdgeEnvironment(environment.Type) && (
|
||||
<div className={styles.wizardListEdgeStatus}>
|
||||
<EdgeIndicator
|
||||
edgeId={environment.EdgeID}
|
||||
checkInInterval={environment.EdgeCheckinInterval}
|
||||
queryDate={environment.QueryDate}
|
||||
lastCheckInDate={environment.LastCheckInDate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { WizardEndpointsList } from './WizardEndpointsList';
|
|
@ -0,0 +1,22 @@
|
|||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
import { AgentForm } from '../shared/AgentForm/AgentForm';
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
|
||||
import { DeploymentScripts } from './DeploymentScripts';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
|
||||
}
|
||||
|
||||
export function AgentPanel({ onCreate }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DeploymentScripts />
|
||||
|
||||
<AgentForm
|
||||
onCreate={(environment) => onCreate(environment, 'kubernetesAgent')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@/portainer/components/Button/CopyButton';
|
||||
import { Code } from '@/portainer/components/Code';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
|
||||
import { getAgentShortVersion } from '@/portainer/views/endpoints/helpers';
|
||||
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
|
||||
|
||||
const deployments = [
|
||||
{
|
||||
id: 'k8sLoadBalancer',
|
||||
label: 'Kubernetes via load balancer',
|
||||
command: kubeLoadBalancerCommand,
|
||||
showAgentSecretMessage: true,
|
||||
},
|
||||
{
|
||||
id: 'k8sNodePort',
|
||||
label: 'Kubernetes via node port',
|
||||
command: kubeNodePortCommand,
|
||||
showAgentSecretMessage: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function DeploymentScripts() {
|
||||
const [deployType, setDeployType] = useState(deployments[0].id);
|
||||
|
||||
const agentDetailsQuery = useAgentDetails();
|
||||
|
||||
if (!agentDetailsQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { agentVersion, agentSecret } = agentDetailsQuery;
|
||||
|
||||
const options = deployments.map((c) => {
|
||||
const code = c.command(agentVersion);
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
children: (
|
||||
<DeployCode
|
||||
agentSecret={agentSecret}
|
||||
showAgentSecretMessage={c.showAgentSecretMessage}
|
||||
code={code}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>Information</FormSectionTitle>
|
||||
|
||||
<div className="form-group">
|
||||
<span className="col-sm-12 text-muted small">
|
||||
Ensure that you have deployed the Portainer agent in your cluster
|
||||
first. Refer to the platform related command below to deploy it.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NavTabs
|
||||
options={options}
|
||||
onSelect={(id: string) => setDeployType(id)}
|
||||
selectedId={deployType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function kubeNodePortCommand(agentVersion: string) {
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
|
||||
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/portainer-agent-k8s-nodeport.yaml -o portainer-agent-k8s.yaml; kubectl apply -f portainer-agent-k8s.yaml`;
|
||||
}
|
||||
|
||||
function kubeLoadBalancerCommand(agentVersion: string) {
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
|
||||
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/portainer-agent-k8s-lb.yaml -o portainer-agent-k8s.yaml; kubectl apply -f portainer-agent-k8s.yaml`;
|
||||
}
|
||||
|
||||
interface LoadBalancerProps {
|
||||
agentSecret?: string;
|
||||
showAgentSecretMessage?: boolean;
|
||||
code: string;
|
||||
}
|
||||
|
||||
function DeployCode({
|
||||
agentSecret,
|
||||
showAgentSecretMessage,
|
||||
code,
|
||||
}: LoadBalancerProps) {
|
||||
return (
|
||||
<>
|
||||
{showAgentSecretMessage && agentSecret && (
|
||||
<p className="text-muted small my-6">
|
||||
<i
|
||||
className="fa fa-info-circle blue-icon space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Note that the environment variable AGENT_SECRET will need to be set to
|
||||
<code>{agentSecret}</code>. Please update the manifest that will be
|
||||
downloaded from the following script.
|
||||
</p>
|
||||
)}
|
||||
<Code>{code}</Code>
|
||||
<CopyButton copyText={code} className="my-6">
|
||||
Copy command
|
||||
</CopyButton>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { BoxSelector } from '@/portainer/components/BoxSelector';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentCreationTypes,
|
||||
} from '@/portainer/environments/types';
|
||||
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
||||
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
|
||||
import { AgentPanel } from './AgentPanel';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
|
||||
}
|
||||
|
||||
const options: BoxSelectorOption<EnvironmentCreationTypes.AgentEnvironment>[] =
|
||||
[
|
||||
{
|
||||
id: 'agent_endpoint',
|
||||
icon: 'fa fa-bolt',
|
||||
label: 'Agent',
|
||||
value: EnvironmentCreationTypes.AgentEnvironment,
|
||||
description: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function WizardKubernetes({ onCreate }: Props) {
|
||||
const [creationType, setCreationType] = useState(options[0].value);
|
||||
|
||||
const Component = getPanel(creationType);
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<BoxSelector
|
||||
onChange={(v) => setCreationType(v)}
|
||||
options={options}
|
||||
value={creationType}
|
||||
radioName="creation-type"
|
||||
/>
|
||||
|
||||
<Component onCreate={onCreate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPanel(type: typeof options[number]['value']) {
|
||||
switch (type) {
|
||||
case EnvironmentCreationTypes.AgentEnvironment:
|
||||
return AgentPanel;
|
||||
default:
|
||||
throw new Error('Creation type not supported');
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { WizardKubernetes } from './WizardKubernetes';
|
|
@ -0,0 +1 @@
|
|||
export { EnvironmentCreationView } from './EnvironmentsCreationView';
|
|
@ -0,0 +1,75 @@
|
|||
import { Form, Formik } from 'formik';
|
||||
import { useReducer } from 'react';
|
||||
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { useCreateAgentEnvironmentMutation } from '@/portainer/environments/queries/useCreateEnvironmentMutation';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
import { CreateAgentEnvironmentValues } from '@/portainer/environments/environment.service/create';
|
||||
|
||||
import { NameField } from '../NameField';
|
||||
import { MetadataFieldset } from '../MetadataFieldset';
|
||||
|
||||
import { EnvironmentUrlField } from './EnvironmentUrlField';
|
||||
import { validation } from './AgentForm.validation';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment): void;
|
||||
}
|
||||
|
||||
const initialValues: CreateAgentEnvironmentValues = {
|
||||
environmentUrl: '',
|
||||
name: '',
|
||||
meta: {
|
||||
groupId: 1,
|
||||
tagIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
export function AgentForm({ onCreate }: Props) {
|
||||
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
|
||||
|
||||
const mutation = useCreateAgentEnvironmentMutation();
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
key={formKey}
|
||||
>
|
||||
{({ isValid, dirty }) => (
|
||||
<Form>
|
||||
<NameField />
|
||||
<EnvironmentUrlField />
|
||||
|
||||
<MetadataFieldset />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="wizard-connect-button"
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={mutation.isLoading}
|
||||
disabled={!dirty || !isValid}
|
||||
>
|
||||
<i className="fa fa-plug" aria-hidden="true" /> Connect
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: CreateAgentEnvironmentValues) {
|
||||
mutation.mutate(values, {
|
||||
onSuccess(environment) {
|
||||
notifySuccess('Environment created', environment.Name);
|
||||
clearForm();
|
||||
onCreate(environment);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { CreateAgentEnvironmentValues } from '@/portainer/environments/environment.service/create';
|
||||
|
||||
import { metadataValidation } from '../MetadataFieldset/validation';
|
||||
import { nameValidation } from '../NameField';
|
||||
|
||||
export function validation(): SchemaOf<CreateAgentEnvironmentValues> {
|
||||
return object({
|
||||
name: nameValidation(),
|
||||
environmentUrl: string().required('This field is required.'),
|
||||
meta: metadataValidation(),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { Field, useField } from 'formik';
|
||||
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
|
||||
export function EnvironmentUrlField() {
|
||||
const [, meta] = useField('environmentUrl');
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
label="Environment URL"
|
||||
errors={meta.error}
|
||||
required
|
||||
inputId="environment-url-field"
|
||||
>
|
||||
<Field
|
||||
id="environment-url-field"
|
||||
name="environmentUrl"
|
||||
as={Input}
|
||||
placeholder="e.g. 10.0.0.10:9001 or tasks.portainer_agent:9001"
|
||||
data-cy="endpointCreate-endpointUrlAgentInput"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { AgentForm } from './AgentForm';
|
|
@ -0,0 +1,36 @@
|
|||
import { useField } from 'formik';
|
||||
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Select } from '@/portainer/components/form-components/Input';
|
||||
import { useGroups } from '@/portainer/environment-groups/queries';
|
||||
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||
|
||||
export function GroupField() {
|
||||
const [fieldProps, metaProps, helpers] =
|
||||
useField<EnvironmentGroupId>('meta.groupId');
|
||||
|
||||
const groupsQuery = useGroups();
|
||||
if (!groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = groupsQuery.data.map((group) => ({
|
||||
value: group.Id,
|
||||
label: group.Name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FormControl label="Group" errors={metaProps.error}>
|
||||
<Select
|
||||
name="meta.groupId"
|
||||
options={options}
|
||||
value={fieldProps.value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
function handleChange(value: string) {
|
||||
helpers.setValue(value ? parseInt(value, 10) : 1);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { useField } from 'formik';
|
||||
|
||||
import { TagSelector } from '@/react/components/TagSelector';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { FormSection } from '@/portainer/components/form-components/FormSection';
|
||||
|
||||
import { GroupField } from './GroupsField';
|
||||
|
||||
export function MetadataFieldset() {
|
||||
const [tagProps, , tagHelpers] = useField('meta.tagIds');
|
||||
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
return (
|
||||
<FormSection title="Metadata" isFoldable>
|
||||
<GroupField />
|
||||
|
||||
<TagSelector
|
||||
value={tagProps.value}
|
||||
allowCreate={isAdmin}
|
||||
onChange={(value) => tagHelpers.setValue(value)}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { MetadataFieldset } from './MetadataFieldset';
|
|
@ -0,0 +1,10 @@
|
|||
import { object, number, array, SchemaOf } from 'yup';
|
||||
|
||||
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
|
||||
|
||||
export function metadataValidation(): SchemaOf<EnvironmentMetadata> {
|
||||
return object({
|
||||
groupId: number(),
|
||||
tagIds: array().of(number()).default([]),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { Field, useField } from 'formik';
|
||||
import { string } from 'yup';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { getEndpoints } from '@/portainer/environments/environment.service';
|
||||
|
||||
interface Props {
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export function NameField({ readonly }: Props) {
|
||||
const [, meta] = useField('name');
|
||||
|
||||
const id = 'name-input';
|
||||
|
||||
return (
|
||||
<FormControl label="Name" required errors={meta.error} inputId={id}>
|
||||
<Field
|
||||
id={id}
|
||||
name="name"
|
||||
as={Input}
|
||||
data-cy="endpointCreate-nameInput"
|
||||
placeholder="e.g. docker-prod01 / kubernetes-cluster01"
|
||||
readOnly={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
async function isNameUnique(name?: string) {
|
||||
if (!name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getEndpoints(0, 1, { name });
|
||||
if (result.totalCount > 0) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
// if backend fails to respond, assume name is unique, name validation happens also in the backend
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const debouncedIsNameUnique = debounce(isNameUnique, 500);
|
||||
|
||||
export function nameValidation() {
|
||||
return string()
|
||||
.required('Name is required')
|
||||
.test(
|
||||
'unique-name',
|
||||
'Name should be unique',
|
||||
(name) => debouncedIsNameUnique(name) || false
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
export interface AnalyticsState {
|
||||
dockerAgent: number;
|
||||
dockerApi: number;
|
||||
kubernetesAgent: number;
|
||||
kubernetesEdgeAgent: number;
|
||||
kaasAgent: number;
|
||||
aciApi: number;
|
||||
localEndpoint: number;
|
||||
nomadEdgeAgent: number;
|
||||
}
|
||||
|
||||
export type AnalyticsStateKey = keyof AnalyticsState;
|
|
@ -0,0 +1,23 @@
|
|||
.wizard-button {
|
||||
display: block;
|
||||
border: 1px solid rgb(163, 163, 163);
|
||||
border-radius: 5px;
|
||||
width: 200px;
|
||||
height: 300px;
|
||||
float: left;
|
||||
margin-right: 30px;
|
||||
cursor: pointer;
|
||||
padding: 25px 20px;
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 60%);
|
||||
}
|
||||
|
||||
.wizard-button:hover {
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
|
||||
border: 1px solid #3ca4ff;
|
||||
color: #337ab7;
|
||||
}
|
||||
|
||||
a.link {
|
||||
color: var(--text-body-color);
|
||||
text-decoration: none;
|
||||
}
|
105
app/react/portainer/environments/wizard/HomeView/HomeView.tsx
Normal file
105
app/react/portainer/environments/wizard/HomeView/HomeView.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||
import { EnvironmentType } from '@/portainer/environments/types';
|
||||
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
|
||||
import { Link } from '@/portainer/components/Link';
|
||||
|
||||
import { Option } from '../components/Option';
|
||||
|
||||
import { useConnectLocalEnvironment } from './useFetchOrCreateLocalEnvironment';
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
export function HomeView() {
|
||||
const localEnvironmentAdded = useConnectLocalEnvironment();
|
||||
const { trackEvent } = useAnalytics();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Quick Setup"
|
||||
breadcrumbs={[{ label: 'Environment Wizard' }]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetTitle title="Environment Wizard" icon="fa-magic" />
|
||||
<WidgetBody>
|
||||
<div className="row">
|
||||
<div className="col-sm-12 form-section-title">
|
||||
Welcome to Portainer
|
||||
</div>
|
||||
<div className="text-muted small">
|
||||
{localEnvironmentAdded.status === 'success' && (
|
||||
<p>
|
||||
We have connected your local environment of
|
||||
{getTypeLabel(localEnvironmentAdded.type)} to Portainer.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{localEnvironmentAdded.status === 'error' && (
|
||||
<p>
|
||||
We could not connect your local environment to Portainer.
|
||||
<br />
|
||||
Please ensure your environment is correctly exposed. For
|
||||
help with installation visit
|
||||
<a href="https://documentation.portainer.io/quickstart/">
|
||||
https://documentation.portainer.io/quickstart
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p>
|
||||
Get started below with your local portainer or connect more
|
||||
container environments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{localEnvironmentAdded.status === 'success' && (
|
||||
<Link to="portainer.home" className={styles.link}>
|
||||
<Option
|
||||
icon={
|
||||
localEnvironmentAdded.type === EnvironmentType.Docker
|
||||
? 'fab fa-docker'
|
||||
: 'fas fa-dharmachakra'
|
||||
}
|
||||
title="Get Started"
|
||||
description="Proceed using the local environment which Portainer is running in"
|
||||
onClick={() => trackLocalEnvironmentAnalytics()}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="portainer.wizard.endpoints" className={styles.link}>
|
||||
<Option
|
||||
title="Add Environments"
|
||||
icon="fa fa-plug"
|
||||
description="Connect to other environments"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function trackLocalEnvironmentAnalytics() {
|
||||
trackEvent('endpoint-wizard-endpoint-select', {
|
||||
category: 'portainer',
|
||||
metadata: { environment: 'Get-started-local-environment' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type?: EnvironmentType) {
|
||||
switch (type) {
|
||||
case EnvironmentType.Docker:
|
||||
return 'Docker';
|
||||
case EnvironmentType.KubernetesLocal:
|
||||
return 'Kubernetes';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { HomeView } from './HomeView';
|
|
@ -0,0 +1,84 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
|
||||
import { Environment, EnvironmentType } from '@/portainer/environments/types';
|
||||
import {
|
||||
createLocalDockerEnvironment,
|
||||
createLocalKubernetesEnvironment,
|
||||
} from '@/portainer/environments/environment.service/create';
|
||||
|
||||
export function useConnectLocalEnvironment(): {
|
||||
status: 'error' | 'loading' | 'success';
|
||||
type?: EnvironmentType;
|
||||
} {
|
||||
const [localEnvironment, setLocalEnvironment] = useState<Environment>();
|
||||
|
||||
const { isLoading, environment } = useFetchLocalEnvironment();
|
||||
|
||||
const createLocalEnvironmentMutation = useMutation(createLocalEnvironment);
|
||||
|
||||
const { mutate } = createLocalEnvironmentMutation;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || localEnvironment) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (environment) {
|
||||
setLocalEnvironment(environment);
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(undefined, {
|
||||
onSuccess(environment) {
|
||||
setLocalEnvironment(environment);
|
||||
},
|
||||
});
|
||||
}, [environment, isLoading, localEnvironment, mutate]);
|
||||
|
||||
return {
|
||||
status: getStatus(isLoading, createLocalEnvironmentMutation.status),
|
||||
type: localEnvironment?.Type,
|
||||
};
|
||||
}
|
||||
|
||||
function getStatus(
|
||||
isLoading: boolean,
|
||||
mutationStatus: 'loading' | 'error' | 'success' | 'idle'
|
||||
): 'loading' | 'error' | 'success' {
|
||||
if (isLoading || mutationStatus === 'loading') {
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
if (mutationStatus === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}
|
||||
|
||||
async function createLocalEnvironment() {
|
||||
try {
|
||||
return await createLocalKubernetesEnvironment({ name: 'local' });
|
||||
} catch (err) {
|
||||
return await createLocalDockerEnvironment({ name: 'local' });
|
||||
}
|
||||
}
|
||||
|
||||
function useFetchLocalEnvironment() {
|
||||
const { environments, isLoading } = useEnvironmentList(
|
||||
{
|
||||
page: 0,
|
||||
pageLimit: 1,
|
||||
types: [EnvironmentType.Docker, EnvironmentType.KubernetesLocal],
|
||||
},
|
||||
false,
|
||||
Infinity
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
environment: environments.length > 0 ? environments[0] : undefined,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
.root {
|
||||
--selected-item-color: var(--blue-2);
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 300px;
|
||||
border: 1px solid rgb(163, 163, 163);
|
||||
border-radius: 5px;
|
||||
padding: 25px 20px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 60%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
|
||||
border: 1px solid var(--blue-2);
|
||||
color: #337ab7;
|
||||
}
|
||||
|
||||
.active:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: #337ab7;
|
||||
color: #fff;
|
||||
border: 1px solid var(--blue-2);
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.icon-component {
|
||||
font-size: 40px;
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import clsx from 'clsx';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import styles from './Option.module.css';
|
||||
|
||||
export interface SelectorItemType {
|
||||
icon: string | ComponentType<{ selected?: boolean; className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props extends SelectorItemType {
|
||||
active?: boolean;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export function Option({
|
||||
icon,
|
||||
active,
|
||||
description,
|
||||
title,
|
||||
onClick = () => {},
|
||||
}: Props) {
|
||||
const Icon = typeof icon !== 'string' ? icon : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx('border-0', styles.root, { [styles.active]: active })}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-center mt-2">
|
||||
{Icon ? (
|
||||
<Icon selected={active} className={styles.iconComponent} />
|
||||
) : (
|
||||
<i className={clsx(icon, 'block', styles.icon)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<h3>{title}</h3>
|
||||
<h5>{description}</h5>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { Option } from './Option';
|
3
app/react/portainer/environments/wizard/index.ts
Normal file
3
app/react/portainer/environments/wizard/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { HomeView } from './HomeView';
|
||||
export { EnvironmentTypeSelectView } from './EnvironmentTypeSelectView';
|
||||
export { EnvironmentCreationView } from './EnvironmentsCreationView';
|
Loading…
Add table
Add a link
Reference in a new issue