mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49: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',
|
'$stateRegistryProvider',
|
||||||
function ($stateRegistryProvider) {
|
function ($stateRegistryProvider) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
@ -53,8 +57,7 @@ angular.module('portainer.azure', ['portainer.app']).config([
|
||||||
url: '/new/',
|
url: '/new/',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: './views/containerinstances/create/createcontainerinstance.html',
|
component: 'createContainerInstanceView',
|
||||||
controller: 'AzureCreateContainerInstanceController',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -48,48 +48,3 @@ export function ContainerGroupViewModel(data) {
|
||||||
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
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',
|
'api-version': '2016-06-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: { method: 'GET' },
|
|
||||||
get: { method: 'GET', params: { id: '@id' } },
|
get: { method: 'GET', params: { id: '@id' } },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,72 +1,75 @@
|
||||||
angular.module('portainer.azure').factory('AzureService', [
|
import { ResourceGroupViewModel } from '../models/resource_group';
|
||||||
'$q',
|
import { SubscriptionViewModel } from '../models/subscription';
|
||||||
'Azure',
|
import { getResourceGroups } from './resource-groups.service';
|
||||||
'SubscriptionService',
|
import { getSubscriptions } from './subscription.service';
|
||||||
'ResourceGroupService',
|
|
||||||
'ContainerGroupService',
|
|
||||||
'ProviderService',
|
|
||||||
function AzureServiceFactory($q, Azure, SubscriptionService, ResourceGroupService, ContainerGroupService, ProviderService) {
|
|
||||||
'use strict';
|
|
||||||
var service = {};
|
|
||||||
|
|
||||||
service.deleteContainerGroup = function (id) {
|
angular.module('portainer.azure').factory('AzureService', AzureService);
|
||||||
return Azure.delete(id, '2018-04-01');
|
|
||||||
};
|
|
||||||
|
|
||||||
service.createContainerGroup = function (model, subscriptionId, resourceGroupName) {
|
/* @ngInject */
|
||||||
return ContainerGroupService.create(model, subscriptionId, resourceGroupName);
|
export function AzureService($q, Azure, $async, EndpointProvider, ContainerGroupService) {
|
||||||
};
|
'use strict';
|
||||||
|
var service = {};
|
||||||
|
|
||||||
service.subscriptions = function () {
|
service.deleteContainerGroup = function (id) {
|
||||||
return SubscriptionService.subscriptions();
|
return Azure.delete(id, '2018-04-01');
|
||||||
};
|
};
|
||||||
|
|
||||||
service.containerInstanceProvider = function (subscriptions) {
|
service.subscriptions = async function subscriptions() {
|
||||||
return retrieveResourcesForEachSubscription(subscriptions, ProviderService.containerInstanceProvider);
|
return $async(async () => {
|
||||||
};
|
const environmentId = EndpointProvider.endpointID();
|
||||||
|
const subscriptions = await getSubscriptions(environmentId);
|
||||||
|
return subscriptions.map((s) => new SubscriptionViewModel(s));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
service.resourceGroups = function (subscriptions) {
|
service.resourceGroups = function resourceGroups(subscriptions) {
|
||||||
return retrieveResourcesForEachSubscription(subscriptions, ResourceGroupService.resourceGroups);
|
return $async(async () => {
|
||||||
};
|
return retrieveResourcesForEachSubscription(subscriptions, async (subscriptionId) => {
|
||||||
|
const environmentId = EndpointProvider.endpointID();
|
||||||
|
|
||||||
service.containerGroups = function (subscriptions) {
|
const resourceGroups = await getResourceGroups(environmentId, subscriptionId);
|
||||||
return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups);
|
return resourceGroups.map((r) => new ResourceGroupViewModel(r, subscriptionId));
|
||||||
};
|
|
||||||
|
|
||||||
service.aggregate = function (resourcesBySubcription) {
|
|
||||||
var aggregatedResources = [];
|
|
||||||
Object.keys(resourcesBySubcription).forEach(function (key) {
|
|
||||||
aggregatedResources = aggregatedResources.concat(resourcesBySubcription[key]);
|
|
||||||
});
|
});
|
||||||
return aggregatedResources;
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) {
|
service.containerGroups = function (subscriptions) {
|
||||||
var deferred = $q.defer();
|
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 = [];
|
function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) {
|
||||||
for (var i = 0; i < subscriptions.length; i++) {
|
var deferred = $q.defer();
|
||||||
var subscription = subscriptions[i];
|
|
||||||
resourceQueries.push(resourceQuery(subscription.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
$q.all(resourceQueries)
|
var resources = {};
|
||||||
.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;
|
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', [
|
angular.module('portainer.azure').factory('ContainerGroupService', [
|
||||||
'$q',
|
'$q',
|
||||||
|
@ -30,18 +30,6 @@ angular.module('portainer.azure').factory('ContainerGroupService', [
|
||||||
return new ContainerGroupViewModel(containerGroup);
|
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;
|
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';
|
'use strict';
|
||||||
var service = {};
|
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;
|
service.resourceGroup = resourceGroup;
|
||||||
async function resourceGroup(subscriptionId, resourceGroupName) {
|
async function resourceGroup(subscriptionId, resourceGroupName) {
|
||||||
const group = await ResourceGroup.get({ subscriptionId, resourceGroupName }).$promise;
|
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',
|
'$q',
|
||||||
'Subscription',
|
'Subscription',
|
||||||
function SubscriptionServiceFactory($q, Subscription) {
|
function SubscriptionServiceFactory($q, Subscription) {
|
||||||
'use strict';
|
return { subscription };
|
||||||
var service = {};
|
|
||||||
|
|
||||||
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) {
|
async function subscription(id) {
|
||||||
const subscription = await Subscription.get({ id }).$promise;
|
const subscription = await Subscription.get({ id }).$promise;
|
||||||
return new SubscriptionViewModel(subscription);
|
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 '*.css';
|
||||||
|
|
||||||
declare module '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';
|
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';
|
import { Team, TeamId } from '@/portainer/teams/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
name?: string;
|
||||||
value: TeamId[];
|
value: TeamId[];
|
||||||
onChange(value: TeamId[]): void;
|
onChange(value: TeamId[]): void;
|
||||||
teams: Team[];
|
teams: Team[];
|
||||||
|
@ -12,6 +13,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeamsSelector({
|
export function TeamsSelector({
|
||||||
|
name,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
teams,
|
teams,
|
||||||
|
@ -21,6 +23,7 @@ export function TeamsSelector({
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
name={name}
|
||||||
isMulti
|
isMulti
|
||||||
getOptionLabel={(team) => team.Name}
|
getOptionLabel={(team) => team.Name}
|
||||||
getOptionValue={(team) => String(team.Id)}
|
getOptionValue={(team) => String(team.Id)}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { UserId } from '@/portainer/users/types';
|
||||||
import './UsersSelector.css';
|
import './UsersSelector.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
name?: string;
|
||||||
value: UserId[];
|
value: UserId[];
|
||||||
onChange(value: UserId[]): void;
|
onChange(value: UserId[]): void;
|
||||||
users: UserViewModel[];
|
users: UserViewModel[];
|
||||||
|
@ -14,6 +15,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsersSelector({
|
export function UsersSelector({
|
||||||
|
name,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
users,
|
users,
|
||||||
|
@ -24,6 +26,7 @@ export function UsersSelector({
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isMulti
|
isMulti
|
||||||
|
name={name}
|
||||||
getOptionLabel={(user) => user.Username}
|
getOptionLabel={(user) => user.Username}
|
||||||
getOptionValue={(user) => user.Id}
|
getOptionValue={(user) => user.Id}
|
||||||
options={users}
|
options={users}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||||
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
|
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 { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { FormError } from '../form-components/FormError';
|
||||||
|
|
||||||
import { AccessControlFormData } from './model';
|
import { AccessControlFormData } from './model';
|
||||||
import { UsersField } from './UsersField';
|
import { UsersField } from './UsersField';
|
||||||
import { TeamsField } from './TeamsField';
|
import { TeamsField } from './TeamsField';
|
||||||
|
@ -19,9 +22,17 @@ export interface Props {
|
||||||
values: AccessControlFormData;
|
values: AccessControlFormData;
|
||||||
onChange(values: AccessControlFormData): void;
|
onChange(values: AccessControlFormData): void;
|
||||||
hideTitle?: boolean;
|
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 { users, teams, isLoading } = useLoadState();
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
@ -49,7 +60,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<SwitchField
|
<SwitchField
|
||||||
checked={values.accessControlEnabled}
|
checked={values.accessControlEnabled}
|
||||||
name="ownership"
|
name={withNamespace('accessControlEnabled')}
|
||||||
label="Enable access control"
|
label="Enable access control"
|
||||||
tooltip="When enabled, you can restrict the access and management of this resource."
|
tooltip="When enabled, you can restrict the access and management of this resource."
|
||||||
onChange={(accessControlEnabled) =>
|
onChange={(accessControlEnabled) =>
|
||||||
|
@ -63,7 +74,7 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
||||||
<>
|
<>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<BoxSelector
|
<BoxSelector
|
||||||
radioName="access-control"
|
radioName={withNamespace('ownership')}
|
||||||
value={values.ownership}
|
value={values.ownership}
|
||||||
options={options}
|
options={options}
|
||||||
onChange={(ownership) => handleChange({ ownership })}
|
onChange={(ownership) => handleChange({ ownership })}
|
||||||
|
@ -73,16 +84,19 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
||||||
<div aria-label="extra-options">
|
<div aria-label="extra-options">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<UsersField
|
<UsersField
|
||||||
|
name={withNamespace('authorizedUsers')}
|
||||||
users={users}
|
users={users}
|
||||||
onChange={(authorizedUsers) =>
|
onChange={(authorizedUsers) =>
|
||||||
handleChange({ authorizedUsers })
|
handleChange({ authorizedUsers })
|
||||||
}
|
}
|
||||||
value={values.authorizedUsers}
|
value={values.authorizedUsers}
|
||||||
|
errors={errors?.authorizedUsers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(isAdmin || teams.length > 1) && (
|
{(isAdmin || teams.length > 1) && (
|
||||||
<TeamsField
|
<TeamsField
|
||||||
|
name={withNamespace('authorizedTeams')}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
overrideTooltip={
|
overrideTooltip={
|
||||||
!isAdmin && teams.length > 1
|
!isAdmin && teams.length > 1
|
||||||
|
@ -93,14 +107,25 @@ export function AccessControlForm({ values, onChange, hideTitle }: Props) {
|
||||||
handleChange({ authorizedTeams })
|
handleChange({ authorizedTeams })
|
||||||
}
|
}
|
||||||
value={values.authorizedTeams}
|
value={values.authorizedTeams}
|
||||||
|
errors={errors?.authorizedTeams}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{typeof errors === 'string' && (
|
||||||
|
<div className="form-group col-md-12">
|
||||||
|
<FormError>{errors}</FormError>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function withNamespace(name: string) {
|
||||||
|
return formNamespace ? `${formNamespace}.${name}` : name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useOptions(isAdmin: boolean, teams?: Team[]) {
|
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,
|
accessControlEnabled: true,
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
ownership: ResourceControlOwnership.RESTRICTED,
|
||||||
|
authorizedTeams: [],
|
||||||
|
authorizedUsers: [],
|
||||||
},
|
},
|
||||||
{ strict: true }
|
{ 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 schema = validationSchema(true);
|
||||||
|
const teams = {
|
||||||
|
accessControlEnabled: true,
|
||||||
|
ownership: ResourceControlOwnership.RESTRICTED,
|
||||||
|
authorizedTeams: [1],
|
||||||
|
authorizedUsers: [],
|
||||||
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
|
||||||
schema.validate(
|
teams
|
||||||
{
|
);
|
||||||
accessControlEnabled: true,
|
|
||||||
ownership: ResourceControlOwnership.RESTRICTED,
|
const users = {
|
||||||
authorizedTeams: [1],
|
accessControlEnabled: true,
|
||||||
},
|
ownership: ResourceControlOwnership.RESTRICTED,
|
||||||
{ strict: true }
|
authorizedTeams: [],
|
||||||
)
|
authorizedUsers: [1],
|
||||||
).rejects.toThrowErrorMatchingSnapshot();
|
};
|
||||||
|
|
||||||
|
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 () => {
|
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';
|
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||||
|
|
||||||
export function validationSchema(isAdmin: boolean) {
|
export function validationSchema(isAdmin: boolean) {
|
||||||
return object().shape({
|
return object()
|
||||||
accessControlEnabled: bool(),
|
.shape({
|
||||||
ownership: string()
|
accessControlEnabled: bool(),
|
||||||
.oneOf(Object.values(ResourceControlOwnership))
|
ownership: string()
|
||||||
.when('accessControlEnabled', {
|
.oneOf(Object.values(ResourceControlOwnership))
|
||||||
is: true,
|
.when('accessControlEnabled', {
|
||||||
then: (schema) => schema.required(),
|
is: true,
|
||||||
}),
|
then: (schema) => schema.required(),
|
||||||
authorizedUsers: array(number()).when(
|
}),
|
||||||
['accessControlEnabled', 'ownership'],
|
authorizedUsers: array(number()),
|
||||||
{
|
authorizedTeams: array(number()),
|
||||||
is: (
|
})
|
||||||
accessControlEnabled: boolean,
|
.test(
|
||||||
ownership: ResourceControlOwnership
|
'user-and-team',
|
||||||
) =>
|
isAdmin
|
||||||
isAdmin &&
|
? 'You must specify at least one team or user.'
|
||||||
accessControlEnabled &&
|
: 'You must specify at least one team.',
|
||||||
ownership === ResourceControlOwnership.RESTRICTED,
|
({
|
||||||
then: (schema) =>
|
accessControlEnabled,
|
||||||
schema.required('You must specify at least one user.'),
|
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';
|
import { Team } from '@/portainer/teams/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
name: string;
|
||||||
teams: Team[];
|
teams: Team[];
|
||||||
value: number[];
|
value: number[];
|
||||||
overrideTooltip?: string;
|
overrideTooltip?: string;
|
||||||
onChange(value: number[]): void;
|
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 (
|
return (
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Authorized teams"
|
label="Authorized teams"
|
||||||
|
@ -21,9 +30,11 @@ export function TeamsField({ teams, value, overrideTooltip, onChange }: Props) {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
inputId="teams-selector"
|
inputId="teams-selector"
|
||||||
|
errors={errors}
|
||||||
>
|
>
|
||||||
{teams.length > 0 ? (
|
{teams.length > 0 ? (
|
||||||
<TeamsSelector
|
<TeamsSelector
|
||||||
|
name={name}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
|
|
@ -4,12 +4,14 @@ import { UserViewModel } from '@/portainer/models/user';
|
||||||
import { Link } from '@/portainer/components/Link';
|
import { Link } from '@/portainer/components/Link';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
name: string;
|
||||||
users: UserViewModel[];
|
users: UserViewModel[];
|
||||||
value: number[];
|
value: number[];
|
||||||
onChange(value: number[]): void;
|
onChange(value: number[]): void;
|
||||||
|
errors?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsersField({ users, value, onChange }: Props) {
|
export function UsersField({ name, users, value, onChange, errors }: Props) {
|
||||||
return (
|
return (
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Authorized users"
|
label="Authorized users"
|
||||||
|
@ -19,9 +21,11 @@ export function UsersField({ users, value, onChange }: Props) {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
inputId="users-selector"
|
inputId="users-selector"
|
||||||
|
errors={errors}
|
||||||
>
|
>
|
||||||
{users.length > 0 ? (
|
{users.length > 0 ? (
|
||||||
<UsersSelector
|
<UsersSelector
|
||||||
|
name={name}
|
||||||
users={users}
|
users={users}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// 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 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 only access control is enabled, should be invalid 1`] = `"ownership is a required field"`;
|
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 { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
||||||
|
|
||||||
|
import { FormError } from '../FormError';
|
||||||
|
|
||||||
import styles from './FormControl.module.css';
|
import styles from './FormControl.module.css';
|
||||||
|
|
||||||
type Size = 'small' | 'medium' | 'large';
|
type Size = 'small' | 'medium' | 'large';
|
||||||
|
@ -40,13 +42,7 @@ export function FormControl({
|
||||||
|
|
||||||
{errors && (
|
{errors && (
|
||||||
<div className="form-group col-md-12">
|
<div className="form-group col-md-12">
|
||||||
<div className="small text-warning">
|
<FormError>{errors}</FormError>
|
||||||
<i
|
|
||||||
className="fa fa-exclamation-triangle space-right"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{errors}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 clsx from 'clsx';
|
||||||
import { SelectHTMLAttributes } from 'react';
|
import { SelectHTMLAttributes } from 'react';
|
||||||
|
|
||||||
interface Option<T extends string | number> {
|
export interface Option<T extends string | number> {
|
||||||
value: T;
|
value: T;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,10 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-line.has-error {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.item-actions {
|
.item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
|
|
|
@ -5,13 +5,17 @@ import { AddButton, Button } from '@/portainer/components/Button';
|
||||||
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
||||||
|
|
||||||
import { Input } from '../Input';
|
import { Input } from '../Input';
|
||||||
|
import { FormError } from '../FormError';
|
||||||
|
|
||||||
import styles from './InputList.module.css';
|
import styles from './InputList.module.css';
|
||||||
import { arrayMove } from './utils';
|
import { arrayMove } from './utils';
|
||||||
|
|
||||||
interface ItemProps<T> {
|
export type InputListError<T> = Record<keyof T, string>;
|
||||||
|
|
||||||
|
export interface ItemProps<T> {
|
||||||
item: T;
|
item: T;
|
||||||
onChange(value: T): void;
|
onChange(value: T): void;
|
||||||
|
error?: InputListError<T>;
|
||||||
}
|
}
|
||||||
type Key = string | number;
|
type Key = string | number;
|
||||||
type ChangeType = 'delete' | 'create' | 'update';
|
type ChangeType = 'delete' | 'create' | 'update';
|
||||||
|
@ -38,6 +42,7 @@ interface Props<T> {
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
itemKeyGetter?(item: T, index: number): Key;
|
itemKeyGetter?(item: T, index: number): Key;
|
||||||
movable?: boolean;
|
movable?: boolean;
|
||||||
|
errors?: InputListError<T>[] | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputList<T = DefaultType>({
|
export function InputList<T = DefaultType>({
|
||||||
|
@ -50,6 +55,7 @@ export function InputList<T = DefaultType>({
|
||||||
addLabel = 'Add item',
|
addLabel = 'Add item',
|
||||||
itemKeyGetter = (item: T, index: number) => index,
|
itemKeyGetter = (item: T, index: number) => index,
|
||||||
movable,
|
movable,
|
||||||
|
errors,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const Item = item;
|
const Item = item;
|
||||||
|
|
||||||
|
@ -70,12 +76,17 @@ export function InputList<T = DefaultType>({
|
||||||
<div className={clsx('col-sm-12 form-inline', styles.items)}>
|
<div className={clsx('col-sm-12 form-inline', styles.items)}>
|
||||||
{value.map((item, index) => {
|
{value.map((item, index) => {
|
||||||
const key = itemKeyGetter(item, index);
|
const key = itemKeyGetter(item, index);
|
||||||
|
const error = typeof errors === 'object' ? errors[index] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className={clsx(styles.itemLine)}>
|
<div
|
||||||
|
key={key}
|
||||||
|
className={clsx(styles.itemLine, { [styles.hasError]: !!error })}
|
||||||
|
>
|
||||||
<Item
|
<Item
|
||||||
item={item}
|
item={item}
|
||||||
onChange={(value: T) => handleChangeItem(key, value)}
|
onChange={(value: T) => handleChangeItem(key, value)}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
<div className={styles.itemActions}>
|
<div className={styles.itemActions}>
|
||||||
{movable && (
|
{movable && (
|
||||||
|
@ -172,12 +183,15 @@ function defaultItemBuilder(): DefaultType {
|
||||||
return { value: '' };
|
return { value: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function DefaultItem({ item, onChange }: ItemProps<DefaultType>) {
|
function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<>
|
||||||
value={item.value}
|
<Input
|
||||||
onChange={(e) => onChange({ value: e.target.value })}
|
value={item.value}
|
||||||
className={styles.defaultItem}
|
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;
|
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 PortainerError from '../error';
|
||||||
import { get as localStorageGet } from '../hooks/useLocalStorage';
|
import { get as localStorageGet } from '../hooks/useLocalStorage';
|
||||||
|
@ -8,11 +10,13 @@ import {
|
||||||
portainerAgentTargetHeader,
|
portainerAgentTargetHeader,
|
||||||
} from './http-request.helper';
|
} 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 newConfig = { headers: config.headers || {}, ...config };
|
||||||
|
|
||||||
const jwt = localStorageGet('JWT', '');
|
const jwt = localStorageGet('JWT', '');
|
||||||
|
@ -41,18 +45,28 @@ export function agentInterceptor(config: AxiosRequestConfig) {
|
||||||
return newConfig;
|
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 resultErr = err;
|
||||||
let resultMsg = msg;
|
let resultMsg = msg;
|
||||||
|
|
||||||
if ('isAxiosError' in err) {
|
if ('isAxiosError' in err) {
|
||||||
const axiosError = err as AxiosError;
|
const { error, details } = parseError(err as AxiosError);
|
||||||
resultErr = new Error(`${axiosError.response?.data.message}`);
|
resultErr = error;
|
||||||
const msgDetails = axiosError.response?.data.details;
|
resultMsg = msg ? `${msg}: ${details}` : details;
|
||||||
resultMsg = msg ? `${msg}: ${msgDetails}` : msgDetails;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PortainerError(resultMsg, resultErr);
|
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 { createMockTeams, createMockUsers } from '../react-tools/test-mocks';
|
||||||
|
|
||||||
|
import { azureHandlers } from './setup-handlers/azure';
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
rest.get('/api/teams', async (req, res, ctx) =>
|
rest.get('/api/teams', async (req, res, ctx) =>
|
||||||
res(ctx.json(createMockTeams(10)))
|
res(ctx.json(createMockTeams(10)))
|
||||||
|
@ -9,4 +11,5 @@ export const handlers = [
|
||||||
rest.get('/api/users', async (req, res, ctx) =>
|
rest.get('/api/users', async (req, res, ctx) =>
|
||||||
res(ctx.json(createMockUsers(10)))
|
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",
|
"angularjs-slider": "^6.4.0",
|
||||||
"angulartics": "^1.6.0",
|
"angulartics": "^1.6.0",
|
||||||
"axios": "^0.24.0",
|
"axios": "^0.24.0",
|
||||||
|
"axios-progress-bar": "^1.2.0",
|
||||||
"babel-plugin-angularjs-annotate": "^0.10.0",
|
"babel-plugin-angularjs-annotate": "^0.10.0",
|
||||||
"bootbox": "^5.5.2",
|
"bootbox": "^5.5.2",
|
||||||
"bootstrap": "^3.4.0",
|
"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"
|
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.5.tgz#78d6911ba317a8262bfee292aeafcc1e04b49cc5"
|
||||||
integrity sha512-WKTW1+xAzhMS5dJsxWkliixlO/PqC4VhmO9T4juNYcaTg9jzWiJsou6m5pxWYGfigWbwzJWeFY6z47a+4neRXA==
|
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:
|
axios@^0.24.0:
|
||||||
version "0.24.0"
|
version "0.24.0"
|
||||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6"
|
resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue