mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(azure/aci): migrate create view to react [EE-2188] (#6371)
This commit is contained in:
parent
1bb02eea59
commit
6f6f78fbe5
53 changed files with 1476 additions and 571 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,219 @@
|
|||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Input, Select } from '@/portainer/components/form-components/Input';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { InputListError } from '@/portainer/components/form-components/InputList/InputList';
|
||||
import { AccessControlForm } from '@/portainer/components/accessControlForm';
|
||||
import { ContainerInstanceFormValues } from '@/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { isAdmin, useUser } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||
import { PortMapping, PortsMappingField } from './PortsMappingField';
|
||||
import { useLoadFormState } from './useLoadFormState';
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
import { useCreateInstance } from './useCreateInstanceMutation';
|
||||
|
||||
export function CreateContainerInstanceForm() {
|
||||
const {
|
||||
params: { endpointId: environmentId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
if (!environmentId) {
|
||||
throw new Error('endpointId url param is required');
|
||||
}
|
||||
|
||||
const { user } = useUser();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const { initialValues, isLoading, providers, subscriptions, resourceGroups } =
|
||||
useLoadFormState(environmentId, isUserAdmin);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync } = useCreateInstance(
|
||||
resourceGroups,
|
||||
environmentId,
|
||||
user?.Id
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik<ContainerInstanceFormValues>
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(isUserAdmin)}
|
||||
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={subscriptions}
|
||||
/>
|
||||
</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 as InputListError<PortMapping>[]}
|
||||
/>
|
||||
|
||||
<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/components/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,13 @@
|
|||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item .inputs {
|
||||
}
|
||||
|
||||
.item .errors {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import { ButtonSelector } from '@/portainer/components/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormError } from '@/portainer/components/form-components/FormError';
|
||||
import { InputGroup } from '@/portainer/components/form-components/InputGroup';
|
||||
import { InputList } from '@/portainer/components/form-components/InputList';
|
||||
import {
|
||||
InputListError,
|
||||
ItemProps,
|
||||
} from '@/portainer/components/form-components/InputList/InputList';
|
||||
|
||||
import styles from './PortsMappingField.module.css';
|
||||
|
||||
type Protocol = 'TCP' | 'UDP';
|
||||
|
||||
export interface PortMapping {
|
||||
host: string;
|
||||
container: string;
|
||||
protocol: Protocol;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: PortMapping[];
|
||||
onChange(value: PortMapping[]): void;
|
||||
errors?: InputListError<PortMapping>[] | string;
|
||||
}
|
||||
|
||||
export function PortsMappingField({ value, onChange, errors }: Props) {
|
||||
return (
|
||||
<>
|
||||
<InputList<PortMapping>
|
||||
label="Port mapping"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
addLabel="map additional port"
|
||||
itemBuilder={() => ({ host: '', container: '', protocol: 'TCP' })}
|
||||
item={Item}
|
||||
errors={errors}
|
||||
/>
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ onChange, item, error }: ItemProps<PortMapping>) {
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<div className={styles.inputs}>
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>host</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
value={item.host}
|
||||
onChange={(e) => handleChange('host', e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<span style={{ margin: '0 10px 0 10px' }}>
|
||||
<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', e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonSelector<Protocol>
|
||||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
{!!error && (
|
||||
<div className={styles.errors}>
|
||||
<FormError>{Object.values(error)[0]}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(name: string, value: string) {
|
||||
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');
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
|
@ -0,0 +1,61 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { createContainerGroup } from '@/azure/services/container-groups.service';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import {
|
||||
ContainerGroup,
|
||||
ContainerInstanceFormValues,
|
||||
ResourceGroup,
|
||||
} from '@/azure/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { applyResourceControl } from '@/portainer/resource-control/resource-control.service';
|
||||
|
||||
import { getSubscriptionResourceGroups } from './utils';
|
||||
|
||||
export function useCreateInstance(
|
||||
resourceGroups: {
|
||||
[k: string]: ResourceGroup[];
|
||||
},
|
||||
environmentId: EnvironmentId,
|
||||
userId?: UserId
|
||||
) {
|
||||
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) {
|
||||
if (!userId) {
|
||||
throw new Error('missing user id');
|
||||
}
|
||||
|
||||
const resourceControl = containerGroup.Portainer.ResourceControl;
|
||||
const accessControlData = values.accessControl;
|
||||
await applyResourceControl(userId, accessControlData, resourceControl);
|
||||
queryClient.invalidateQueries(['azure', 'container-instances']);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
import { useQueries, useQuery } from 'react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { Option } from '@/portainer/components/form-components/Input/Select';
|
||||
import { getResourceGroups } from '@/azure/services/resource-groups.service';
|
||||
import { getSubscriptions } from '@/azure/services/subscription.service';
|
||||
import { getContainerInstanceProvider } from '@/azure/services/provider.service';
|
||||
import { ContainerInstanceFormValues, Subscription } from '@/azure/types';
|
||||
import { parseFromResourceControl } from '@/portainer/components/accessControlForm/model';
|
||||
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
|
||||
export function useLoadFormState(
|
||||
environmentId: EnvironmentId,
|
||||
isUserAdmin: boolean
|
||||
) {
|
||||
const { subscriptions, isLoading: isLoadingSubscriptions } =
|
||||
useSubscriptions(environmentId);
|
||||
const { resourceGroups, isLoading: isLoadingResourceGroups } =
|
||||
useResourceGroups(environmentId, subscriptions);
|
||||
const { providers, isLoading: isLoadingProviders } = useProviders(
|
||||
environmentId,
|
||||
subscriptions
|
||||
);
|
||||
|
||||
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: parseFromResourceControl(isUserAdmin),
|
||||
};
|
||||
|
||||
return {
|
||||
isUserAdmin,
|
||||
initialValues,
|
||||
subscriptions: subscriptionOptions,
|
||||
resourceGroups,
|
||||
providers,
|
||||
isLoading:
|
||||
isLoadingProviders || isLoadingResourceGroups || isLoadingSubscriptions,
|
||||
};
|
||||
|
||||
function getFirstValue<T extends string | number>(arr: Option<T>[]) {
|
||||
if (arr.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return arr[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
function useSubscriptions(environmentId: EnvironmentId) {
|
||||
const { data, isError, error, isLoading } = useQuery(
|
||||
'azure.subscriptions',
|
||||
() => getSubscriptions(environmentId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
error as PortainerError,
|
||||
'Unable to retrieve Azure resources'
|
||||
);
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
return { subscriptions: data || [], isLoading };
|
||||
}
|
||||
|
||||
function useResourceGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[]
|
||||
) {
|
||||
const queries = useQueries(
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: ['azure.resourceGroups', subscription.subscriptionId],
|
||||
queryFn: () =>
|
||||
getResourceGroups(environmentId, subscription.subscriptionId),
|
||||
}))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const failedQuery = queries.find((q) => q.error);
|
||||
if (failedQuery) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
failedQuery.error as PortainerError,
|
||||
'Unable to retrieve Azure resources'
|
||||
);
|
||||
}
|
||||
}, [queries]);
|
||||
|
||||
return {
|
||||
resourceGroups: Object.fromEntries(
|
||||
queries.map((q, index) => [
|
||||
subscriptions[index].subscriptionId,
|
||||
q.data || [],
|
||||
])
|
||||
),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
};
|
||||
}
|
||||
|
||||
function useProviders(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[]
|
||||
) {
|
||||
const queries = useQueries(
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: [
|
||||
'azure.containerInstanceProvider',
|
||||
subscription.subscriptionId,
|
||||
],
|
||||
queryFn: () =>
|
||||
getContainerInstanceProvider(
|
||||
environmentId,
|
||||
subscription.subscriptionId
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const failedQuery = queries.find((q) => q.error);
|
||||
if (failedQuery) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
failedQuery.error as PortainerError,
|
||||
'Unable to retrieve Azure resources'
|
||||
);
|
||||
}
|
||||
}, [queries]);
|
||||
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
queries.map((q, index) => [subscriptions[index].subscriptionId, q.data])
|
||||
),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { ProviderViewModel } from '@/azure/models/provider';
|
||||
import { ResourceGroup } from '@/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,
|
||||
}));
|
||||
}
|
34
app/azure/ContainerInstances/CreateContainerInstanceView.tsx
Normal file
34
app/azure/ContainerInstances/CreateContainerInstanceView.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||
import { Widget, WidgetBody } from '@/portainer/components/widget';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
export function CreateContainerInstanceView() {
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CreateContainerInstanceViewAngular = r2a(
|
||||
CreateContainerInstanceView,
|
||||
[]
|
||||
);
|
11
app/azure/ContainerInstances/index.ts
Normal file
11
app/azure/ContainerInstances/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { CreateContainerInstanceViewAngular } from './CreateContainerInstanceView';
|
||||
|
||||
export const containerInstancesModule = angular
|
||||
.module('portainer.azure.containerInstances', [])
|
||||
|
||||
.component(
|
||||
'createContainerInstanceView',
|
||||
CreateContainerInstanceViewAngular
|
||||
).name;
|
|
@ -1,4 +1,8 @@
|
|||
angular.module('portainer.azure', ['portainer.app']).config([
|
||||
import angular from 'angular';
|
||||
|
||||
import { containerInstancesModule } from './ContainerInstances';
|
||||
|
||||
angular.module('portainer.azure', ['portainer.app', containerInstancesModule]).config([
|
||||
'$stateRegistryProvider',
|
||||
function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
@ -53,8 +57,7 @@ angular.module('portainer.azure', ['portainer.app']).config([
|
|||
url: '/new/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/containerinstances/create/createcontainerinstance.html',
|
||||
controller: 'AzureCreateContainerInstanceController',
|
||||
component: 'createContainerInstanceView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -48,48 +48,3 @@ export function ContainerGroupViewModel(data) {
|
|||
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateContainerGroupRequest(model) {
|
||||
this.location = model.Location;
|
||||
|
||||
var containerPorts = [];
|
||||
var addressPorts = [];
|
||||
for (var i = 0; i < model.Ports.length; i++) {
|
||||
var binding = model.Ports[i];
|
||||
if (!binding.container || !binding.host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
containerPorts.push({
|
||||
port: binding.container,
|
||||
});
|
||||
|
||||
addressPorts.push({
|
||||
port: binding.host,
|
||||
protocol: binding.protocol,
|
||||
});
|
||||
}
|
||||
|
||||
this.properties = {
|
||||
osType: model.OSType,
|
||||
containers: [
|
||||
{
|
||||
name: model.Name,
|
||||
properties: {
|
||||
image: model.Image,
|
||||
ports: containerPorts,
|
||||
resources: {
|
||||
requests: {
|
||||
cpu: model.CPU,
|
||||
memoryInGB: model.Memory,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
ipAddress: {
|
||||
type: model.AllocatePublicIP ? 'Public' : 'Private',
|
||||
ports: addressPorts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
export function ContainerInstanceProviderViewModel(data) {
|
||||
this.Id = data.id;
|
||||
this.Namespace = data.namespace;
|
||||
|
||||
var containerGroupType = _.find(data.resourceTypes, { resourceType: 'containerGroups' });
|
||||
this.Locations = containerGroupType.locations;
|
||||
}
|
21
app/azure/models/provider.ts
Normal file
21
app/azure/models/provider.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import { ProviderResponse } from '../types';
|
||||
|
||||
export interface ProviderViewModel {
|
||||
id: string;
|
||||
namespace: string;
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
export function parseViewModel({
|
||||
id,
|
||||
namespace,
|
||||
resourceTypes,
|
||||
}: ProviderResponse): ProviderViewModel {
|
||||
const containerGroupType = _.find(resourceTypes, {
|
||||
resourceType: 'containerGroups',
|
||||
});
|
||||
const { locations = [] } = containerGroupType || {};
|
||||
return { id, namespace, locations };
|
||||
}
|
|
@ -11,7 +11,6 @@ angular.module('portainer.azure').factory('Subscription', [
|
|||
'api-version': '2016-06-01',
|
||||
},
|
||||
{
|
||||
query: { method: 'GET' },
|
||||
get: { method: 'GET', params: { id: '@id' } },
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,72 +1,75 @@
|
|||
angular.module('portainer.azure').factory('AzureService', [
|
||||
'$q',
|
||||
'Azure',
|
||||
'SubscriptionService',
|
||||
'ResourceGroupService',
|
||||
'ContainerGroupService',
|
||||
'ProviderService',
|
||||
function AzureServiceFactory($q, Azure, SubscriptionService, ResourceGroupService, ContainerGroupService, ProviderService) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
import { ResourceGroupViewModel } from '../models/resource_group';
|
||||
import { SubscriptionViewModel } from '../models/subscription';
|
||||
import { getResourceGroups } from './resource-groups.service';
|
||||
import { getSubscriptions } from './subscription.service';
|
||||
|
||||
service.deleteContainerGroup = function (id) {
|
||||
return Azure.delete(id, '2018-04-01');
|
||||
};
|
||||
angular.module('portainer.azure').factory('AzureService', AzureService);
|
||||
|
||||
service.createContainerGroup = function (model, subscriptionId, resourceGroupName) {
|
||||
return ContainerGroupService.create(model, subscriptionId, resourceGroupName);
|
||||
};
|
||||
/* @ngInject */
|
||||
export function AzureService($q, Azure, $async, EndpointProvider, ContainerGroupService) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.subscriptions = function () {
|
||||
return SubscriptionService.subscriptions();
|
||||
};
|
||||
service.deleteContainerGroup = function (id) {
|
||||
return Azure.delete(id, '2018-04-01');
|
||||
};
|
||||
|
||||
service.containerInstanceProvider = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ProviderService.containerInstanceProvider);
|
||||
};
|
||||
service.subscriptions = async function subscriptions() {
|
||||
return $async(async () => {
|
||||
const environmentId = EndpointProvider.endpointID();
|
||||
const subscriptions = await getSubscriptions(environmentId);
|
||||
return subscriptions.map((s) => new SubscriptionViewModel(s));
|
||||
});
|
||||
};
|
||||
|
||||
service.resourceGroups = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ResourceGroupService.resourceGroups);
|
||||
};
|
||||
service.resourceGroups = function resourceGroups(subscriptions) {
|
||||
return $async(async () => {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, async (subscriptionId) => {
|
||||
const environmentId = EndpointProvider.endpointID();
|
||||
|
||||
service.containerGroups = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups);
|
||||
};
|
||||
|
||||
service.aggregate = function (resourcesBySubcription) {
|
||||
var aggregatedResources = [];
|
||||
Object.keys(resourcesBySubcription).forEach(function (key) {
|
||||
aggregatedResources = aggregatedResources.concat(resourcesBySubcription[key]);
|
||||
const resourceGroups = await getResourceGroups(environmentId, subscriptionId);
|
||||
return resourceGroups.map((r) => new ResourceGroupViewModel(r, subscriptionId));
|
||||
});
|
||||
return aggregatedResources;
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) {
|
||||
var deferred = $q.defer();
|
||||
service.containerGroups = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups);
|
||||
};
|
||||
|
||||
var resources = {};
|
||||
service.aggregate = function (resourcesBySubscription) {
|
||||
var aggregatedResources = [];
|
||||
Object.keys(resourcesBySubscription).forEach(function (key) {
|
||||
aggregatedResources = aggregatedResources.concat(resourcesBySubscription[key]);
|
||||
});
|
||||
return aggregatedResources;
|
||||
};
|
||||
|
||||
var resourceQueries = [];
|
||||
for (var i = 0; i < subscriptions.length; i++) {
|
||||
var subscription = subscriptions[i];
|
||||
resourceQueries.push(resourceQuery(subscription.Id));
|
||||
}
|
||||
function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
$q.all(resourceQueries)
|
||||
.then(function success(data) {
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var result = data[i];
|
||||
resources[subscriptions[i].Id] = result;
|
||||
}
|
||||
deferred.resolve(resources);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve resources', err: err });
|
||||
});
|
||||
var resources = {};
|
||||
|
||||
return deferred.promise;
|
||||
var resourceQueries = [];
|
||||
for (var i = 0; i < subscriptions.length; i++) {
|
||||
var subscription = subscriptions[i];
|
||||
resourceQueries.push(resourceQuery(subscription.Id));
|
||||
}
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
$q.all(resourceQueries)
|
||||
.then(function success(data) {
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var result = data[i];
|
||||
resources[subscriptions[i].Id] = result;
|
||||
}
|
||||
deferred.resolve(resources);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve resources', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
|
78
app/azure/services/container-groups.service.ts
Normal file
78
app/azure/services/container-groups.service.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { ContainerGroup, ContainerInstanceFormValues } from '../types';
|
||||
|
||||
export async function createContainerGroup(
|
||||
model: ContainerInstanceFormValues,
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string
|
||||
) {
|
||||
const payload = transformToPayload(model);
|
||||
try {
|
||||
const { data } = await axios.put<ContainerGroup>(
|
||||
buildUrl(environmentId, subscriptionId, resourceGroupName, model.name),
|
||||
payload,
|
||||
{ params: { 'api-version': '2018-04-01' } }
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
containerGroupName: string
|
||||
) {
|
||||
return `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ContainerInstance/containerGroups/${containerGroupName}`;
|
||||
}
|
||||
|
||||
function transformToPayload(model: ContainerInstanceFormValues) {
|
||||
const containerPorts = [];
|
||||
const addressPorts = [];
|
||||
|
||||
const ports = model.ports.filter((p) => p.container && p.host);
|
||||
|
||||
for (let i = 0; i < ports.length; i += 1) {
|
||||
const binding = ports[i];
|
||||
|
||||
containerPorts.push({
|
||||
port: binding.container,
|
||||
});
|
||||
|
||||
addressPorts.push({
|
||||
port: binding.host,
|
||||
protocol: binding.protocol,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
location: model.location,
|
||||
properties: {
|
||||
osType: model.os,
|
||||
containers: [
|
||||
{
|
||||
name: model.name,
|
||||
properties: {
|
||||
image: model.image,
|
||||
ports: containerPorts,
|
||||
resources: {
|
||||
requests: {
|
||||
cpu: model.cpu,
|
||||
memoryInGB: model.memory,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
ipAddress: {
|
||||
type: model.allocatePublicIP ? 'Public' : 'Private',
|
||||
ports: addressPorts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { ContainerGroupViewModel, CreateContainerGroupRequest } from '../models/container_group';
|
||||
import { ContainerGroupViewModel } from '../models/container_group';
|
||||
|
||||
angular.module('portainer.azure').factory('ContainerGroupService', [
|
||||
'$q',
|
||||
|
@ -30,18 +30,6 @@ angular.module('portainer.azure').factory('ContainerGroupService', [
|
|||
return new ContainerGroupViewModel(containerGroup);
|
||||
}
|
||||
|
||||
service.create = function (model, subscriptionId, resourceGroupName) {
|
||||
var payload = new CreateContainerGroupRequest(model);
|
||||
return ContainerGroup.create(
|
||||
{
|
||||
subscriptionId: subscriptionId,
|
||||
resourceGroupName: resourceGroupName,
|
||||
containerGroupName: model.Name,
|
||||
},
|
||||
payload
|
||||
).$promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
|
|
29
app/azure/services/provider.service.ts
Normal file
29
app/azure/services/provider.service.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
// import { ContainerInstanceProviderViewModel } from '../models/provider';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { parseViewModel } from '../models/provider';
|
||||
import { ProviderResponse } from '../types';
|
||||
|
||||
import { azureErrorParser } from './utils';
|
||||
|
||||
export async function getContainerInstanceProvider(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
try {
|
||||
const url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/providers/Microsoft.ContainerInstance`;
|
||||
const { data } = await axios.get<ProviderResponse>(url, {
|
||||
params: { 'api-version': '2018-02-01' },
|
||||
});
|
||||
|
||||
return parseViewModel(data);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(
|
||||
error as Error,
|
||||
'Unable to retrieve provider',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import { ContainerInstanceProviderViewModel } from '../models/provider';
|
||||
|
||||
angular.module('portainer.azure').factory('ProviderService', [
|
||||
'$q',
|
||||
'Provider',
|
||||
function ProviderServiceFactory($q, Provider) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.containerInstanceProvider = function (subscriptionId) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Provider.get({ subscriptionId: subscriptionId, providerNamespace: 'Microsoft.ContainerInstance' })
|
||||
.$promise.then(function success(data) {
|
||||
var provider = new ContainerInstanceProviderViewModel(data);
|
||||
deferred.resolve(provider);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve provider', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
42
app/azure/services/resource-groups.service.ts
Normal file
42
app/azure/services/resource-groups.service.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { ResourceGroup } from '../types';
|
||||
|
||||
import { azureErrorParser } from './utils';
|
||||
|
||||
export async function getResourceGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
try {
|
||||
const {
|
||||
data: { value },
|
||||
} = await axios.get<{ value: ResourceGroup[] }>(
|
||||
buildUrl(environmentId, subscriptionId),
|
||||
{ params: { 'api-version': '2018-02-01' } }
|
||||
);
|
||||
|
||||
return value;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
err as Error,
|
||||
'Unable to retrieve resource groups',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName?: string
|
||||
) {
|
||||
let url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourcegroups`;
|
||||
|
||||
if (resourceGroupName) {
|
||||
url += `/${resourceGroupName}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -7,23 +7,6 @@ angular.module('portainer.azure').factory('ResourceGroupService', [
|
|||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.resourceGroups = function (subscriptionId) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
ResourceGroup.query({ subscriptionId: subscriptionId })
|
||||
.$promise.then(function success(data) {
|
||||
var resourceGroups = data.value.map(function (item) {
|
||||
return new ResourceGroupViewModel(item, subscriptionId);
|
||||
});
|
||||
deferred.resolve(resourceGroups);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve resource groups', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.resourceGroup = resourceGroup;
|
||||
async function resourceGroup(subscriptionId, resourceGroupName) {
|
||||
const group = await ResourceGroup.get({ subscriptionId, resourceGroupName }).$promise;
|
||||
|
|
30
app/azure/services/subscription.service.ts
Normal file
30
app/azure/services/subscription.service.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { Subscription } from '../types';
|
||||
|
||||
import { azureErrorParser } from './utils';
|
||||
|
||||
export async function getSubscriptions(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data } = await axios.get<{ value: Subscription[] }>(
|
||||
buildUrl(environmentId)
|
||||
);
|
||||
return data.value;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve subscriptions',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, id?: string) {
|
||||
let url = `/endpoints/${environmentId}/azure/subscriptions?api-version=2016-06-01`;
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -4,32 +4,11 @@ angular.module('portainer.azure').factory('SubscriptionService', [
|
|||
'$q',
|
||||
'Subscription',
|
||||
function SubscriptionServiceFactory($q, Subscription) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
return { subscription };
|
||||
|
||||
service.subscriptions = function () {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Subscription.query({})
|
||||
.$promise.then(function success(data) {
|
||||
var subscriptions = data.value.map(function (item) {
|
||||
return new SubscriptionViewModel(item);
|
||||
});
|
||||
deferred.resolve(subscriptions);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve subscriptions', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.subscription = subscription;
|
||||
async function subscription(id) {
|
||||
const subscription = await Subscription.get({ id }).$promise;
|
||||
return new SubscriptionViewModel(subscription);
|
||||
}
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
|
|
12
app/azure/services/utils.ts
Normal file
12
app/azure/services/utils.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { AxiosError } from 'axios';
|
||||
|
||||
export function azureErrorParser(axiosError: AxiosError) {
|
||||
const message =
|
||||
(axiosError.response?.data?.error?.message as string) ||
|
||||
'Failed azure request';
|
||||
|
||||
return {
|
||||
error: new Error(message),
|
||||
details: message,
|
||||
};
|
||||
}
|
83
app/azure/types.ts
Normal file
83
app/azure/types.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
|
||||
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
|
||||
|
||||
type OS = 'Linux' | 'Windows';
|
||||
|
||||
export interface ContainerInstanceFormValues {
|
||||
name: string;
|
||||
location?: string;
|
||||
subscription?: string;
|
||||
resourceGroup?: string;
|
||||
image: string;
|
||||
os: OS;
|
||||
memory: number;
|
||||
cpu: number;
|
||||
ports: PortMapping[];
|
||||
allocatePublicIP: boolean;
|
||||
accessControl: AccessControlFormData;
|
||||
}
|
||||
|
||||
interface PortainerMetadata {
|
||||
ResourceControl: ResourceControlResponse;
|
||||
}
|
||||
|
||||
interface Container {
|
||||
name: string;
|
||||
properties: {
|
||||
environmentVariables: unknown[];
|
||||
image: string;
|
||||
ports: { port: number }[];
|
||||
resources: {
|
||||
cpu: number;
|
||||
memoryInGB: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ContainerGroupProperties {
|
||||
containers: Container[];
|
||||
instanceView: {
|
||||
events: unknown[];
|
||||
state: 'pending' | string;
|
||||
};
|
||||
ipAddress: {
|
||||
dnsNameLabelReusePolicy: string;
|
||||
ports: { port: number; protocol: 'TCP' | 'UDP' }[];
|
||||
type: 'Public' | 'Private';
|
||||
};
|
||||
osType: OS;
|
||||
}
|
||||
|
||||
export interface ContainerGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
type: string;
|
||||
properties: ContainerGroupProperties;
|
||||
Portainer: PortainerMetadata;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
subscriptionId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface ResourceGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
interface ResourceType {
|
||||
resourceType: 'containerGroups' | string;
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
export interface ProviderResponse {
|
||||
id: string;
|
||||
namespace: string;
|
||||
resourceTypes: ResourceType[];
|
||||
}
|
|
@ -1,122 +0,0 @@
|
|||
import { ContainerGroupDefaultModel } from '../../../models/container_group';
|
||||
|
||||
angular.module('portainer.azure').controller('AzureCreateContainerInstanceController', [
|
||||
'$q',
|
||||
'$scope',
|
||||
'$state',
|
||||
'AzureService',
|
||||
'Notifications',
|
||||
'Authentication',
|
||||
'ResourceControlService',
|
||||
'FormValidator',
|
||||
function ($q, $scope, $state, AzureService, Notifications, Authentication, ResourceControlService, FormValidator) {
|
||||
var allResourceGroups = [];
|
||||
var allProviders = [];
|
||||
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
selectedSubscription: null,
|
||||
selectedResourceGroup: null,
|
||||
formValidationError: '',
|
||||
};
|
||||
|
||||
$scope.changeSubscription = function () {
|
||||
var selectedSubscription = $scope.state.selectedSubscription;
|
||||
updateResourceGroupsAndLocations(selectedSubscription, allResourceGroups, allProviders);
|
||||
};
|
||||
|
||||
$scope.addPortBinding = function () {
|
||||
$scope.model.Ports.push({ host: '', container: '', protocol: 'TCP' });
|
||||
};
|
||||
|
||||
$scope.removePortBinding = function (index) {
|
||||
$scope.model.Ports.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.create = function () {
|
||||
var model = $scope.model;
|
||||
var subscriptionId = $scope.state.selectedSubscription.Id;
|
||||
var resourceGroupName = $scope.state.selectedResourceGroup.Name;
|
||||
|
||||
$scope.state.formValidationError = validateForm(model);
|
||||
if ($scope.state.formValidationError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
AzureService.createContainerGroup(model, subscriptionId, resourceGroupName)
|
||||
.then(applyResourceControl)
|
||||
.then(() => {
|
||||
Notifications.success('Container successfully created', model.Name);
|
||||
$state.go('azure.containerinstances');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to create container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
function applyResourceControl(newResourceGroup) {
|
||||
const userId = Authentication.getUserDetails().ID;
|
||||
const resourceControl = newResourceGroup.Portainer.ResourceControl;
|
||||
const accessControlData = $scope.model.AccessControlData;
|
||||
|
||||
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
|
||||
}
|
||||
|
||||
function validateForm(model) {
|
||||
if (!model.Ports || !model.Ports.length || model.Ports.every((port) => !port.host || !port.container)) {
|
||||
return 'At least one port binding is required';
|
||||
}
|
||||
|
||||
const error = FormValidator.validateAccessControl(model.AccessControlData, Authentication.isAdmin());
|
||||
if (error !== '') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateResourceGroupsAndLocations(subscription, resourceGroups, providers) {
|
||||
$scope.state.selectedResourceGroup = resourceGroups[subscription.Id][0];
|
||||
$scope.resourceGroups = resourceGroups[subscription.Id];
|
||||
|
||||
var currentSubLocations = providers[subscription.Id].Locations;
|
||||
$scope.model.Location = currentSubLocations[0];
|
||||
$scope.locations = currentSubLocations;
|
||||
}
|
||||
|
||||
function initView() {
|
||||
$scope.model = new ContainerGroupDefaultModel();
|
||||
|
||||
AzureService.subscriptions()
|
||||
.then(function success(data) {
|
||||
var subscriptions = data;
|
||||
$scope.state.selectedSubscription = subscriptions[0];
|
||||
$scope.subscriptions = subscriptions;
|
||||
|
||||
return $q.all({
|
||||
resourceGroups: AzureService.resourceGroups(subscriptions),
|
||||
containerInstancesProviders: AzureService.containerInstanceProvider(subscriptions),
|
||||
});
|
||||
})
|
||||
.then(function success(data) {
|
||||
var resourceGroups = data.resourceGroups;
|
||||
allResourceGroups = resourceGroups;
|
||||
|
||||
var containerInstancesProviders = data.containerInstancesProviders;
|
||||
allProviders = containerInstancesProviders;
|
||||
|
||||
var selectedSubscription = $scope.state.selectedSubscription;
|
||||
updateResourceGroupsAndLocations(selectedSubscription, resourceGroups, containerInstancesProviders);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve Azure resources');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
|
@ -1,173 +0,0 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Create container instance"></rd-header-title>
|
||||
<rd-header-content> <a ui-sref="azure.containerinstances">Container instances</a> > Add container </rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" autocomplete="off" name="aciForm">
|
||||
<div class="col-sm-12 form-section-title"> Azure settings </div>
|
||||
<!-- subscription-input -->
|
||||
<div class="form-group">
|
||||
<label for="azure_subscription" class="col-sm-1 control-label text-left">Subscription</label>
|
||||
<div class="col-sm-11">
|
||||
<select
|
||||
class="form-control"
|
||||
name="azure_subscription"
|
||||
ng-model="state.selectedSubscription"
|
||||
ng-options="subscription.Name for subscription in subscriptions"
|
||||
ng-change="changeSubscription()"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !subscription-input -->
|
||||
<!-- resourcegroup-input -->
|
||||
<div class="form-group">
|
||||
<label for="azure_resourcegroup" class="col-sm-1 control-label text-left">Resource group</label>
|
||||
<div class="col-sm-11">
|
||||
<select
|
||||
class="form-control"
|
||||
name="azure_resourcegroup"
|
||||
ng-model="state.selectedResourceGroup"
|
||||
ng-options="resourceGroup.Name for resourceGroup in resourceGroups"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !resourcegroup-input -->
|
||||
<!-- location-input -->
|
||||
<div class="form-group">
|
||||
<label for="azure_location" class="col-sm-1 control-label text-left">Location</label>
|
||||
<div class="col-sm-11">
|
||||
<select class="form-control" name="azure_location" ng-model="model.Location" ng-options="location for location in locations"></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !location-input -->
|
||||
<div class="col-sm-12 form-section-title"> Container configuration </div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-1 control-label text-left">Name</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="model.Name" name="container_name" placeholder="e.g. myContainer" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="aciForm.container_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="aciForm.container_name.$error">
|
||||
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Name is required. </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- image-input -->
|
||||
<div class="form-group">
|
||||
<label for="image_name" class="col-sm-1 control-label text-left">Image</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="model.Image" name="image_name" placeholder="e.g. nginx:alpine" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="aciForm.image_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="aciForm.image_name.$error">
|
||||
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Image is required. </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !image-input -->
|
||||
<!-- os-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_os" class="col-sm-1 control-label text-left">OS</label>
|
||||
<div class="col-sm-11">
|
||||
<select class="form-control" ng-model="model.OSType" name="container_os">
|
||||
<option value="Linux">Linux</option>
|
||||
<option value="Windows">Windows</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !os-input -->
|
||||
<!-- port-mapping -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Port mapping</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addPortBinding()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
|
||||
</span>
|
||||
</div>
|
||||
<!-- port-mapping-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
||||
<div ng-repeat="binding in model.Ports" style="margin-top: 2px">
|
||||
<!-- host-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">host</span>
|
||||
<input type="text" class="form-control" ng-model="binding.host" placeholder="e.g. 80" />
|
||||
</div>
|
||||
<!-- !host-port -->
|
||||
<span style="margin: 0 10px 0 10px">
|
||||
<i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
<!-- container-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">container</span>
|
||||
<input type="text" class="form-control" ng-model="binding.container" placeholder="e.g. 80" />
|
||||
</div>
|
||||
<!-- !container-port -->
|
||||
<!-- protocol-actions -->
|
||||
<div class="input-group col-sm-3 input-group-sm">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'TCP'">TCP</label>
|
||||
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'UDP'">UDP</label>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- !protocol-actions -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !port-mapping-input-list -->
|
||||
</div>
|
||||
<!-- !port-mapping -->
|
||||
<!-- public-ip -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">This will automatically deploy a container with a public IP address</div>
|
||||
</div>
|
||||
<!-- public-ip -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Container resources </div>
|
||||
<!-- cpu-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_cpu" class="col-sm-1 control-label text-left">CPU</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="number" class="form-control" ng-model="model.CPU" name="container_cpu" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-input -->
|
||||
<!-- memory-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_memory" class="col-sm-1 control-label text-left">Memory</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="number" class="form-control" ng-model="model.Memory" name="container_memory" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-input -->
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="model.AccessControlData"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress" ng-click="create()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Deploy the container</span>
|
||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
10
app/global.d.ts
vendored
10
app/global.d.ts
vendored
|
@ -8,3 +8,13 @@ declare module '*.png' {
|
|||
declare module '*.css';
|
||||
|
||||
declare module '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';
|
||||
|
||||
declare module 'axios-progress-bar' {
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { NProgressOptions } from 'nprogress';
|
||||
|
||||
export function loadProgressBar(
|
||||
config?: Partial<NProgressOptions>,
|
||||
instance?: AxiosInstance
|
||||
): void;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import Select from 'react-select';
|
|||
import { Team, TeamId } from '@/portainer/teams/types';
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
value: TeamId[];
|
||||
onChange(value: TeamId[]): void;
|
||||
teams: Team[];
|
||||
|
@ -12,6 +13,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export function TeamsSelector({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
teams,
|
||||
|
@ -21,6 +23,7 @@ export function TeamsSelector({
|
|||
}: Props) {
|
||||
return (
|
||||
<Select
|
||||
name={name}
|
||||
isMulti
|
||||
getOptionLabel={(team) => team.Name}
|
||||
getOptionValue={(team) => String(team.Id)}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { UserId } from '@/portainer/users/types';
|
|||
import './UsersSelector.css';
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
value: UserId[];
|
||||
onChange(value: UserId[]): void;
|
||||
users: UserViewModel[];
|
||||
|
@ -14,6 +15,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export function UsersSelector({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
users,
|
||||
|
@ -24,6 +26,7 @@ export function UsersSelector({
|
|||
return (
|
||||
<Select
|
||||
isMulti
|
||||
name={name}
|
||||
getOptionLabel={(user) => user.Username}
|
||||
getOptionValue={(user) => user.Id}
|
||||
options={users}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import _ from 'lodash';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
|
||||
|
@ -10,6 +11,8 @@ import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
|||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
|
||||
import { FormError } from '../form-components/FormError';
|
||||
|
||||
import { AccessControlFormData } from './model';
|
||||
import { UsersField } from './UsersField';
|
||||
import { TeamsField } from './TeamsField';
|
||||
|
@ -19,9 +22,17 @@ export interface Props {
|
|||
values: AccessControlFormData;
|
||||
onChange(values: AccessControlFormData): void;
|
||||
hideTitle?: boolean;
|
||||
errors?: FormikErrors<AccessControlFormData>;
|
||||
formNamespace?: string;
|
||||
}
|
||||
|
||||
export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
||||
export function AccessControlForm({
|
||||
values,
|
||||
onChange,
|
||||
hideTitle,
|
||||
errors,
|
||||
formNamespace,
|
||||
}: Props) {
|
||||
const { users, teams, isLoading } = useLoadState();
|
||||
|
||||
const { user } = useUser();
|
||||
|
@ -49,7 +60,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
|||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={values.accessControlEnabled}
|
||||
name="ownership"
|
||||
name={withNamespace('accessControlEnabled')}
|
||||
label="Enable access control"
|
||||
tooltip="When enabled, you can restrict the access and management of this resource."
|
||||
onChange={(accessControlEnabled) =>
|
||||
|
@ -63,7 +74,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
|||
<>
|
||||
<div className="form-group">
|
||||
<BoxSelector
|
||||
radioName="access-control"
|
||||
radioName={withNamespace('ownership')}
|
||||
value={values.ownership}
|
||||
options={options}
|
||||
onChange={(ownership) => handleChange({ ownership })}
|
||||
|
@ -73,16 +84,19 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
|||
<div aria-label="extra-options">
|
||||
{isAdmin && (
|
||||
<UsersField
|
||||
name={withNamespace('authorizedUsers')}
|
||||
users={users}
|
||||
onChange={(authorizedUsers) =>
|
||||
handleChange({ authorizedUsers })
|
||||
}
|
||||
value={values.authorizedUsers}
|
||||
errors={errors?.authorizedUsers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isAdmin || teams.length > 1) && (
|
||||
<TeamsField
|
||||
name={withNamespace('authorizedTeams')}
|
||||
teams={teams}
|
||||
overrideTooltip={
|
||||
!isAdmin && teams.length > 1
|
||||
|
@ -93,14 +107,25 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
|||
handleChange({ authorizedTeams })
|
||||
}
|
||||
value={values.authorizedTeams}
|
||||
errors={errors?.authorizedTeams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function withNamespace(name: string) {
|
||||
return formNamespace ? `${formNamespace}.${name}` : name;
|
||||
}
|
||||
}
|
||||
|
||||
function useOptions(isAdmin: boolean, teams?: Team[]) {
|
||||
|
|
|
@ -43,6 +43,8 @@ test('when access control is enabled, ownership is restricted and no teams or us
|
|||
{
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [],
|
||||
},
|
||||
{ strict: true }
|
||||
)
|
||||
|
@ -50,19 +52,40 @@ test('when access control is enabled, ownership is restricted and no teams or us
|
|||
});
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is admin but no users, should be valid', async () => {
|
||||
test('when access control is enabled, ownership is restricted, user is admin should have either teams or users', async () => {
|
||||
const schema = validationSchema(true);
|
||||
const teams = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema.validate(
|
||||
{
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
},
|
||||
{ strict: true }
|
||||
)
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
|
||||
teams
|
||||
);
|
||||
|
||||
const users = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [1],
|
||||
};
|
||||
|
||||
await expect(schema.validate(users, { strict: true })).resolves.toStrictEqual(
|
||||
users
|
||||
);
|
||||
|
||||
const both = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [2],
|
||||
};
|
||||
|
||||
await expect(schema.validate(both, { strict: true })).resolves.toStrictEqual(
|
||||
both
|
||||
);
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is admin with teams and users, should be valid', async () => {
|
||||
|
|
|
@ -3,39 +3,45 @@ import { object, string, array, number, bool } from 'yup';
|
|||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
return object().shape({
|
||||
accessControlEnabled: bool(),
|
||||
ownership: string()
|
||||
.oneOf(Object.values(ResourceControlOwnership))
|
||||
.when('accessControlEnabled', {
|
||||
is: true,
|
||||
then: (schema) => schema.required(),
|
||||
}),
|
||||
authorizedUsers: array(number()).when(
|
||||
['accessControlEnabled', 'ownership'],
|
||||
{
|
||||
is: (
|
||||
accessControlEnabled: boolean,
|
||||
ownership: ResourceControlOwnership
|
||||
) =>
|
||||
isAdmin &&
|
||||
accessControlEnabled &&
|
||||
ownership === ResourceControlOwnership.RESTRICTED,
|
||||
then: (schema) =>
|
||||
schema.required('You must specify at least one user.'),
|
||||
return object()
|
||||
.shape({
|
||||
accessControlEnabled: bool(),
|
||||
ownership: string()
|
||||
.oneOf(Object.values(ResourceControlOwnership))
|
||||
.when('accessControlEnabled', {
|
||||
is: true,
|
||||
then: (schema) => schema.required(),
|
||||
}),
|
||||
authorizedUsers: array(number()),
|
||||
authorizedTeams: array(number()),
|
||||
})
|
||||
.test(
|
||||
'user-and-team',
|
||||
isAdmin
|
||||
? 'You must specify at least one team or user.'
|
||||
: 'You must specify at least one team.',
|
||||
({
|
||||
accessControlEnabled,
|
||||
ownership,
|
||||
authorizedTeams,
|
||||
authorizedUsers,
|
||||
}) => {
|
||||
if (
|
||||
!accessControlEnabled ||
|
||||
ownership !== ResourceControlOwnership.RESTRICTED
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return !!authorizedTeams && authorizedTeams.length > 0;
|
||||
}
|
||||
|
||||
return (
|
||||
!!authorizedTeams &&
|
||||
!!authorizedUsers &&
|
||||
(authorizedTeams.length > 0 || authorizedUsers.length > 0)
|
||||
);
|
||||
}
|
||||
),
|
||||
authorizedTeams: array(number()).when(
|
||||
['accessControlEnabled', 'ownership'],
|
||||
{
|
||||
is: (
|
||||
accessControlEnabled: boolean,
|
||||
ownership: ResourceControlOwnership
|
||||
) =>
|
||||
accessControlEnabled &&
|
||||
ownership === ResourceControlOwnership.RESTRICTED,
|
||||
then: (schema) => schema.required('You must specify at least one team'),
|
||||
}
|
||||
),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,13 +4,22 @@ import { Link } from '@/portainer/components/Link';
|
|||
import { Team } from '@/portainer/teams/types';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
teams: Team[];
|
||||
value: number[];
|
||||
overrideTooltip?: string;
|
||||
onChange(value: number[]): void;
|
||||
errors?: string | string[];
|
||||
}
|
||||
|
||||
export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
|
||||
export function TeamsField({
|
||||
name,
|
||||
teams,
|
||||
value,
|
||||
overrideTooltip,
|
||||
onChange,
|
||||
errors,
|
||||
}: Props) {
|
||||
return (
|
||||
<FormControl
|
||||
label="Authorized teams"
|
||||
|
@ -21,9 +30,11 @@ export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
|
|||
: undefined
|
||||
}
|
||||
inputId="teams-selector"
|
||||
errors={errors}
|
||||
>
|
||||
{teams.length > 0 ? (
|
||||
<TeamsSelector
|
||||
name={name}
|
||||
teams={teams}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
|
|
|
@ -4,12 +4,14 @@ import { UserViewModel } from '@/portainer/models/user';
|
|||
import { Link } from '@/portainer/components/Link';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
users: UserViewModel[];
|
||||
value: number[];
|
||||
onChange(value: number[]): void;
|
||||
errors?: string | string[];
|
||||
}
|
||||
|
||||
export function UsersField({ users, value, onChange }: Props) {
|
||||
export function UsersField({ name, users, value, onChange, errors }: Props) {
|
||||
return (
|
||||
<FormControl
|
||||
label="Authorized users"
|
||||
|
@ -19,9 +21,11 @@ export function UsersField({ users, value, onChange }: Props) {
|
|||
: undefined
|
||||
}
|
||||
inputId="users-selector"
|
||||
errors={errors}
|
||||
>
|
||||
{users.length > 0 ? (
|
||||
<UsersSelector
|
||||
name={name}
|
||||
users={users}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team"`;
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team or user."`;
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team"`;
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted, user is admin but no users, should be valid 1`] = `"You must specify at least one user."`;
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team."`;
|
||||
|
||||
exports[`when only access control is enabled, should be invalid 1`] = `"ownership is a required field"`;
|
||||
|
|
|
@ -3,6 +3,8 @@ import clsx from 'clsx';
|
|||
|
||||
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
||||
|
||||
import { FormError } from '../FormError';
|
||||
|
||||
import styles from './FormControl.module.css';
|
||||
|
||||
type Size = 'small' | 'medium' | 'large';
|
||||
|
@ -40,13 +42,7 @@ export function FormControl({
|
|||
|
||||
{errors && (
|
||||
<div className="form-group col-md-12">
|
||||
<div className="small text-warning">
|
||||
<i
|
||||
className="fa fa-exclamation-triangle space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{errors}
|
||||
</div>
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
13
app/portainer/components/form-components/FormError.tsx
Normal file
13
app/portainer/components/form-components/FormError.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
export function FormError({ children }: PropsWithChildren<unknown>) {
|
||||
return (
|
||||
<div className="small text-warning">
|
||||
<i
|
||||
className="fa fa-exclamation-triangle space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx';
|
||||
import { SelectHTMLAttributes } from 'react';
|
||||
|
||||
interface Option<T extends string | number> {
|
||||
export interface Option<T extends string | number> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,10 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.item-line.has-error {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
margin-left: 2px;
|
||||
|
|
|
@ -5,13 +5,17 @@ import { AddButton, Button } from '@/portainer/components/Button';
|
|||
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
||||
|
||||
import { Input } from '../Input';
|
||||
import { FormError } from '../FormError';
|
||||
|
||||
import styles from './InputList.module.css';
|
||||
import { arrayMove } from './utils';
|
||||
|
||||
interface ItemProps<T> {
|
||||
export type InputListError<T> = Record<keyof T, string>;
|
||||
|
||||
export interface ItemProps<T> {
|
||||
item: T;
|
||||
onChange(value: T): void;
|
||||
error?: InputListError<T>;
|
||||
}
|
||||
type Key = string | number;
|
||||
type ChangeType = 'delete' | 'create' | 'update';
|
||||
|
@ -38,6 +42,7 @@ interface Props<T> {
|
|||
addLabel?: string;
|
||||
itemKeyGetter?(item: T, index: number): Key;
|
||||
movable?: boolean;
|
||||
errors?: InputListError<T>[] | string;
|
||||
}
|
||||
|
||||
export function InputList<T = DefaultType>({
|
||||
|
@ -50,6 +55,7 @@ export function InputList<T = DefaultType>({
|
|||
addLabel = 'Add item',
|
||||
itemKeyGetter = (item: T, index: number) => index,
|
||||
movable,
|
||||
errors,
|
||||
}: Props<T>) {
|
||||
const Item = item;
|
||||
|
||||
|
@ -70,12 +76,17 @@ export function InputList<T = DefaultType>({
|
|||
<div className={clsx('col-sm-12 form-inline', styles.items)}>
|
||||
{value.map((item, index) => {
|
||||
const key = itemKeyGetter(item, index);
|
||||
const error = typeof errors === 'object' ? errors[index] : undefined;
|
||||
|
||||
return (
|
||||
<div key={key} className={clsx(styles.itemLine)}>
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(styles.itemLine, { [styles.hasError]: !!error })}
|
||||
>
|
||||
<Item
|
||||
item={item}
|
||||
onChange={(value: T) => handleChangeItem(key, value)}
|
||||
error={error}
|
||||
/>
|
||||
<div className={styles.itemActions}>
|
||||
{movable && (
|
||||
|
@ -172,12 +183,15 @@ function defaultItemBuilder(): DefaultType {
|
|||
return { value: '' };
|
||||
}
|
||||
|
||||
function DefaultItem({ item, onChange }: ItemProps<DefaultType>) {
|
||||
function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
|
||||
return (
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
className={styles.defaultItem}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
className={styles.defaultItem}
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -125,6 +125,6 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||
}
|
||||
}
|
||||
|
||||
function isAdmin(user?: UserViewModel | null) {
|
||||
export function isAdmin(user?: UserViewModel | null): boolean {
|
||||
return !!user && user.Role === 1;
|
||||
}
|
||||
|
|
51
app/portainer/resource-control/helper.ts
Normal file
51
app/portainer/resource-control/helper.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
import { AccessControlFormData } from '../components/accessControlForm/model';
|
||||
import { TeamId } from '../teams/types';
|
||||
import { UserId } from '../users/types';
|
||||
|
||||
import { OwnershipParameters } from './types';
|
||||
|
||||
/**
|
||||
* Transform AccessControlFormData to ResourceControlOwnershipParameters
|
||||
* @param {int} userId ID of user performing the operation
|
||||
* @param {AccessControlFormData} formValues Form data (generated by AccessControlForm)
|
||||
* @param {int[]} subResources Sub Resources restricted by the ResourceControl
|
||||
*/
|
||||
export function parseOwnershipParameters(
|
||||
userId: UserId,
|
||||
formValues: AccessControlFormData,
|
||||
subResources: (number | string)[] = []
|
||||
): OwnershipParameters {
|
||||
let { ownership } = formValues;
|
||||
if (!formValues.accessControlEnabled) {
|
||||
ownership = ResourceControlOwnership.PUBLIC;
|
||||
}
|
||||
|
||||
let adminOnly = false;
|
||||
let publicOnly = false;
|
||||
let users: UserId[] = [];
|
||||
let teams: TeamId[] = [];
|
||||
switch (ownership) {
|
||||
case ResourceControlOwnership.PUBLIC:
|
||||
publicOnly = true;
|
||||
break;
|
||||
case ResourceControlOwnership.PRIVATE:
|
||||
users.push(userId);
|
||||
break;
|
||||
case ResourceControlOwnership.RESTRICTED:
|
||||
users = formValues.authorizedUsers;
|
||||
teams = formValues.authorizedTeams;
|
||||
break;
|
||||
default:
|
||||
adminOnly = true;
|
||||
break;
|
||||
}
|
||||
return {
|
||||
administratorsOnly: adminOnly,
|
||||
public: publicOnly,
|
||||
users,
|
||||
teams,
|
||||
subResources,
|
||||
};
|
||||
}
|
48
app/portainer/resource-control/resource-control.service.ts
Normal file
48
app/portainer/resource-control/resource-control.service.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { UserId } from '@/portainer/users/types';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
|
||||
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { parseOwnershipParameters } from './helper';
|
||||
import { OwnershipParameters } from './types';
|
||||
|
||||
/**
|
||||
* Apply a ResourceControl after Resource creation
|
||||
* @param userId ID of User performing the action
|
||||
* @param accessControlData ResourceControl to apply
|
||||
* @param resourceControl ResourceControl to update
|
||||
* @param subResources SubResources managed by the ResourceControl
|
||||
*/
|
||||
export function applyResourceControl(
|
||||
userId: UserId,
|
||||
accessControlData: AccessControlFormData,
|
||||
resourceControl: ResourceControlResponse,
|
||||
subResources: (number | string)[] = []
|
||||
) {
|
||||
const ownershipParameters = parseOwnershipParameters(
|
||||
userId,
|
||||
accessControlData,
|
||||
subResources
|
||||
);
|
||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a ResourceControl
|
||||
* @param resourceControlId ID of involved resource
|
||||
* @param ownershipParameters Transient type from view data to payload
|
||||
*/
|
||||
async function updateResourceControl(
|
||||
resourceControlId: string | number,
|
||||
ownershipParameters: OwnershipParameters
|
||||
) {
|
||||
try {
|
||||
await axios.put(
|
||||
`/resource_controls/${resourceControlId}`,
|
||||
ownershipParameters
|
||||
);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
13
app/portainer/resource-control/types.ts
Normal file
13
app/portainer/resource-control/types.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { TeamId } from '@/portainer/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
/**
|
||||
* Transient type from view data to payload
|
||||
*/
|
||||
export interface OwnershipParameters {
|
||||
administratorsOnly: boolean;
|
||||
public: boolean;
|
||||
users: UserId[];
|
||||
teams: TeamId[];
|
||||
subResources: (number | string)[];
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { loadProgressBar } from 'axios-progress-bar';
|
||||
import 'axios-progress-bar/dist/nprogress.css';
|
||||
|
||||
import PortainerError from '../error';
|
||||
import { get as localStorageGet } from '../hooks/useLocalStorage';
|
||||
|
@ -8,11 +10,13 @@ import {
|
|||
portainerAgentTargetHeader,
|
||||
} from './http-request.helper';
|
||||
|
||||
const axiosApiInstance = axios.create({ baseURL: 'api' });
|
||||
const axios = axiosOrigin.create({ baseURL: 'api' });
|
||||
|
||||
export default axiosApiInstance;
|
||||
loadProgressBar(undefined, axios);
|
||||
|
||||
axiosApiInstance.interceptors.request.use(async (config) => {
|
||||
export default axios;
|
||||
|
||||
axios.interceptors.request.use(async (config) => {
|
||||
const newConfig = { headers: config.headers || {}, ...config };
|
||||
|
||||
const jwt = localStorageGet('JWT', '');
|
||||
|
@ -41,18 +45,28 @@ export function agentInterceptor(config: AxiosRequestConfig) {
|
|||
return newConfig;
|
||||
}
|
||||
|
||||
axiosApiInstance.interceptors.request.use(agentInterceptor);
|
||||
axios.interceptors.request.use(agentInterceptor);
|
||||
|
||||
export function parseAxiosError(err: Error, msg = '') {
|
||||
export function parseAxiosError(
|
||||
err: Error,
|
||||
msg = '',
|
||||
parseError = defaultErrorParser
|
||||
) {
|
||||
let resultErr = err;
|
||||
let resultMsg = msg;
|
||||
|
||||
if ('isAxiosError' in err) {
|
||||
const axiosError = err as AxiosError;
|
||||
resultErr = new Error(`${axiosError.response?.data.message}`);
|
||||
const msgDetails = axiosError.response?.data.details;
|
||||
resultMsg = msg ? `${msg}: ${msgDetails}` : msgDetails;
|
||||
const { error, details } = parseError(err as AxiosError);
|
||||
resultErr = error;
|
||||
resultMsg = msg ? `${msg}: ${details}` : details;
|
||||
}
|
||||
|
||||
return new PortainerError(resultMsg, resultErr);
|
||||
}
|
||||
|
||||
function defaultErrorParser(axiosError: AxiosError) {
|
||||
const message = axiosError.response?.data.message;
|
||||
const details = axiosError.response?.data.details || message;
|
||||
const error = new Error(message);
|
||||
return { error, details };
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import { rest } from 'msw';
|
|||
|
||||
import { createMockTeams, createMockUsers } from '../react-tools/test-mocks';
|
||||
|
||||
import { azureHandlers } from './setup-handlers/azure';
|
||||
|
||||
export const handlers = [
|
||||
rest.get('/api/teams', async (req, res, ctx) =>
|
||||
res(ctx.json(createMockTeams(10)))
|
||||
|
@ -9,4 +11,5 @@ export const handlers = [
|
|||
rest.get('/api/users', async (req, res, ctx) =>
|
||||
res(ctx.json(createMockUsers(10)))
|
||||
),
|
||||
...azureHandlers,
|
||||
];
|
||||
|
|
76
app/setup-tests/setup-handlers/azure.ts
Normal file
76
app/setup-tests/setup-handlers/azure.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { rest } from 'msw';
|
||||
|
||||
export const azureHandlers = [
|
||||
rest.get('/api/endpoints/:endpointId/azure/subscriptions', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
value: [
|
||||
{
|
||||
id: '/subscriptions/sub1',
|
||||
authorizationSource: 'RoleBased',
|
||||
subscriptionId: 'sub1',
|
||||
displayName: 'Portainer',
|
||||
state: 'Enabled',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
),
|
||||
rest.get(
|
||||
'/api/endpoints/:endpointId/azure/subscriptions/:subscriptionId/providers/Microsoft.ContainerInstance',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
id: `/subscriptions/${req.params.subscriptionId}/providers/Microsoft.ContainerInstance`,
|
||||
namespace: 'Microsoft.ContainerInstance',
|
||||
resourceTypes: [
|
||||
{
|
||||
resourceType: 'containerGroups',
|
||||
locations: [
|
||||
'Australia East',
|
||||
'Australia Southeast',
|
||||
'Brazil South',
|
||||
],
|
||||
},
|
||||
{
|
||||
resourceType: 'serviceAssociationLinks',
|
||||
locations: [
|
||||
'Korea Central',
|
||||
'North Central US',
|
||||
'North Europe',
|
||||
'Norway East',
|
||||
'South Africa North',
|
||||
'South Central US',
|
||||
],
|
||||
},
|
||||
{
|
||||
resourceType: 'locations',
|
||||
locations: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
),
|
||||
rest.get(
|
||||
'/api/endpoints/:endpointId/azure/subscriptions/:subsriptionId/resourcegroups',
|
||||
(res, req, ctx) =>
|
||||
req(
|
||||
ctx.json({
|
||||
value: [
|
||||
{
|
||||
id: `/subscriptions/${res.params.subscriptionId}/resourceGroups/rg1`,
|
||||
name: 'rg1',
|
||||
location: 'southcentralus',
|
||||
properties: { provisioningState: 'Succeeded' },
|
||||
},
|
||||
{
|
||||
id: `/subscriptions/${res.params.subscriptionId}/resourceGroups/rg2`,
|
||||
name: 'rg2',
|
||||
location: 'southcentralus',
|
||||
properties: { provisioningState: 'Succeeded' },
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
),
|
||||
];
|
|
@ -92,6 +92,7 @@
|
|||
"angularjs-slider": "^6.4.0",
|
||||
"angulartics": "^1.6.0",
|
||||
"axios": "^0.24.0",
|
||||
"axios-progress-bar": "^1.2.0",
|
||||
"babel-plugin-angularjs-annotate": "^0.10.0",
|
||||
"bootbox": "^5.5.2",
|
||||
"bootstrap": "^3.4.0",
|
||||
|
|
|
@ -5057,6 +5057,11 @@ axe-core@^4.3.5:
|
|||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.5.tgz#78d6911ba317a8262bfee292aeafcc1e04b49cc5"
|
||||
integrity sha512-WKTW1+xAzhMS5dJsxWkliixlO/PqC4VhmO9T4juNYcaTg9jzWiJsou6m5pxWYGfigWbwzJWeFY6z47a+4neRXA==
|
||||
|
||||
axios-progress-bar@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/axios-progress-bar/-/axios-progress-bar-1.2.0.tgz#f9ee88dc9af977246be1ef07eedfa4c990c639c5"
|
||||
integrity sha512-PEgWb/b2SMyHnKJ/cxA46OdCuNeVlo8eqL0HxXPtz+6G/Jtpyo49icPbW+jpO1wUeDEjbqpseMoCyWxESxf5pA==
|
||||
|
||||
axios@^0.24.0:
|
||||
version "0.24.0"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue