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