mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 13:55:21 +02:00
refactor(azure): migrate module to react [EE-2782] (#6689)
* refactor(azure): migrate module to react [EE-2782] * fix(azure): remove optional chain * feat(azure): apply new icons in dashboard * feat(azure): apply new icons in dashboard * feat(ui): allow single string for breadcrumbs * refactor(azure/containers): use Table.content * feat(azure/containers): implement new ui [EE-3538] * fix(azure/containers): use correct icon * chore(tests): mock svg as component * fix(azure): fix tests Co-authored-by: matias.spinarolli <matias.spinarolli@portainer.io>
This commit is contained in:
parent
b059641c80
commit
82b848af0c
97 changed files with 1723 additions and 1430 deletions
142
app/react/azure/DashboardView/DashboardView.test.tsx
Normal file
142
app/react/azure/DashboardView/DashboardView.test.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
import {
|
||||
createMockResourceGroups,
|
||||
createMockSubscriptions,
|
||||
} from '@/react-tools/test-mocks';
|
||||
|
||||
import { DashboardView } from './DashboardView';
|
||||
|
||||
jest.mock('@uirouter/react', () => ({
|
||||
...jest.requireActual('@uirouter/react'),
|
||||
useCurrentStateAndParams: jest.fn(() => ({
|
||||
params: { endpointId: 1 },
|
||||
})),
|
||||
}));
|
||||
|
||||
test('dashboard items should render correctly', async () => {
|
||||
const { findByLabelText } = await renderComponent();
|
||||
|
||||
const subscriptionsItem = await findByLabelText('Subscription');
|
||||
expect(subscriptionsItem).toBeVisible();
|
||||
|
||||
const subscriptionElements = within(subscriptionsItem);
|
||||
expect(subscriptionElements.getByLabelText('value')).toBeVisible();
|
||||
|
||||
expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent(
|
||||
'Subscriptions'
|
||||
);
|
||||
|
||||
const resourceGroupsItem = await findByLabelText('Resource group');
|
||||
expect(resourceGroupsItem).toBeVisible();
|
||||
|
||||
const resourceGroupElements = within(resourceGroupsItem);
|
||||
expect(resourceGroupElements.getByLabelText('value')).toBeVisible();
|
||||
|
||||
expect(
|
||||
resourceGroupElements.getByLabelText('resourceType')
|
||||
).toHaveTextContent('Resource groups');
|
||||
});
|
||||
|
||||
test('when there are no subscriptions, should show 0 subscriptions and 0 resource groups', async () => {
|
||||
const { findByLabelText } = await renderComponent();
|
||||
|
||||
const subscriptionElements = within(await findByLabelText('Subscription'));
|
||||
expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('0');
|
||||
|
||||
const resourceGroupElements = within(await findByLabelText('Resource group'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
test('when there is subscription & resource group data, should display these', async () => {
|
||||
const { findByLabelText } = await renderComponent(1, { 'subscription-1': 2 });
|
||||
|
||||
const subscriptionElements = within(await findByLabelText('Subscription'));
|
||||
expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('1');
|
||||
|
||||
const resourceGroupElements = within(await findByLabelText('Resource group'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
test('should correctly show total number of resource groups across multiple subscriptions', async () => {
|
||||
const { findByLabelText } = await renderComponent(2, {
|
||||
'subscription-1': 2,
|
||||
'subscription-2': 3,
|
||||
});
|
||||
|
||||
const resourceGroupElements = within(await findByLabelText('Resource group'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
test("when only subscriptions fail to load, don't show the dashboard", async () => {
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
1,
|
||||
{ 'subscription-1': 1 },
|
||||
500,
|
||||
200
|
||||
);
|
||||
expect(queryByLabelText('Subscription')).not.toBeInTheDocument();
|
||||
expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when only resource groups fail to load, still show the subscriptions', async () => {
|
||||
const { queryByLabelText, findByLabelText } = await renderComponent(
|
||||
1,
|
||||
{ 'subscription-1': 1 },
|
||||
200,
|
||||
500
|
||||
);
|
||||
await expect(findByLabelText('Subscription')).resolves.toBeInTheDocument();
|
||||
expect(queryByLabelText('Resource group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
async function renderComponent(
|
||||
subscriptionsCount = 0,
|
||||
resourceGroups: Record<string, number> = {},
|
||||
subscriptionsStatus = 200,
|
||||
resourceGroupsStatus = 200
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const state = { user };
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'/api/endpoints/:endpointId/azure/subscriptions',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
ctx.json(createMockSubscriptions(subscriptionsCount)),
|
||||
ctx.status(subscriptionsStatus)
|
||||
)
|
||||
),
|
||||
rest.get(
|
||||
'/api/endpoints/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups',
|
||||
(req, res, ctx) => {
|
||||
if (typeof req.params.subscriptionId !== 'string') {
|
||||
throw new Error("Provided subscriptionId must be of type: 'string'");
|
||||
}
|
||||
|
||||
const { subscriptionId } = req.params;
|
||||
return res(
|
||||
ctx.json(
|
||||
createMockResourceGroups(
|
||||
req.params.subscriptionId,
|
||||
resourceGroups[subscriptionId] || 0
|
||||
)
|
||||
),
|
||||
ctx.status(resourceGroupsStatus)
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
const renderResult = renderWithQueryClient(
|
||||
<UserContext.Provider value={state}>
|
||||
<DashboardView />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(renderResult.findByText(/Home/)).resolves.toBeVisible();
|
||||
|
||||
return renderResult;
|
||||
}
|
53
app/react/azure/DashboardView/DashboardView.tsx
Normal file
53
app/react/azure/DashboardView/DashboardView.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { Package } from 'react-feather';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { DashboardItem } from '@@/DashboardItem';
|
||||
import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
|
||||
|
||||
import { useResourceGroups } from '../queries/useResourceGroups';
|
||||
import { useSubscriptions } from '../queries/useSubscriptions';
|
||||
|
||||
import SubscriptionsIcon from './icon-subscription.svg?c';
|
||||
|
||||
export function DashboardView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const subscriptionsQuery = useSubscriptions(environmentId);
|
||||
|
||||
const resourceGroupsQuery = useResourceGroups(
|
||||
environmentId,
|
||||
subscriptionsQuery.data
|
||||
);
|
||||
|
||||
const subscriptionsCount = subscriptionsQuery.data?.length;
|
||||
const resourceGroupsCount = Object.values(
|
||||
resourceGroupsQuery.resourceGroups
|
||||
).flatMap((x) => Object.values(x)).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Home" breadcrumbs={[{ label: 'Dashboard' }]} />
|
||||
|
||||
<div className="mx-4">
|
||||
{subscriptionsQuery.data && (
|
||||
<DashboardGrid>
|
||||
<DashboardItem
|
||||
value={subscriptionsCount as number}
|
||||
icon={SubscriptionsIcon}
|
||||
type="Subscription"
|
||||
/>
|
||||
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
|
||||
<DashboardItem
|
||||
value={resourceGroupsCount}
|
||||
icon={Package}
|
||||
type="Resource group"
|
||||
/>
|
||||
)}
|
||||
</DashboardGrid>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
3
app/react/azure/DashboardView/icon-subscription.svg
Normal file
3
app/react/azure/DashboardView/icon-subscription.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.8641 8.08333H1.69743M10.3224 16.7083L17.7974 16.7083C18.8709 16.7083 19.4076 16.7083 19.8176 16.4994C20.1782 16.3157 20.4714 16.0225 20.6552 15.6618C20.8641 15.2518 20.8641 14.7151 20.8641 13.6417V6.35833C20.8641 5.2849 20.8641 4.74818 20.6552 4.33819C20.4714 3.97754 20.1782 3.68433 19.8176 3.50057C19.4076 3.29167 18.8709 3.29167 17.7974 3.29167H16.0724M10.3224 16.7083L12.2391 18.625M10.3224 16.7083L12.2391 14.7917M6.48909 16.7083H4.76409C3.69066 16.7083 3.15394 16.7083 2.74394 16.4994C2.3833 16.3157 2.09009 16.0225 1.90633 15.6618C1.69743 15.2518 1.69743 14.7151 1.69743 13.6417V6.35833C1.69743 5.2849 1.69743 4.74818 1.90633 4.33818C2.09009 3.97754 2.3833 3.68433 2.74394 3.50057C3.15394 3.29167 3.69066 3.29167 4.76409 3.29167H12.2391M12.2391 3.29167L10.3224 5.20833M12.2391 3.29167L10.3224 1.375" stroke="#344054" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1,008 B |
1
app/react/azure/DashboardView/index.ts
Normal file
1
app/react/azure/DashboardView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { DashboardView } from './DashboardView';
|
|
@ -0,0 +1,43 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
jest.mock('@uirouter/react', () => ({
|
||||
...jest.requireActual('@uirouter/react'),
|
||||
useCurrentStateAndParams: jest.fn(() => ({
|
||||
params: { endpointId: 5 },
|
||||
})),
|
||||
}));
|
||||
|
||||
test('submit button should be disabled when name or image is missing', async () => {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
|
||||
const { findByText, getByText, getByLabelText } = renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<CreateContainerInstanceForm />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
|
||||
|
||||
const button = getByText(/Deploy the container/);
|
||||
expect(button).toBeVisible();
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const nameInput = getByLabelText(/name/i);
|
||||
userEvent.type(nameInput, 'name');
|
||||
|
||||
const imageInput = getByLabelText(/image/i);
|
||||
userEvent.type(imageInput, 'image');
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
|
||||
|
||||
expect(nameInput).toHaveValue('name');
|
||||
userEvent.clear(nameInput);
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
|
||||
});
|
|
@ -0,0 +1,217 @@
|
|||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { ContainerInstanceFormValues } from '@/react/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
|
||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||
import { PortsMappingField } from './PortsMappingField';
|
||||
import { useFormState, useLoadFormState } from './useLoadFormState';
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
import { useCreateInstanceMutation } from './useCreateInstanceMutation';
|
||||
|
||||
export function CreateContainerInstanceForm() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const { providers, subscriptions, resourceGroups, isLoading } =
|
||||
useLoadFormState(environmentId);
|
||||
|
||||
const { initialValues, subscriptionOptions } = useFormState(
|
||||
subscriptions,
|
||||
resourceGroups,
|
||||
providers
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync } = useCreateInstanceMutation(
|
||||
resourceGroups,
|
||||
environmentId
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik<ContainerInstanceFormValues>
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(isAdmin)}
|
||||
onSubmit={onSubmit}
|
||||
validateOnMount
|
||||
validateOnChange
|
||||
enableReinitialize
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
|
||||
<FormSectionTitle>Azure settings</FormSectionTitle>
|
||||
<FormControl
|
||||
label="Subscription"
|
||||
inputId="subscription-input"
|
||||
errors={errors.subscription}
|
||||
>
|
||||
<Field
|
||||
name="subscription"
|
||||
as={Select}
|
||||
id="subscription-input"
|
||||
options={subscriptionOptions}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Resource group"
|
||||
inputId="resourceGroup-input"
|
||||
errors={errors.resourceGroup}
|
||||
>
|
||||
<Field
|
||||
name="resourceGroup"
|
||||
as={Select}
|
||||
id="resourceGroup-input"
|
||||
options={getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Location"
|
||||
inputId="location-input"
|
||||
errors={errors.location}
|
||||
>
|
||||
<Field
|
||||
name="location"
|
||||
as={Select}
|
||||
id="location-input"
|
||||
options={getSubscriptionLocations(values.subscription, providers)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSectionTitle>Container configuration</FormSectionTitle>
|
||||
|
||||
<FormControl label="Name" inputId="name-input" errors={errors.name}>
|
||||
<Field
|
||||
name="name"
|
||||
as={Input}
|
||||
id="name-input"
|
||||
placeholder="e.g. myContainer"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Image"
|
||||
inputId="image-input"
|
||||
errors={errors.image}
|
||||
>
|
||||
<Field
|
||||
name="image"
|
||||
as={Input}
|
||||
id="image-input"
|
||||
placeholder="e.g. nginx:alpine"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="OS" inputId="os-input" errors={errors.os}>
|
||||
<Field
|
||||
name="os"
|
||||
as={Select}
|
||||
id="os-input"
|
||||
options={[
|
||||
{ label: 'Linux', value: 'Linux' },
|
||||
{ label: 'Windows', value: 'Windows' },
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(value) => setFieldValue('ports', value)}
|
||||
errors={errors.ports}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 small text-muted">
|
||||
This will automatically deploy a container with a public IP
|
||||
address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormSectionTitle>Container Resources</FormSectionTitle>
|
||||
|
||||
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
|
||||
<Field
|
||||
name="cpu"
|
||||
as={Input}
|
||||
id="cpu-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Memory"
|
||||
inputId="cpu-input"
|
||||
errors={errors.memory}
|
||||
>
|
||||
<Field
|
||||
name="memory"
|
||||
as={Input}
|
||||
id="memory-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid}
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Deployment in progress..."
|
||||
>
|
||||
<i className="fa fa-plus space-right" aria-hidden="true" />
|
||||
Deploy the container
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function onSubmit(values: ContainerInstanceFormValues) {
|
||||
try {
|
||||
await mutateAsync(values);
|
||||
notifications.success('Container successfully created', values.name);
|
||||
router.stateService.go('azure.containerinstances');
|
||||
} catch (e) {
|
||||
notifications.error('Failure', e as Error, 'Unable to create container');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { object, string, number, boolean } from 'yup';
|
||||
|
||||
import { validationSchema as accessControlSchema } from '@/portainer/access-control/AccessControlForm/AccessControlForm.validation';
|
||||
|
||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
return object().shape({
|
||||
name: string().required('Name is required.'),
|
||||
image: string().required('Image is required.'),
|
||||
subscription: string().required('Subscription is required.'),
|
||||
resourceGroup: string().required('Resource group is required.'),
|
||||
location: string().required('Location is required.'),
|
||||
os: string().oneOf(['Linux', 'Windows']),
|
||||
cpu: number().positive(),
|
||||
memory: number().positive(),
|
||||
allocatePublicIP: boolean(),
|
||||
ports: portsSchema(),
|
||||
accessControl: accessControlSchema(isAdmin),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
export function CreateView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create container instance"
|
||||
breadcrumbs={[
|
||||
{ link: 'azure.containerinstances', label: 'Container instances' },
|
||||
{ label: 'Add container' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<CreateContainerInstanceForm />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item .inputs {
|
||||
}
|
||||
|
||||
.item .errors {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { ItemProps } from '@@/form-components/InputList/InputList';
|
||||
|
||||
import styles from './PortsMappingField.module.css';
|
||||
|
||||
type Protocol = 'TCP' | 'UDP';
|
||||
|
||||
export interface PortMapping {
|
||||
host?: number;
|
||||
container?: number;
|
||||
protocol: Protocol;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: PortMapping[];
|
||||
onChange?(value: PortMapping[]): void;
|
||||
errors?: FormikErrors<PortMapping>[] | string | string[];
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function PortsMappingField({
|
||||
value,
|
||||
onChange = () => {},
|
||||
errors,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<InputList<PortMapping>
|
||||
label="Port mapping"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
addLabel="map additional port"
|
||||
itemBuilder={() => ({
|
||||
host: 0,
|
||||
container: 0,
|
||||
protocol: 'TCP',
|
||||
})}
|
||||
item={Item}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
onChange,
|
||||
item,
|
||||
error,
|
||||
disabled,
|
||||
readOnly,
|
||||
}: ItemProps<PortMapping>) {
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<div className="flex items-center gap-2">
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>host</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
value={item.host}
|
||||
onChange={(e) =>
|
||||
handleChange('host', parseInt(e.target.value || '0', 10))
|
||||
}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
type="number"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<span className="mx-3">
|
||||
<i className="fa fa-long-arrow-alt-right" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>container</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
value={item.container}
|
||||
onChange={(e) =>
|
||||
handleChange('container', parseInt(e.target.value || '0', 10))
|
||||
}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
type="number"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonSelector<Protocol>
|
||||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
{!!error && (
|
||||
<div className={styles.errors}>
|
||||
<FormError>{Object.values(error)[0]}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(name: keyof PortMapping, value: string | number) {
|
||||
onChange({ ...item, [name]: value });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { array, object, string } from 'yup';
|
||||
|
||||
export function validationSchema() {
|
||||
return array(
|
||||
object().shape({
|
||||
host: string().required('host is required'),
|
||||
container: string().required('container is required'),
|
||||
protocol: string().oneOf(['TCP', 'UDP']),
|
||||
})
|
||||
).min(1, 'At least one port binding is required');
|
||||
}
|
1
app/react/azure/container-instances/CreateView/index.ts
Normal file
1
app/react/azure/container-instances/CreateView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { CreateView } from './CreateView';
|
|
@ -0,0 +1,62 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { createContainerGroup } from '@/react/azure/services/container-groups.service';
|
||||
import { queryKeys } from '@/react/azure/queries/query-keys';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import {
|
||||
ContainerGroup,
|
||||
ContainerInstanceFormValues,
|
||||
ResourceGroup,
|
||||
} from '@/react/azure/types';
|
||||
import { applyResourceControl } from '@/portainer/access-control/access-control.service';
|
||||
|
||||
import { getSubscriptionResourceGroups } from './utils';
|
||||
|
||||
export function useCreateInstanceMutation(
|
||||
resourceGroups: {
|
||||
[k: string]: ResourceGroup[];
|
||||
},
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ContainerGroup, unknown, ContainerInstanceFormValues>(
|
||||
(values) => {
|
||||
if (!values.subscription) {
|
||||
throw new PortainerError('subscription is required');
|
||||
}
|
||||
|
||||
const subscriptionResourceGroup = getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
);
|
||||
const resourceGroup = subscriptionResourceGroup.find(
|
||||
(r) => r.value === values.resourceGroup
|
||||
);
|
||||
if (!resourceGroup) {
|
||||
throw new PortainerError('resource group not found');
|
||||
}
|
||||
|
||||
return createContainerGroup(
|
||||
values,
|
||||
environmentId,
|
||||
values.subscription,
|
||||
resourceGroup.label
|
||||
);
|
||||
},
|
||||
{
|
||||
async onSuccess(containerGroup, values) {
|
||||
const resourceControl = containerGroup.Portainer?.ResourceControl;
|
||||
if (!resourceControl) {
|
||||
throw new PortainerError('resource control expected after creation');
|
||||
}
|
||||
|
||||
const accessControlData = values.accessControl;
|
||||
await applyResourceControl(accessControlData, resourceControl);
|
||||
return queryClient.invalidateQueries(
|
||||
queryKeys.subscriptions(environmentId)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import {
|
||||
ContainerInstanceFormValues,
|
||||
ProviderViewModel,
|
||||
ResourceGroup,
|
||||
Subscription,
|
||||
} from '@/react/azure/types';
|
||||
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
||||
import { useProvider } from '@/react/azure/queries/useProvider';
|
||||
import { useResourceGroups } from '@/react/azure/queries/useResourceGroups';
|
||||
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
|
||||
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
|
||||
export function useLoadFormState(environmentId: EnvironmentId) {
|
||||
const { data: subscriptions, isLoading: isLoadingSubscriptions } =
|
||||
useSubscriptions(environmentId);
|
||||
const { resourceGroups, isLoading: isLoadingResourceGroups } =
|
||||
useResourceGroups(environmentId, subscriptions);
|
||||
const { providers, isLoading: isLoadingProviders } = useProvider(
|
||||
environmentId,
|
||||
subscriptions
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isLoadingSubscriptions || isLoadingResourceGroups || isLoadingProviders;
|
||||
|
||||
return { isLoading, subscriptions, resourceGroups, providers };
|
||||
}
|
||||
|
||||
export function useFormState(
|
||||
subscriptions: Subscription[] = [],
|
||||
resourceGroups: Record<string, ResourceGroup[]> = {},
|
||||
providers: Record<string, ProviderViewModel> = {}
|
||||
) {
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const subscriptionOptions = subscriptions.map((s) => ({
|
||||
value: s.subscriptionId,
|
||||
label: s.displayName,
|
||||
}));
|
||||
|
||||
const initSubscriptionId = getFirstValue(subscriptionOptions);
|
||||
|
||||
const subscriptionResourceGroups = getSubscriptionResourceGroups(
|
||||
initSubscriptionId,
|
||||
resourceGroups
|
||||
);
|
||||
|
||||
const subscriptionLocations = getSubscriptionLocations(
|
||||
initSubscriptionId,
|
||||
providers
|
||||
);
|
||||
|
||||
const initialValues: ContainerInstanceFormValues = {
|
||||
name: '',
|
||||
location: getFirstValue(subscriptionLocations),
|
||||
subscription: initSubscriptionId,
|
||||
resourceGroup: getFirstValue(subscriptionResourceGroups),
|
||||
image: '',
|
||||
os: 'Linux',
|
||||
memory: 1,
|
||||
cpu: 1,
|
||||
ports: [{ container: 80, host: 80, protocol: 'TCP' }],
|
||||
allocatePublicIP: true,
|
||||
accessControl: parseAccessControlFormData(isAdmin),
|
||||
};
|
||||
|
||||
return {
|
||||
initialValues,
|
||||
subscriptionOptions,
|
||||
};
|
||||
|
||||
function getFirstValue<T>(arr: { value: T }[]) {
|
||||
if (arr.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return arr[0].value;
|
||||
}
|
||||
}
|
34
app/react/azure/container-instances/CreateView/utils.ts
Normal file
34
app/react/azure/container-instances/CreateView/utils.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { ProviderViewModel, ResourceGroup } from '@/react/azure/types';
|
||||
|
||||
export function getSubscriptionResourceGroups(
|
||||
subscriptionId?: string,
|
||||
resourceGroups?: Record<string, ResourceGroup[]>
|
||||
) {
|
||||
if (!subscriptionId || !resourceGroups || !resourceGroups[subscriptionId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resourceGroups[subscriptionId].map(({ name, id }) => ({
|
||||
value: id,
|
||||
label: name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSubscriptionLocations(
|
||||
subscriptionId?: string,
|
||||
containerInstanceProviders?: Record<string, ProviderViewModel | undefined>
|
||||
) {
|
||||
if (!subscriptionId || !containerInstanceProviders) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = containerInstanceProviders[subscriptionId];
|
||||
if (!provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return provider.locations.map((location) => ({
|
||||
value: location,
|
||||
label: location,
|
||||
}));
|
||||
}
|
266
app/react/azure/container-instances/ItemView/ItemView.tsx
Normal file
266
app/react/azure/container-instances/ItemView/ItemView.tsx
Normal file
|
@ -0,0 +1,266 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
import {
|
||||
ContainerGroup,
|
||||
ResourceGroup,
|
||||
Subscription,
|
||||
} from '@/react/azure/types';
|
||||
import { useContainerGroup } from '@/react/azure/queries/useContainerGroup';
|
||||
import { useResourceGroup } from '@/react/azure/queries/useResourceGroup';
|
||||
import { useSubscription } from '@/react/azure/queries/useSubscription';
|
||||
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
|
||||
import { PortsMappingField } from '../CreateView/PortsMappingField';
|
||||
|
||||
export function ItemView() {
|
||||
const {
|
||||
params: { id },
|
||||
} = useCurrentStateAndParams();
|
||||
const { subscriptionId, resourceGroupId, containerGroupId } = parseId(id);
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const subscriptionQuery = useSubscription(environmentId, subscriptionId);
|
||||
const resourceGroupQuery = useResourceGroup(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupId
|
||||
);
|
||||
|
||||
const containerQuery = useContainerGroup(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupId,
|
||||
containerGroupId
|
||||
);
|
||||
|
||||
if (
|
||||
!subscriptionQuery.isSuccess ||
|
||||
!resourceGroupQuery.isSuccess ||
|
||||
!containerQuery.isSuccess
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = aggregateContainerData(
|
||||
subscriptionQuery.data,
|
||||
resourceGroupQuery.data,
|
||||
containerQuery.data
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Container Instance"
|
||||
breadcrumbs={[
|
||||
{ link: 'azure.containerinstances', label: 'Container instances' },
|
||||
{ label: container.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody className="form-horizontal">
|
||||
<FormSectionTitle>Azure settings</FormSectionTitle>
|
||||
<FormControl label="Subscription" inputId="subscription-input">
|
||||
<Input
|
||||
name="subscription"
|
||||
id="subscription-input"
|
||||
value={container.subscriptionName}
|
||||
readOnly
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Resource group" inputId="resourceGroup-input">
|
||||
<Input
|
||||
name="resourceGroup"
|
||||
id="resourceGroup-input"
|
||||
value={container.resourceGroupName}
|
||||
readOnly
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Location" inputId="location-input">
|
||||
<Input
|
||||
name="location"
|
||||
id="location-input"
|
||||
value={container.location}
|
||||
readOnly
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSectionTitle>Container configuration</FormSectionTitle>
|
||||
|
||||
<FormControl label="Name" inputId="name-input">
|
||||
<Input
|
||||
name="name"
|
||||
id="name-input"
|
||||
readOnly
|
||||
value={container.name}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Image" inputId="image-input">
|
||||
<Input
|
||||
name="image"
|
||||
id="image-input"
|
||||
value={container.imageName}
|
||||
readOnly
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="OS" inputId="os-input">
|
||||
<Input
|
||||
name="os"
|
||||
id="os-input"
|
||||
readOnly
|
||||
value={container.osType}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<PortsMappingField value={container.ports} readOnly />
|
||||
|
||||
<FormControl label="Public IP" inputId="public-ip">
|
||||
<Input
|
||||
name="public-ip"
|
||||
id="public-ip"
|
||||
readOnly
|
||||
value={container.ipAddress}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSectionTitle>Container Resources</FormSectionTitle>
|
||||
|
||||
<FormControl label="CPU" inputId="cpu-input">
|
||||
<Input
|
||||
name="cpu"
|
||||
id="cpu-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
readOnly
|
||||
value={container.cpu}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Memory" inputId="cpu-input">
|
||||
<Input
|
||||
name="memory"
|
||||
id="memory-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
readOnly
|
||||
value={container.memory}
|
||||
/>
|
||||
</FormControl>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccessControlPanel
|
||||
onUpdateSuccess={() =>
|
||||
queryClient.invalidateQueries([
|
||||
'azure',
|
||||
environmentId,
|
||||
'subscriptions',
|
||||
subscriptionId,
|
||||
'resourceGroups',
|
||||
resourceGroupQuery.data.name,
|
||||
'containerGroups',
|
||||
containerQuery.data.name,
|
||||
])
|
||||
}
|
||||
resourceId={id}
|
||||
resourceControl={container.resourceControl}
|
||||
resourceType={ResourceControlType.ContainerGroup}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function parseId(id: string) {
|
||||
const match = id.match(
|
||||
/^\/subscriptions\/(.+)\/resourceGroups\/(.+)\/providers\/(.+)\/containerGroups\/(.+)$/
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('container id is missing details');
|
||||
}
|
||||
|
||||
const [, subscriptionId, resourceGroupId, , containerGroupId] = match;
|
||||
|
||||
return { subscriptionId, resourceGroupId, containerGroupId };
|
||||
}
|
||||
|
||||
function aggregateContainerData(
|
||||
subscription: Subscription,
|
||||
resourceGroup: ResourceGroup,
|
||||
containerGroup: ContainerGroup
|
||||
) {
|
||||
const containerInstanceData = aggregateContainerInstance();
|
||||
|
||||
const resourceControl = containerGroup.Portainer?.ResourceControl
|
||||
? new ResourceControlViewModel(containerGroup.Portainer.ResourceControl)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name: containerGroup.name,
|
||||
subscriptionName: subscription.displayName,
|
||||
resourceGroupName: resourceGroup.name,
|
||||
location: containerGroup.location,
|
||||
osType: containerGroup.properties.osType,
|
||||
ipAddress: containerGroup.properties.ipAddress.ip,
|
||||
resourceControl,
|
||||
...containerInstanceData,
|
||||
};
|
||||
|
||||
function aggregateContainerInstance() {
|
||||
const containerInstanceData = containerGroup.properties.containers[0];
|
||||
|
||||
if (!containerInstanceData) {
|
||||
return {
|
||||
ports: [],
|
||||
};
|
||||
}
|
||||
|
||||
const containerInstanceProperties = containerInstanceData.properties;
|
||||
|
||||
const containerPorts = containerInstanceProperties.ports;
|
||||
|
||||
const imageName = containerInstanceProperties.image;
|
||||
|
||||
const ports = containerGroup.properties.ipAddress.ports.map(
|
||||
(binding, index) => {
|
||||
const port =
|
||||
containerPorts && containerPorts[index]
|
||||
? containerPorts[index].port
|
||||
: undefined;
|
||||
return {
|
||||
container: port,
|
||||
host: binding.port,
|
||||
protocol: binding.protocol,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
imageName,
|
||||
ports,
|
||||
cpu: containerInstanceProperties.resources.cpu,
|
||||
memory: containerInstanceProperties.resources.memoryInGB,
|
||||
};
|
||||
}
|
||||
}
|
1
app/react/azure/container-instances/ItemView/index.ts
Normal file
1
app/react/azure/container-instances/ItemView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ItemView } from './ItemView';
|
|
@ -0,0 +1,216 @@
|
|||
import { useEffect } from 'react';
|
||||
import {
|
||||
useTable,
|
||||
useSortBy,
|
||||
useGlobalFilter,
|
||||
usePagination,
|
||||
} from 'react-table';
|
||||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
import { Box, Plus, Trash2 } from 'react-feather';
|
||||
|
||||
import { useDebounce } from '@/portainer/hooks/useDebounce';
|
||||
import { ContainerGroup } from '@/react/azure/types';
|
||||
import { Authorized } from '@/portainer/hooks/useUser';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import {
|
||||
Table,
|
||||
TableActions,
|
||||
TableContainer,
|
||||
TableHeaderRow,
|
||||
TableRow,
|
||||
TableTitle,
|
||||
} from '@@/datatables';
|
||||
import { multiple } from '@@/datatables/filter-types';
|
||||
import { useTableSettings } from '@@/datatables/useTableSettings';
|
||||
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
|
||||
import { useRowSelect } from '@@/datatables/useRowSelect';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { TableSettings } from './types';
|
||||
import { useColumns } from './columns';
|
||||
|
||||
export interface Props {
|
||||
tableKey: string;
|
||||
dataset: ContainerGroup[];
|
||||
onRemoveClick(containerIds: string[]): void;
|
||||
}
|
||||
|
||||
export function ContainersDatatable({
|
||||
dataset,
|
||||
tableKey,
|
||||
onRemoveClick,
|
||||
}: Props) {
|
||||
const { settings, setTableSettings } = useTableSettings<TableSettings>();
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey);
|
||||
|
||||
const columns = useColumns();
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
selectedFlatRows,
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
setGlobalFilter,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable<ContainerGroup>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: dataset,
|
||||
filterTypes: { multiple },
|
||||
initialState: {
|
||||
pageSize: settings.pageSize || 10,
|
||||
sortBy: [settings.sortBy],
|
||||
globalFilter: searchBarValue,
|
||||
},
|
||||
selectCheckboxComponent: Checkbox,
|
||||
autoResetSelectedRows: false,
|
||||
getRowId(row) {
|
||||
return row.id;
|
||||
},
|
||||
},
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
useRowSelectColumn
|
||||
);
|
||||
|
||||
const debouncedSearchValue = useDebounce(searchBarValue);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFilter(debouncedSearchValue);
|
||||
}, [debouncedSearchValue, setGlobalFilter]);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableContainer>
|
||||
<TableTitle icon={Box} label="Containers">
|
||||
<SearchBar
|
||||
value={searchBarValue}
|
||||
onChange={handleSearchBarChange}
|
||||
/>
|
||||
|
||||
<TableActions>
|
||||
<Authorized authorizations="AzureContainerGroupDelete">
|
||||
<Button
|
||||
color="dangerlight"
|
||||
disabled={selectedFlatRows.length === 0}
|
||||
onClick={() =>
|
||||
handleRemoveClick(
|
||||
selectedFlatRows.map((row) => row.original.id)
|
||||
)
|
||||
}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Authorized>
|
||||
|
||||
<Authorized authorizations="AzureContainerGroupCreate">
|
||||
<Link to="azure.containerinstances.new" className="space-left">
|
||||
<Button icon={Plus}>Add container</Button>
|
||||
</Link>
|
||||
</Authorized>
|
||||
</TableActions>
|
||||
</TableTitle>
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<TableHeaderRow<ContainerGroup>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<Table.Content
|
||||
prepareRow={prepareRow}
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<TableRow<ContainerGroup>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
rows={page}
|
||||
emptyContent="No container available."
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<TableFooter>
|
||||
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
page={pageIndex + 1}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
totalCount={dataset.length}
|
||||
onPageLimitChange={handlePageSizeChange}
|
||||
/>
|
||||
</TableFooter>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function handleRemoveClick(containerIds: string[]) {
|
||||
const confirmed = await confirmDeletionAsync(
|
||||
'Are you sure you want to delete the selected containers?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return onRemoveClick(containerIds);
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
setPageSize(pageSize);
|
||||
setTableSettings((settings) => ({ ...settings, pageSize }));
|
||||
}
|
||||
|
||||
function handleSearchBarChange(value: string) {
|
||||
setSearchBarValue(value);
|
||||
}
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setTableSettings((settings) => ({
|
||||
...settings,
|
||||
sortBy: { id, desc },
|
||||
}));
|
||||
}
|
||||
}
|
97
app/react/azure/container-instances/ListView/ListView.tsx
Normal file
97
app/react/azure/container-instances/ListView/ListView.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { deleteContainerGroup } from '@/react/azure/services/container-groups.service';
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import { useContainerGroups } from '@/react/azure/queries/useContainerGroups';
|
||||
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
|
||||
|
||||
import { ContainersDatatable } from './ContainersDatatable';
|
||||
import { TableSettings } from './types';
|
||||
|
||||
export function ListView() {
|
||||
const defaultSettings: TableSettings = {
|
||||
pageSize: 10,
|
||||
sortBy: { id: 'state', desc: false },
|
||||
};
|
||||
|
||||
const tableKey = 'containergroups';
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const subscriptionsQuery = useSubscriptions(environmentId);
|
||||
|
||||
const groupsQuery = useContainerGroups(
|
||||
environmentId,
|
||||
subscriptionsQuery.data,
|
||||
subscriptionsQuery.isSuccess
|
||||
);
|
||||
|
||||
const { handleRemove } = useRemoveMutation(environmentId);
|
||||
|
||||
if (groupsQuery.isLoading || subscriptionsQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
breadcrumbs="Container instances"
|
||||
reload
|
||||
title="Container list"
|
||||
/>
|
||||
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
|
||||
<ContainersDatatable
|
||||
tableKey={tableKey}
|
||||
dataset={groupsQuery.containerGroups}
|
||||
onRemoveClick={handleRemove}
|
||||
/>
|
||||
</TableSettingsProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useRemoveMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
(containerGroupIds: string[]) =>
|
||||
promiseSequence(
|
||||
containerGroupIds.map(
|
||||
(id) => () => deleteContainerGroup(environmentId, id)
|
||||
)
|
||||
),
|
||||
|
||||
{
|
||||
onSuccess() {
|
||||
return queryClient.invalidateQueries([
|
||||
'azure',
|
||||
environmentId,
|
||||
'subscriptions',
|
||||
]);
|
||||
},
|
||||
onError(err) {
|
||||
notifyError(
|
||||
'Failure',
|
||||
err as Error,
|
||||
'Unable to remove container groups'
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { handleRemove };
|
||||
|
||||
async function handleRemove(groupIds: string[]) {
|
||||
deleteMutation.mutate(groupIds, {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Container groups successfully removed');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { name } from './name';
|
||||
import { location } from './location';
|
||||
import { ports } from './ports';
|
||||
import { ownership } from './ownership';
|
||||
|
||||
export function useColumns() {
|
||||
return useMemo(() => [name, location, ports, ownership], []);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { ContainerGroup } from '@/react/azure/types';
|
||||
|
||||
export const location: Column<ContainerGroup> = {
|
||||
Header: 'Location',
|
||||
accessor: (container) => container.location,
|
||||
id: 'location',
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { ContainerGroup } from '@/react/azure/types';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export const name: Column<ContainerGroup> = {
|
||||
Header: 'Name',
|
||||
accessor: (container) => container.name,
|
||||
id: 'name',
|
||||
Cell: NameCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function NameCell({
|
||||
value: name,
|
||||
row: { original: container },
|
||||
}: CellProps<ContainerGroup, string>) {
|
||||
return (
|
||||
<Link
|
||||
to="azure.containerinstances.container"
|
||||
params={{ id: container.id }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { Column } from 'react-table';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import { ResourceControlOwnership } from '@/portainer/access-control/types';
|
||||
import { ContainerGroup } from '@/react/azure/types';
|
||||
import { determineOwnership } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export const ownership: Column<ContainerGroup> = {
|
||||
Header: 'Ownership',
|
||||
id: 'ownership',
|
||||
accessor: (row) =>
|
||||
row.Portainer && row.Portainer.ResourceControl
|
||||
? determineOwnership(row.Portainer.ResourceControl)
|
||||
: ResourceControlOwnership.ADMINISTRATORS,
|
||||
Cell: OwnershipCell,
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
Filter: () => null,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value: 'public' | 'private' | 'restricted' | 'administrators';
|
||||
}
|
||||
|
||||
function OwnershipCell({ value }: Props) {
|
||||
return (
|
||||
<>
|
||||
<i
|
||||
className={clsx(ownershipIcon(value), 'space-right')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{value}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { ContainerGroup } from '@/react/azure/types';
|
||||
import { getPorts } from '@/react/azure/utils';
|
||||
|
||||
export const ports: Column<ContainerGroup> = {
|
||||
Header: 'Published Ports',
|
||||
accessor: (container) => getPorts(container),
|
||||
id: 'ports',
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
Cell: PortsCell,
|
||||
};
|
||||
|
||||
function PortsCell({
|
||||
value: ports,
|
||||
row: { original: container },
|
||||
}: CellProps<ContainerGroup, ReturnType<typeof getPorts>>) {
|
||||
const ip = container.properties.ipAddress
|
||||
? container.properties.ipAddress.ip
|
||||
: '';
|
||||
if (ports.length === 0 || !ip) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return ports.map((port) => (
|
||||
<a className="image-tag" href={`http://${ip}:${port.host}`} key={port.host}>
|
||||
<i className="fa fa-external-link-alt" aria-hidden="true" /> {ip}:
|
||||
{port.host}
|
||||
</a>
|
||||
));
|
||||
}
|
1
app/react/azure/container-instances/ListView/index.ts
Normal file
1
app/react/azure/container-instances/ListView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
8
app/react/azure/container-instances/ListView/types.ts
Normal file
8
app/react/azure/container-instances/ListView/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import {
|
||||
PaginationTableSettings,
|
||||
SortableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
|
||||
export interface TableSettings
|
||||
extends PaginationTableSettings,
|
||||
SortableTableSettings {}
|
47
app/react/azure/queries/query-keys.ts
Normal file
47
app/react/azure/queries/query-keys.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
|
||||
export const queryKeys = {
|
||||
subscriptions: (environmentId: EnvironmentId) =>
|
||||
['azure', environmentId, 'subscriptions'] as const,
|
||||
subscription: (environmentId: EnvironmentId, subscriptionId: string) =>
|
||||
[...queryKeys.subscriptions(environmentId), subscriptionId] as const,
|
||||
resourceGroups: (environmentId: EnvironmentId, subscriptionId: string) =>
|
||||
[
|
||||
...queryKeys.subscription(environmentId, subscriptionId),
|
||||
'resourceGroups',
|
||||
] as const,
|
||||
resourceGroup: (
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string
|
||||
) =>
|
||||
[
|
||||
...queryKeys.resourceGroups(environmentId, subscriptionId),
|
||||
resourceGroupName,
|
||||
] as const,
|
||||
provider: (environmentId: EnvironmentId, subscriptionId: string) =>
|
||||
[
|
||||
...queryKeys.subscription(environmentId, subscriptionId),
|
||||
'provider',
|
||||
] as const,
|
||||
containerGroups: (environmentId: EnvironmentId, subscriptionId: string) =>
|
||||
[
|
||||
...queryKeys.subscription(environmentId, subscriptionId),
|
||||
'containerGroups',
|
||||
] as const,
|
||||
containerGroup: (
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
containerGroupName: string
|
||||
) =>
|
||||
[
|
||||
...queryKeys.resourceGroup(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupName
|
||||
),
|
||||
'containerGroups',
|
||||
containerGroupName,
|
||||
] as const,
|
||||
};
|
59
app/react/azure/queries/useContainerGroup.ts
Normal file
59
app/react/azure/queries/useContainerGroup.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { ContainerGroup } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildContainerGroupUrl } from './utils';
|
||||
|
||||
export function useContainerGroup(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
containerGroupName: string
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.containerGroup(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupName,
|
||||
containerGroupName
|
||||
),
|
||||
() =>
|
||||
getContainerGroup(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupName,
|
||||
containerGroupName
|
||||
),
|
||||
{
|
||||
...withError('Unable to retrieve Azure container group'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getContainerGroup(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
containerGroupName: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<ContainerGroup>(
|
||||
buildContainerGroupUrl(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupName,
|
||||
containerGroupName
|
||||
),
|
||||
{ params: { 'api-version': '2018-04-01' } }
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
59
app/react/azure/queries/useContainerGroups.ts
Normal file
59
app/react/azure/queries/useContainerGroups.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import _ from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
|
||||
import { Subscription, ContainerGroup } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildContainerGroupUrl } from './utils';
|
||||
|
||||
export function useContainerGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[] = [],
|
||||
enabled?: boolean
|
||||
) {
|
||||
const queries = useQueries(
|
||||
useMemo(
|
||||
() =>
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: queryKeys.containerGroups(
|
||||
environmentId,
|
||||
subscription.subscriptionId
|
||||
),
|
||||
queryFn: async () =>
|
||||
getContainerGroups(environmentId, subscription.subscriptionId),
|
||||
...withError('Unable to retrieve Azure container groups'),
|
||||
enabled,
|
||||
})),
|
||||
[subscriptions, enabled, environmentId]
|
||||
)
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
containerGroups: _.flatMap(_.compact(queries.map((q) => q.data))),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
}),
|
||||
[queries]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getContainerGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<{ value: ContainerGroup[] }>(
|
||||
buildContainerGroupUrl(environmentId, subscriptionId),
|
||||
{ params: { 'api-version': '2018-04-01' } }
|
||||
);
|
||||
|
||||
return data.value;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve container groups');
|
||||
}
|
||||
}
|
87
app/react/azure/queries/useProvider.ts
Normal file
87
app/react/azure/queries/useProvider.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import _ from 'lodash';
|
||||
import { useQueries } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { ProviderViewModel, Subscription } from '../types';
|
||||
import { azureErrorParser } from '../services/utils';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useProvider(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[] = []
|
||||
) {
|
||||
const queries = useQueries(
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: queryKeys.provider(environmentId, subscription.subscriptionId),
|
||||
queryFn: async () => {
|
||||
const provider = await getContainerInstanceProvider(
|
||||
environmentId,
|
||||
subscription.subscriptionId
|
||||
);
|
||||
return [subscription.subscriptionId, provider] as const;
|
||||
},
|
||||
...withError('Unable to retrieve Azure providers'),
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
_.compact(
|
||||
queries.map((q) => {
|
||||
if (q.data) {
|
||||
return q.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
)
|
||||
),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
};
|
||||
}
|
||||
|
||||
interface ResourceType {
|
||||
resourceType: 'containerGroups' | string;
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
interface ProviderResponse {
|
||||
id: string;
|
||||
namespace: string;
|
||||
resourceTypes: ResourceType[];
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
function parseViewModel({
|
||||
id,
|
||||
namespace,
|
||||
resourceTypes,
|
||||
}: ProviderResponse): ProviderViewModel {
|
||||
const containerGroupType = _.find(resourceTypes, {
|
||||
resourceType: 'containerGroups',
|
||||
});
|
||||
const { locations = [] } = containerGroupType || {};
|
||||
return { id, namespace, locations };
|
||||
}
|
46
app/react/azure/queries/useResourceGroup.ts
Normal file
46
app/react/azure/queries/useResourceGroup.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { azureErrorParser } from '../services/utils';
|
||||
import { ResourceGroup } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildResourceGroupUrl } from './utils';
|
||||
|
||||
export function useResourceGroup(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.resourceGroup(environmentId, subscriptionId, resourceGroupName),
|
||||
() => getResourceGroup(environmentId, subscriptionId, resourceGroupName),
|
||||
{
|
||||
...withError('Unable to retrieve Azure resource group'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getResourceGroup(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<ResourceGroup>(
|
||||
buildResourceGroupUrl(environmentId, subscriptionId, resourceGroupName),
|
||||
{ params: { 'api-version': '2018-02-01' } }
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
err as Error,
|
||||
'Unable to retrieve resource group',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
72
app/react/azure/queries/useResourceGroups.ts
Normal file
72
app/react/azure/queries/useResourceGroups.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import _ from 'lodash';
|
||||
import { useQueries } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { azureErrorParser } from '../services/utils';
|
||||
import { Subscription, ResourceGroup } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildResourceGroupUrl } from './utils';
|
||||
|
||||
export function useResourceGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[] = []
|
||||
) {
|
||||
const queries = useQueries(
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: queryKeys.resourceGroups(
|
||||
environmentId,
|
||||
subscription.subscriptionId
|
||||
),
|
||||
queryFn: async () => {
|
||||
const groups = await getResourceGroups(
|
||||
environmentId,
|
||||
subscription.subscriptionId
|
||||
);
|
||||
return [subscription.subscriptionId, groups] as const;
|
||||
},
|
||||
...withError('Unable to retrieve Azure resource groups'),
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
resourceGroups: Object.fromEntries(
|
||||
_.compact(
|
||||
queries.map((q) => {
|
||||
if (q.data) {
|
||||
return q.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
)
|
||||
),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
isError: queries.some((q) => q.isError),
|
||||
};
|
||||
}
|
||||
|
||||
async function getResourceGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
try {
|
||||
const {
|
||||
data: { value },
|
||||
} = await axios.get<{ value: ResourceGroup[] }>(
|
||||
buildResourceGroupUrl(environmentId, subscriptionId),
|
||||
{ params: { 'api-version': '2018-02-01' } }
|
||||
);
|
||||
|
||||
return value;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
err as Error,
|
||||
'Unable to retrieve resource groups',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
44
app/react/azure/queries/useSubscription.ts
Normal file
44
app/react/azure/queries/useSubscription.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { Subscription } from '../types';
|
||||
import { azureErrorParser } from '../services/utils';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildSubscriptionsUrl } from './utils';
|
||||
|
||||
export function useSubscription(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.subscription(environmentId, subscriptionId),
|
||||
() => getSubscription(environmentId, subscriptionId),
|
||||
{
|
||||
...withError('Unable to retrieve Azure subscription'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getSubscription(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<Subscription>(
|
||||
buildSubscriptionsUrl(environmentId, subscriptionId),
|
||||
{ params: { 'api-version': '2016-06-01' } }
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve subscription',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
37
app/react/azure/queries/useSubscriptions.ts
Normal file
37
app/react/azure/queries/useSubscriptions.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { azureErrorParser } from '../services/utils';
|
||||
import { Subscription } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildSubscriptionsUrl } from './utils';
|
||||
|
||||
export function useSubscriptions(environmentId: EnvironmentId) {
|
||||
return useQuery(
|
||||
queryKeys.subscriptions(environmentId),
|
||||
() => getSubscriptions(environmentId),
|
||||
{
|
||||
...withError('Unable to retrieve Azure subscriptions'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getSubscriptions(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data } = await axios.get<{ value: Subscription[] }>(
|
||||
buildSubscriptionsUrl(environmentId),
|
||||
{ params: { 'api-version': '2016-06-01' } }
|
||||
);
|
||||
return data.value;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve subscriptions',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
51
app/react/azure/queries/utils.ts
Normal file
51
app/react/azure/queries/utils.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
|
||||
export function buildSubscriptionsUrl(
|
||||
environmentId: EnvironmentId,
|
||||
id?: string
|
||||
) {
|
||||
let url = `/endpoints/${environmentId}/azure/subscriptions`;
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function buildResourceGroupUrl(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName?: string
|
||||
) {
|
||||
let url = `${buildSubscriptionsUrl(
|
||||
environmentId,
|
||||
subscriptionId
|
||||
)}/resourcegroups`;
|
||||
|
||||
if (resourceGroupName) {
|
||||
url += `/${resourceGroupName}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function buildContainerGroupUrl(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName?: string,
|
||||
containerGroupName?: string
|
||||
) {
|
||||
let url = buildSubscriptionsUrl(environmentId, subscriptionId);
|
||||
|
||||
if (resourceGroupName) {
|
||||
url += `/resourceGroups/${resourceGroupName}`;
|
||||
}
|
||||
|
||||
url += `/providers/Microsoft.ContainerInstance/containerGroups`;
|
||||
|
||||
if (containerGroupName) {
|
||||
url += `/${containerGroupName}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
88
app/react/azure/services/container-groups.service.ts
Normal file
88
app/react/azure/services/container-groups.service.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { buildContainerGroupUrl } from '../queries/utils';
|
||||
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>(
|
||||
buildContainerGroupUrl(
|
||||
environmentId,
|
||||
subscriptionId,
|
||||
resourceGroupName,
|
||||
model.name
|
||||
),
|
||||
payload,
|
||||
{ params: { 'api-version': '2018-04-01' } }
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteContainerGroup(
|
||||
environmentId: EnvironmentId,
|
||||
containerGroupId: string
|
||||
) {
|
||||
try {
|
||||
await axios.delete(`/endpoints/${environmentId}/azure${containerGroupId}`, {
|
||||
params: { 'api-version': '2018-04-01' },
|
||||
});
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to remove container group');
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
12
app/react/azure/services/utils.ts
Normal file
12
app/react/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,
|
||||
};
|
||||
}
|
75
app/react/azure/types.ts
Normal file
75
app/react/azure/types.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { AccessControlFormData } from '@/portainer/access-control/types';
|
||||
import { PortainerMetadata } from '@/react/docker/types';
|
||||
|
||||
import { PortMapping } from './container-instances/CreateView/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 Container {
|
||||
name: string;
|
||||
properties: {
|
||||
environmentVariables: unknown[];
|
||||
image: string;
|
||||
ports: { port: number }[];
|
||||
resources: {
|
||||
cpu: number;
|
||||
memoryInGB: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ContainerGroupProperties {
|
||||
containers: (Container | undefined)[];
|
||||
instanceView: {
|
||||
events: unknown[];
|
||||
state: 'pending' | string;
|
||||
};
|
||||
ipAddress: {
|
||||
dnsNameLabelReusePolicy: string;
|
||||
ports: { port: number; protocol: 'TCP' | 'UDP' }[];
|
||||
type: 'Public' | 'Private';
|
||||
ip: string;
|
||||
};
|
||||
osType: OS;
|
||||
}
|
||||
|
||||
export type 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;
|
||||
}
|
||||
|
||||
export interface ProviderViewModel {
|
||||
id: string;
|
||||
namespace: string;
|
||||
locations: string[];
|
||||
}
|
20
app/react/azure/utils.ts
Normal file
20
app/react/azure/utils.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { ContainerGroup } from './types';
|
||||
|
||||
export function getPorts(containerGroup: ContainerGroup) {
|
||||
const addressPorts = containerGroup.properties.ipAddress
|
||||
? containerGroup.properties.ipAddress.ports
|
||||
: [];
|
||||
const container = containerGroup.properties.containers.length
|
||||
? containerGroup.properties.containers[0]
|
||||
: null;
|
||||
const containerPorts = container ? container.properties.ports : [];
|
||||
|
||||
return addressPorts.map((binding, index) => {
|
||||
const port = containerPorts[index] ? containerPorts[index].port : undefined;
|
||||
return {
|
||||
container: port,
|
||||
host: binding.port,
|
||||
protocol: binding.protocol,
|
||||
};
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue