1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +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:
Chaim Lev-Ari 2022-07-26 21:44:08 +02:00 committed by GitHub
parent b059641c80
commit 82b848af0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 1723 additions and 1430 deletions

View file

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

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
.item {
display: flex;
flex-direction: column;
position: relative;
}
.item .inputs {
}
.item .errors {
position: absolute;
bottom: -20px;
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
export interface TableSettings
extends PaginationTableSettings,
SortableTableSettings {}

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

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

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

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

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

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

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

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

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

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

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

View file

@ -10,16 +10,20 @@ export interface Crumb {
linkParams?: Record<string, unknown>;
}
interface Props {
breadcrumbs: (Crumb | string)[];
breadcrumbs: (Crumb | string)[] | string;
}
export function Breadcrumbs({ breadcrumbs }: Props) {
const breadcrumbsArray = Array.isArray(breadcrumbs)
? breadcrumbs
: [breadcrumbs];
return (
<div className="breadcrumb-links">
{breadcrumbs.map((crumb, index) => (
{breadcrumbsArray.map((crumb, index) => (
<Fragment key={index}>
{renderCrumb(crumb)}
{index !== breadcrumbs.length - 1 ? ' > ' : ''}
{index !== breadcrumbsArray.length - 1 ? ' > ' : ''}
</Fragment>
))}
</div>

View file

@ -14,7 +14,7 @@ interface Props {
reload?: boolean;
loading?: boolean;
onReload?(): Promise<void> | void;
breadcrumbs?: Crumb[];
breadcrumbs?: (Crumb | string)[] | string;
title: string;
}

View file

@ -5,10 +5,11 @@ import styles from './AddButton.module.css';
export interface Props {
className?: string;
label: string;
disabled?: boolean;
onClick: () => void;
}
export function AddButton({ label, onClick, className }: Props) {
export function AddButton({ label, onClick, className, disabled }: Props) {
return (
<button
className={clsx(
@ -20,6 +21,7 @@ export function AddButton({ label, onClick, className }: Props) {
)}
type="button"
onClick={onClick}
disabled={disabled}
>
<i className="fa fa-plus-circle space-right" aria-hidden="true" /> {label}
</button>

View file

@ -1,13 +1,11 @@
import { PropsWithChildren } from 'react';
import { Icon } from '@/react/components/Icon';
import { Icon, IconProps } from '@@/Icon';
import { useTableContext } from './TableContainer';
interface Props {
icon: string;
interface Props extends IconProps {
label: string;
featherIcon?: boolean;
}
export function TableTitle({

View file

@ -25,7 +25,7 @@ interface UseRowSelectTableInstance<D extends DefaultType = DefaultType>
isAllRowSelected: boolean;
selectSubRows: boolean;
getSubRows(row: Row<D>): Row<D>[];
isRowSelectable(row: Row<D>): boolean;
isRowSelectable?(row: Row<D>): boolean;
}
const pluginName = 'useRowSelect';
@ -73,7 +73,10 @@ function defaultGetToggleRowSelectedProps<D extends DefaultType>(
props: D,
{ instance, row }: { instance: UseRowSelectTableInstance<D>; row: Row<D> }
) {
const { manualRowSelectedKey = 'isSelected' } = instance;
const {
manualRowSelectedKey = 'isSelected',
isRowSelectable = defaultIsRowSelectable,
} = instance;
let checked = false;
if (row.original && row.original[manualRowSelectedKey]) {
@ -94,7 +97,7 @@ function defaultGetToggleRowSelectedProps<D extends DefaultType>(
checked,
title: 'Toggle Row Selected',
indeterminate: row.isSomeSelected,
disabled: !instance.isRowSelectable(row),
disabled: !isRowSelectable(row),
},
];
}
@ -317,7 +320,7 @@ function useInstance<D extends Record<string, unknown>>(
dispatch,
page,
getSubRows,
isRowSelectable,
isRowSelectable = defaultIsRowSelectable,
} = instance;
ensurePluginOrder(
@ -474,5 +477,5 @@ function getRowIsSelected<D extends Record<string, unknown>>(
}
function defaultIsRowSelectable<D extends DefaultType>(row: Row<D>) {
return !!row.original.disabled;
return !row.original.disabled;
}

View file

@ -15,6 +15,8 @@ interface Props<T> {
onChange(value: T): void;
options: Option<T>[];
size?: Size;
disabled?: boolean;
readOnly?: boolean;
}
export function ButtonSelector<T extends string | number>({
@ -22,6 +24,8 @@ export function ButtonSelector<T extends string | number>({
onChange,
size,
options,
disabled,
readOnly,
}: Props<T>) {
return (
<ButtonGroup size={size} className={styles.group}>
@ -30,6 +34,8 @@ export function ButtonSelector<T extends string | number>({
key={option.value}
selected={value === option.value}
onChange={() => onChange(option.value)}
disabled={disabled}
readOnly={readOnly}
>
{option.label || option.value.toString()}
</OptionItem>
@ -41,17 +47,32 @@ export function ButtonSelector<T extends string | number>({
interface OptionItemProps {
selected: boolean;
onChange(): void;
disabled?: boolean;
readOnly?: boolean;
}
function OptionItem({
selected,
children,
onChange,
disabled,
readOnly,
}: PropsWithChildren<OptionItemProps>) {
return (
<label className={clsx('btn btn-primary', { active: selected })}>
<label
className={clsx('btn btn-primary', {
active: selected,
disabled: readOnly || disabled,
})}
>
{children}
<input type="radio" checked={selected} onChange={onChange} />
<input
type="radio"
checked={selected}
onChange={onChange}
disabled={disabled}
readOnly={readOnly}
/>
</label>
);
}

View file

@ -1,5 +1,6 @@
import { ComponentType } from 'react';
import clsx from 'clsx';
import { FormikErrors } from 'formik';
import { AddButton, Button } from '@@/buttons';
import { Tooltip } from '@@/Tip/Tooltip';
@ -11,12 +12,12 @@ import { FormError } from '../FormError';
import styles from './InputList.module.css';
import { arrayMove } from './utils';
export type InputListError<T> = Record<keyof T, string>;
export interface ItemProps<T> {
item: T;
onChange(value: T): void;
error?: InputListError<T>;
error?: string | FormikErrors<T>;
disabled?: boolean;
readOnly?: boolean;
}
type Key = string | number;
type ChangeType = 'delete' | 'create' | 'update';
@ -36,7 +37,7 @@ type OnChangeEvent<T> =
type RenderItemFunction<T> = (
item: T,
onChange: (value: T) => void,
error?: InputListError<T>
error?: string | FormikErrors<T>
) => React.ReactNode;
interface Props<T> {
@ -50,9 +51,11 @@ interface Props<T> {
addLabel?: string;
itemKeyGetter?(item: T, index: number): Key;
movable?: boolean;
errors?: InputListError<T>[] | string;
errors?: FormikErrors<T>[] | string | string[];
textTip?: string;
isAddButtonHidden?: boolean;
disabled?: boolean;
readOnly?: boolean;
}
export function InputList<T = DefaultType>({
@ -69,6 +72,8 @@ export function InputList<T = DefaultType>({
errors,
textTip,
isAddButtonHidden = false,
disabled,
readOnly,
}: Props<T>) {
return (
<div className={clsx('form-group', styles.root)}>
@ -77,11 +82,12 @@ export function InputList<T = DefaultType>({
{label}
{tooltip && <Tooltip message={tooltip} />}
</div>
{!isAddButtonHidden && (
{!(isAddButtonHidden || readOnly) && (
<AddButton
label={addLabel}
className="space-left"
onClick={handleAdd}
disabled={disabled}
/>
)}
</div>
@ -107,6 +113,8 @@ export function InputList<T = DefaultType>({
item={item}
onChange={(value: T) => handleChangeItem(key, value)}
error={error}
disabled={disabled}
readOnly={readOnly}
/>
) : (
renderItem(
@ -116,11 +124,11 @@ export function InputList<T = DefaultType>({
)
)}
<div className={clsx(styles.itemActions, 'items-start')}>
{movable && (
{!readOnly && movable && (
<>
<Button
size="small"
disabled={index === 0}
disabled={disabled || index === 0}
onClick={() => handleMoveUp(index)}
>
<i className="fa fa-arrow-up" aria-hidden="true" />
@ -128,20 +136,23 @@ export function InputList<T = DefaultType>({
<Button
size="small"
type="button"
disabled={index === value.length - 1}
disabled={disabled || index === value.length - 1}
onClick={() => handleMoveDown(index)}
>
<i className="fa fa-arrow-down" aria-hidden="true" />
</Button>
</>
)}
<Button
color="danger"
size="small"
onClick={() => handleRemoveItem(key, item)}
>
<i className="fa fa-trash" aria-hidden="true" />
</Button>
{!readOnly && (
<Button
color="danger"
size="small"
onClick={() => handleRemoveItem(key, item)}
disabled={disabled}
>
<i className="fa fa-trash" aria-hidden="true" />
</Button>
)}
</div>
</div>
);
@ -210,13 +221,21 @@ function defaultItemBuilder(): DefaultType {
return { value: '' };
}
function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
function DefaultItem({
item,
onChange,
error,
disabled,
readOnly,
}: ItemProps<DefaultType>) {
return (
<>
<Input
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
className={styles.defaultItem}
disabled={disabled}
readOnly={readOnly}
/>
{error && <FormError>{error}</FormError>}
</>
@ -226,7 +245,7 @@ function DefaultItem({ item, onChange, error }: ItemProps<DefaultType>) {
function renderDefaultItem(
item: DefaultType,
onChange: (value: DefaultType) => void,
error?: InputListError<DefaultType>
error?: FormikErrors<DefaultType>
) {
return <DefaultItem item={item} onChange={onChange} error={error} />;
}

View file

@ -1,10 +1,9 @@
import { FormikErrors } from 'formik';
import { FormError } from '@@/form-components/FormError';
import { Input } from '@@/form-components/Input';
import { InputList } from '@@/form-components/InputList';
import {
InputListError,
ItemProps,
} from '@@/form-components/InputList/InputList';
import { ItemProps } from '@@/form-components/InputList/InputList';
export interface VariableDefinition {
name: string;
@ -16,7 +15,7 @@ export interface VariableDefinition {
interface Props {
value: VariableDefinition[];
onChange: (value: VariableDefinition[]) => void;
errors?: InputListError<VariableDefinition>[] | string;
errors?: FormikErrors<VariableDefinition>[];
isVariablesNamesFromParent?: boolean;
}
@ -57,6 +56,8 @@ interface DefinitionItemProps extends ItemProps<VariableDefinition> {
}
function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
const errorObj = typeof error === 'object' ? error : {};
return (
<div className="flex gap-2">
<div>
@ -67,7 +68,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
placeholder="Name (e.g var_name)"
readOnly={isNameReadonly}
/>
{error?.name && <FormError>{error.name}</FormError>}
{errorObj?.name && <FormError>{errorObj.name}</FormError>}
</div>
<div>
<Input
@ -76,7 +77,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
placeholder="Label"
name="label"
/>
{error?.label && <FormError>{error.label}</FormError>}
{errorObj?.label && <FormError>{errorObj.label}</FormError>}
</div>
<div>
<Input
@ -85,7 +86,7 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
onChange={handleChange}
placeholder="Description"
/>
{error?.description && <FormError>{error.description}</FormError>}
{errorObj?.description && <FormError>{errorObj.description}</FormError>}
</div>
<div>
<Input
@ -94,7 +95,9 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
placeholder="Default Value"
name="defaultValue"
/>
{error?.defaultValue && <FormError>{error.defaultValue}</FormError>}
{errorObj?.defaultValue && (
<FormError>{errorObj.defaultValue}</FormError>
)}
</div>
</div>
);