mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(azure): migrate module to react [EE-2782] (#6689)
* refactor(azure): migrate module to react [EE-2782] * fix(azure): remove optional chain * feat(azure): apply new icons in dashboard * feat(azure): apply new icons in dashboard * feat(ui): allow single string for breadcrumbs * refactor(azure/containers): use Table.content * feat(azure/containers): implement new ui [EE-3538] * fix(azure/containers): use correct icon * chore(tests): mock svg as component * fix(azure): fix tests Co-authored-by: matias.spinarolli <matias.spinarolli@portainer.io>
This commit is contained in:
parent
b059641c80
commit
82b848af0c
97 changed files with 1723 additions and 1430 deletions
|
@ -0,0 +1,43 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
jest.mock('@uirouter/react', () => ({
|
||||
...jest.requireActual('@uirouter/react'),
|
||||
useCurrentStateAndParams: jest.fn(() => ({
|
||||
params: { endpointId: 5 },
|
||||
})),
|
||||
}));
|
||||
|
||||
test('submit button should be disabled when name or image is missing', async () => {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
|
||||
const { findByText, getByText, getByLabelText } = renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<CreateContainerInstanceForm />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
|
||||
|
||||
const button = getByText(/Deploy the container/);
|
||||
expect(button).toBeVisible();
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const nameInput = getByLabelText(/name/i);
|
||||
userEvent.type(nameInput, 'name');
|
||||
|
||||
const imageInput = getByLabelText(/image/i);
|
||||
userEvent.type(imageInput, 'image');
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
|
||||
|
||||
expect(nameInput).toHaveValue('name');
|
||||
userEvent.clear(nameInput);
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
|
||||
});
|
|
@ -0,0 +1,217 @@
|
|||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { ContainerInstanceFormValues } from '@/react/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
|
||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||
import { PortsMappingField } from './PortsMappingField';
|
||||
import { useFormState, useLoadFormState } from './useLoadFormState';
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
import { useCreateInstanceMutation } from './useCreateInstanceMutation';
|
||||
|
||||
export function CreateContainerInstanceForm() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const { providers, subscriptions, resourceGroups, isLoading } =
|
||||
useLoadFormState(environmentId);
|
||||
|
||||
const { initialValues, subscriptionOptions } = useFormState(
|
||||
subscriptions,
|
||||
resourceGroups,
|
||||
providers
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync } = useCreateInstanceMutation(
|
||||
resourceGroups,
|
||||
environmentId
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik<ContainerInstanceFormValues>
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(isAdmin)}
|
||||
onSubmit={onSubmit}
|
||||
validateOnMount
|
||||
validateOnChange
|
||||
enableReinitialize
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
|
||||
<FormSectionTitle>Azure settings</FormSectionTitle>
|
||||
<FormControl
|
||||
label="Subscription"
|
||||
inputId="subscription-input"
|
||||
errors={errors.subscription}
|
||||
>
|
||||
<Field
|
||||
name="subscription"
|
||||
as={Select}
|
||||
id="subscription-input"
|
||||
options={subscriptionOptions}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Resource group"
|
||||
inputId="resourceGroup-input"
|
||||
errors={errors.resourceGroup}
|
||||
>
|
||||
<Field
|
||||
name="resourceGroup"
|
||||
as={Select}
|
||||
id="resourceGroup-input"
|
||||
options={getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Location"
|
||||
inputId="location-input"
|
||||
errors={errors.location}
|
||||
>
|
||||
<Field
|
||||
name="location"
|
||||
as={Select}
|
||||
id="location-input"
|
||||
options={getSubscriptionLocations(values.subscription, providers)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSectionTitle>Container configuration</FormSectionTitle>
|
||||
|
||||
<FormControl label="Name" inputId="name-input" errors={errors.name}>
|
||||
<Field
|
||||
name="name"
|
||||
as={Input}
|
||||
id="name-input"
|
||||
placeholder="e.g. myContainer"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Image"
|
||||
inputId="image-input"
|
||||
errors={errors.image}
|
||||
>
|
||||
<Field
|
||||
name="image"
|
||||
as={Input}
|
||||
id="image-input"
|
||||
placeholder="e.g. nginx:alpine"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="OS" inputId="os-input" errors={errors.os}>
|
||||
<Field
|
||||
name="os"
|
||||
as={Select}
|
||||
id="os-input"
|
||||
options={[
|
||||
{ label: 'Linux', value: 'Linux' },
|
||||
{ label: 'Windows', value: 'Windows' },
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(value) => setFieldValue('ports', value)}
|
||||
errors={errors.ports}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 small text-muted">
|
||||
This will automatically deploy a container with a public IP
|
||||
address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormSectionTitle>Container Resources</FormSectionTitle>
|
||||
|
||||
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
|
||||
<Field
|
||||
name="cpu"
|
||||
as={Input}
|
||||
id="cpu-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Memory"
|
||||
inputId="cpu-input"
|
||||
errors={errors.memory}
|
||||
>
|
||||
<Field
|
||||
name="memory"
|
||||
as={Input}
|
||||
id="memory-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid}
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Deployment in progress..."
|
||||
>
|
||||
<i className="fa fa-plus space-right" aria-hidden="true" />
|
||||
Deploy the container
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function onSubmit(values: ContainerInstanceFormValues) {
|
||||
try {
|
||||
await mutateAsync(values);
|
||||
notifications.success('Container successfully created', values.name);
|
||||
router.stateService.go('azure.containerinstances');
|
||||
} catch (e) {
|
||||
notifications.error('Failure', e as Error, 'Unable to create container');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { object, string, number, boolean } from 'yup';
|
||||
|
||||
import { validationSchema as accessControlSchema } from '@/portainer/access-control/AccessControlForm/AccessControlForm.validation';
|
||||
|
||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
return object().shape({
|
||||
name: string().required('Name is required.'),
|
||||
image: string().required('Image is required.'),
|
||||
subscription: string().required('Subscription is required.'),
|
||||
resourceGroup: string().required('Resource group is required.'),
|
||||
location: string().required('Location is required.'),
|
||||
os: string().oneOf(['Linux', 'Windows']),
|
||||
cpu: number().positive(),
|
||||
memory: number().positive(),
|
||||
allocatePublicIP: boolean(),
|
||||
ports: portsSchema(),
|
||||
accessControl: accessControlSchema(isAdmin),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
export function CreateView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create container instance"
|
||||
breadcrumbs={[
|
||||
{ link: 'azure.containerinstances', label: 'Container instances' },
|
||||
{ label: 'Add container' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<CreateContainerInstanceForm />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item .inputs {
|
||||
}
|
||||
|
||||
.item .errors {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { ItemProps } from '@@/form-components/InputList/InputList';
|
||||
|
||||
import styles from './PortsMappingField.module.css';
|
||||
|
||||
type Protocol = 'TCP' | 'UDP';
|
||||
|
||||
export interface PortMapping {
|
||||
host?: number;
|
||||
container?: number;
|
||||
protocol: Protocol;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: PortMapping[];
|
||||
onChange?(value: PortMapping[]): void;
|
||||
errors?: FormikErrors<PortMapping>[] | string | string[];
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function PortsMappingField({
|
||||
value,
|
||||
onChange = () => {},
|
||||
errors,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<InputList<PortMapping>
|
||||
label="Port mapping"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
addLabel="map additional port"
|
||||
itemBuilder={() => ({
|
||||
host: 0,
|
||||
container: 0,
|
||||
protocol: 'TCP',
|
||||
})}
|
||||
item={Item}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
onChange,
|
||||
item,
|
||||
error,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: ItemProps<PortMapping>) {
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<div className="flex items-center gap-2">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>host</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
value={item.host}
|
||||
onChange={(e) =>
|
||||
handleChange('host', parseInt(e.target.value || '0', 10))
|
||||
}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
type="number"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<span className="mx-3">
|
||||
<i className="fa fa-long-arrow-alt-right" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>container</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
value={item.container}
|
||||
onChange={(e) =>
|
||||
handleChange('container', parseInt(e.target.value || '0', 10))
|
||||
}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
type="number"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonSelector<Protocol>
|
||||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
{!!error && (
|
||||
<div className={styles.errors}>
|
||||
<FormError>{Object.values(error)[0]}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(name: keyof PortMapping, value: string | number) {
|
||||
onChange({ ...item, [name]: value });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { array, object, string } from 'yup';
|
||||
|
||||
export function validationSchema() {
|
||||
return array(
|
||||
object().shape({
|
||||
host: string().required('host is required'),
|
||||
container: string().required('container is required'),
|
||||
protocol: string().oneOf(['TCP', 'UDP']),
|
||||
})
|
||||
).min(1, 'At least one port binding is required');
|
||||
}
|
1
app/react/azure/container-instances/CreateView/index.ts
Normal file
1
app/react/azure/container-instances/CreateView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { CreateView } from './CreateView';
|
|
@ -0,0 +1,62 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { createContainerGroup } from '@/react/azure/services/container-groups.service';
|
||||
import { queryKeys } from '@/react/azure/queries/query-keys';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import {
|
||||
ContainerGroup,
|
||||
ContainerInstanceFormValues,
|
||||
ResourceGroup,
|
||||
} from '@/react/azure/types';
|
||||
import { applyResourceControl } from '@/portainer/access-control/access-control.service';
|
||||
|
||||
import { getSubscriptionResourceGroups } from './utils';
|
||||
|
||||
export function useCreateInstanceMutation(
|
||||
resourceGroups: {
|
||||
[k: string]: ResourceGroup[];
|
||||
},
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ContainerGroup, unknown, ContainerInstanceFormValues>(
|
||||
(values) => {
|
||||
if (!values.subscription) {
|
||||
throw new PortainerError('subscription is required');
|
||||
}
|
||||
|
||||
const subscriptionResourceGroup = getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
);
|
||||
const resourceGroup = subscriptionResourceGroup.find(
|
||||
(r) => r.value === values.resourceGroup
|
||||
);
|
||||
if (!resourceGroup) {
|
||||
throw new PortainerError('resource group not found');
|
||||
}
|
||||
|
||||
return createContainerGroup(
|
||||
values,
|
||||
environmentId,
|
||||
values.subscription,
|
||||
resourceGroup.label
|
||||
);
|
||||
},
|
||||
{
|
||||
async onSuccess(containerGroup, values) {
|
||||
const resourceControl = containerGroup.Portainer?.ResourceControl;
|
||||
if (!resourceControl) {
|
||||
throw new PortainerError('resource control expected after creation');
|
||||
}
|
||||
|
||||
const accessControlData = values.accessControl;
|
||||
await applyResourceControl(accessControlData, resourceControl);
|
||||
return queryClient.invalidateQueries(
|
||||
queryKeys.subscriptions(environmentId)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import {
|
||||
ContainerInstanceFormValues,
|
||||
ProviderViewModel,
|
||||
ResourceGroup,
|
||||
Subscription,
|
||||
} from '@/react/azure/types';
|
||||
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
||||
import { useProvider } from '@/react/azure/queries/useProvider';
|
||||
import { useResourceGroups } from '@/react/azure/queries/useResourceGroups';
|
||||
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
|
||||
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
|
||||
export function useLoadFormState(environmentId: EnvironmentId) {
|
||||
const { data: subscriptions, isLoading: isLoadingSubscriptions } =
|
||||
useSubscriptions(environmentId);
|
||||
const { resourceGroups, isLoading: isLoadingResourceGroups } =
|
||||
useResourceGroups(environmentId, subscriptions);
|
||||
const { providers, isLoading: isLoadingProviders } = useProvider(
|
||||
environmentId,
|
||||
subscriptions
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isLoadingSubscriptions || isLoadingResourceGroups || isLoadingProviders;
|
||||
|
||||
return { isLoading, subscriptions, resourceGroups, providers };
|
||||
}
|
||||
|
||||
export function useFormState(
|
||||
subscriptions: Subscription[] = [],
|
||||
resourceGroups: Record<string, ResourceGroup[]> = {},
|
||||
providers: Record<string, ProviderViewModel> = {}
|
||||
) {
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const subscriptionOptions = subscriptions.map((s) => ({
|
||||
value: s.subscriptionId,
|
||||
label: s.displayName,
|
||||
}));
|
||||
|
||||
const initSubscriptionId = getFirstValue(subscriptionOptions);
|
||||
|
||||
const subscriptionResourceGroups = getSubscriptionResourceGroups(
|
||||
initSubscriptionId,
|
||||
resourceGroups
|
||||
);
|
||||
|
||||
const subscriptionLocations = getSubscriptionLocations(
|
||||
initSubscriptionId,
|
||||
providers
|
||||
);
|
||||
|
||||
const initialValues: ContainerInstanceFormValues = {
|
||||
name: '',
|
||||
location: getFirstValue(subscriptionLocations),
|
||||
subscription: initSubscriptionId,
|
||||
resourceGroup: getFirstValue(subscriptionResourceGroups),
|
||||
image: '',
|
||||
os: 'Linux',
|
||||
memory: 1,
|
||||
cpu: 1,
|
||||
ports: [{ container: 80, host: 80, protocol: 'TCP' }],
|
||||
allocatePublicIP: true,
|
||||
accessControl: parseAccessControlFormData(isAdmin),
|
||||
};
|
||||
|
||||
return {
|
||||
initialValues,
|
||||
subscriptionOptions,
|
||||
};
|
||||
|
||||
function getFirstValue<T>(arr: { value: T }[]) {
|
||||
if (arr.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return arr[0].value;
|
||||
}
|
||||
}
|
34
app/react/azure/container-instances/CreateView/utils.ts
Normal file
34
app/react/azure/container-instances/CreateView/utils.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { ProviderViewModel, ResourceGroup } from '@/react/azure/types';
|
||||
|
||||
export function getSubscriptionResourceGroups(
|
||||
subscriptionId?: string,
|
||||
resourceGroups?: Record<string, ResourceGroup[]>
|
||||
) {
|
||||
if (!subscriptionId || !resourceGroups || !resourceGroups[subscriptionId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resourceGroups[subscriptionId].map(({ name, id }) => ({
|
||||
value: id,
|
||||
label: name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSubscriptionLocations(
|
||||
subscriptionId?: string,
|
||||
containerInstanceProviders?: Record<string, ProviderViewModel | undefined>
|
||||
) {
|
||||
if (!subscriptionId || !containerInstanceProviders) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = containerInstanceProviders[subscriptionId];
|
||||
if (!provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return provider.locations.map((location) => ({
|
||||
value: location,
|
||||
label: location,
|
||||
}));
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue