1
0
Fork 0
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:
Chaim Lev-Ari 2022-05-23 17:32:51 +03:00 committed by GitHub
parent 3aacaa7caf
commit 01dc9066b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 2994 additions and 1744 deletions

View file

@ -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,
});
}
}

View file

@ -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]);
}
}

View file

@ -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;

View file

@ -0,0 +1 @@
export { EnvironmentTypeSelectView } from './EndpointTypeView';

View file

@ -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;
}

View file

@ -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,
}));
}
}

View file

@ -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(),
});
}

View file

@ -0,0 +1 @@
export { WizardAzure } from './WizardAzure';

View file

@ -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,
};
}
}
}
}

View file

@ -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(),
});
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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(),
};
}

View file

@ -0,0 +1 @@
export { APITab } from './APITab';

View file

@ -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;
}

View file

@ -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>
</>
);
}

View file

@ -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 `;
}

View file

@ -0,0 +1 @@
export { AgentTab } from './AgentTab';

View file

@ -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>
)}
</>
);
}

View file

@ -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
),
});
}

View file

@ -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>
</>
);
}

View file

@ -0,0 +1 @@
export { SocketTab } from './SocketTab';

View file

@ -0,0 +1,8 @@
import { EnvironmentMetadata } from '@/portainer/environments/environment.service/create';
export interface FormValues {
name: string;
socketPath: string;
overridePath: boolean;
meta: EnvironmentMetadata;
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1 @@
export { WizardDocker } from './WizardDocker';

View file

@ -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);
}

View file

@ -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>
);
}

View file

@ -0,0 +1 @@
export { WizardEndpointsList } from './WizardEndpointsList';

View file

@ -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')}
/>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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');
}
}

View file

@ -0,0 +1 @@
export { WizardKubernetes } from './WizardKubernetes';

View file

@ -0,0 +1 @@
export { EnvironmentCreationView } from './EnvironmentsCreationView';

View file

@ -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);
},
});
}
}

View file

@ -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(),
});
}

View file

@ -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>
);
}

View file

@ -0,0 +1 @@
export { AgentForm } from './AgentForm';

View file

@ -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);
}
}

View file

@ -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>
);
}

View file

@ -0,0 +1 @@
export { MetadataFieldset } from './MetadataFieldset';

View file

@ -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([]),
});
}

View file

@ -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
);
}

View file

@ -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;

View file

@ -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;
}

View 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 '';
}
}

View file

@ -0,0 +1 @@
export { HomeView } from './HomeView';

View file

@ -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,
};
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -0,0 +1 @@
export { Option } from './Option';

View file

@ -0,0 +1,3 @@
export { HomeView } from './HomeView';
export { EnvironmentTypeSelectView } from './EnvironmentTypeSelectView';
export { EnvironmentCreationView } from './EnvironmentsCreationView';