1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

refactor(docker): move components to react [EE-3348] (#7084)

This commit is contained in:
Chaim Lev-Ari 2022-06-26 17:16:50 +03:00 committed by GitHub
parent 7238372d8d
commit 46e1a01625
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 61 additions and 76 deletions

View file

@ -0,0 +1,138 @@
import { useState, useEffect } from 'react';
import { useRouter, useCurrentStateAndParams } from '@uirouter/react';
import { useQueryClient } from 'react-query';
import _ from 'lodash';
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
import { ResourceControlType } from '@/portainer/access-control/types';
import { DockerContainer } from '@/react/docker/containers/types';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { PageHeader } from '@@/PageHeader';
import { useNetwork, useDeleteNetwork } from '../queries';
import { isSystemNetwork } from '../network.helper';
import { DockerNetwork, NetworkContainer } from '../types';
import { useContainers } from '../../containers/queries';
import { NetworkDetailsTable } from './NetworkDetailsTable';
import { NetworkOptionsTable } from './NetworkOptionsTable';
import { NetworkContainersTable } from './NetworkContainersTable';
export function ItemView() {
const router = useRouter();
const queryClient = useQueryClient();
const [networkContainers, setNetworkContainers] = useState<
NetworkContainer[]
>([]);
const {
params: { id: networkId, nodeName },
} = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const networkQuery = useNetwork(environmentId, networkId);
const deleteNetworkMutation = useDeleteNetwork();
const filters = {
network: [networkId],
};
const containersQuery = useContainers(environmentId, filters);
useEffect(() => {
if (networkQuery.data && containersQuery.data) {
setNetworkContainers(
filterContainersInNetwork(networkQuery.data, containersQuery.data)
);
}
}, [networkQuery.data, containersQuery.data]);
if (!networkQuery.data) {
return null;
}
const network = networkQuery.data;
const resourceControl = network.Portainer?.ResourceControl
? new ResourceControlViewModel(network.Portainer.ResourceControl)
: undefined;
return (
<>
<PageHeader
title="Network details"
breadcrumbs={[
{ link: 'docker.networks', label: 'Networks' },
{
link: 'docker.networks.network',
label: networkQuery.data.Name,
},
]}
/>
<NetworkDetailsTable
network={networkQuery.data}
onRemoveNetworkClicked={onRemoveNetworkClicked}
/>
<AccessControlPanel
onUpdateSuccess={() =>
queryClient.invalidateQueries([
'environments',
environmentId,
'docker',
'networks',
networkId,
])
}
resourceControl={resourceControl}
resourceType={ResourceControlType.Network}
disableOwnershipChange={isSystemNetwork(networkQuery.data.Name)}
resourceId={networkId}
/>
<NetworkOptionsTable options={networkQuery.data.Options} />
<NetworkContainersTable
networkContainers={networkContainers}
nodeName={nodeName}
environmentId={environmentId}
networkId={networkId}
/>
</>
);
async function onRemoveNetworkClicked() {
const message = 'Do you want to delete the network?';
const confirmed = await confirmDeletionAsync(message);
if (confirmed) {
deleteNetworkMutation.mutate(
{ environmentId, networkId },
{
onSuccess: () => {
router.stateService.go('docker.networks');
},
}
);
}
}
function filterContainersInNetwork(
network: DockerNetwork,
containers: DockerContainer[]
) {
const containersInNetwork = _.compact(
containers.map((container) => {
const containerInNetworkResponse = network.Containers[container.Id];
if (containerInNetworkResponse) {
const containerInNetwork: NetworkContainer = {
...containerInNetworkResponse,
Id: container.Id,
};
return containerInNetwork;
}
return null;
})
);
return containersInNetwork;
}
}

View file

@ -0,0 +1,52 @@
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { NetworkContainer } from '../types';
import { NetworkContainersTable } from './NetworkContainersTable';
const networkContainers: NetworkContainer[] = [
{
EndpointID:
'069d703f3ff4939956233137c4c6270d7d46c04fb10c44d3ec31fde1b46d6610',
IPv4Address: '10.0.1.3/24',
IPv6Address: '',
MacAddress: '02:42:0a:00:01:03',
Name: 'portainer-agent_agent.8hjjodl4hoyhuq1kscmzccyqn.wnv2pp17f8ayeopke2z56yw5x',
Id: 'd54c74b7e1c5649d2a880d3fc02c6201d1d2f85a4fee718f978ec8b147239295',
},
];
jest.mock('@uirouter/react', () => ({
...jest.requireActual('@uirouter/react'),
useCurrentStateAndParams: jest.fn(() => ({
params: { endpointId: 1 },
})),
}));
test('Network container values should be visible and the link should be valid', async () => {
const user = new UserViewModel({ Username: 'test', Role: 1 });
const { findByText } = renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<NetworkContainersTable
networkContainers={networkContainers}
nodeName=""
environmentId={1}
networkId="pc8xc9s6ot043vl1q5iz4zhfs"
/>
</UserContext.Provider>
);
await expect(findByText('Containers in network')).resolves.toBeVisible();
await expect(findByText(networkContainers[0].Name)).resolves.toBeVisible();
await expect(
findByText(networkContainers[0].IPv4Address)
).resolves.toBeVisible();
await expect(
findByText(networkContainers[0].MacAddress)
).resolves.toBeVisible();
await expect(
findByText('Leave network', { exact: false })
).resolves.toBeVisible();
});

View file

@ -0,0 +1,98 @@
import { Authorized } from '@/portainer/hooks/useUser';
import { EnvironmentId } from '@/portainer/environments/types';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { DetailsTable } from '@@/DetailsTable';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { NetworkContainer, NetworkId } from '../types';
import { useDisconnectContainer } from '../queries';
type Props = {
networkContainers: NetworkContainer[];
nodeName: string;
environmentId: EnvironmentId;
networkId: NetworkId;
};
const tableHeaders = [
'Container Name',
'IPv4 Address',
'IPv6 Address',
'MacAddress',
'Actions',
];
export function NetworkContainersTable({
networkContainers,
nodeName,
environmentId,
networkId,
}: Props) {
const disconnectContainer = useDisconnectContainer();
if (networkContainers.length === 0) {
return null;
}
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<Widget>
<WidgetTitle title="Containers in network" icon="fa-server" />
<WidgetBody className="nopadding">
<DetailsTable
headers={tableHeaders}
dataCy="networkDetails-networkContainers"
>
{networkContainers.map((container) => (
<tr key={container.Id}>
<td>
<Link
to="docker.containers.container"
params={{
id: container.Id,
nodeName,
}}
title={container.Name}
>
{container.Name}
</Link>
</td>
<td>{container.IPv4Address || '-'}</td>
<td>{container.IPv6Address || '-'}</td>
<td>{container.MacAddress || '-'}</td>
<td>
<Authorized authorizations="DockerNetworkDisconnect">
<Button
data-cy={`networkDetails-disconnect${container.Name}`}
size="xsmall"
color="danger"
onClick={() => {
if (container.Id) {
disconnectContainer.mutate({
containerId: container.Id,
environmentId,
networkId,
});
}
}}
>
<i
className="fa fa-trash-alt space-right"
aria-hidden="true"
/>
Leave Network
</Button>
</Authorized>
</td>
</tr>
))}
</DetailsTable>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { render } from '@/react-tools/test-utils';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { DockerNetwork } from '../types';
import { NetworkDetailsTable } from './NetworkDetailsTable';
jest.mock('@uirouter/react', () => ({
...jest.requireActual('@uirouter/react'),
useCurrentStateAndParams: jest.fn(() => ({
params: { endpointId: 1 },
})),
}));
test('Network details values should be visible', async () => {
const network = getNetwork('test');
const { findByText } = await renderComponent(true, network);
await expect(findByText(network.Name)).resolves.toBeVisible();
await expect(findByText(network.Id)).resolves.toBeVisible();
await expect(findByText(network.Driver)).resolves.toBeVisible();
await expect(findByText(network.Scope)).resolves.toBeVisible();
await expect(
findByText(network.IPAM?.Config[0].Gateway || 'not found', { exact: false })
).resolves.toBeVisible();
await expect(
findByText(network.IPAM?.Config[0].Subnet || 'not found', { exact: false })
).resolves.toBeVisible();
});
test(`System networks shouldn't show a delete button`, async () => {
const systemNetwork = getNetwork('bridge');
const { queryByText } = await renderComponent(true, systemNetwork);
const deleteButton = queryByText('Delete this network');
expect(deleteButton).toBeNull();
});
test('Non system networks should have a delete button', async () => {
const nonSystemNetwork = getNetwork('non system network');
const { queryByText } = await renderComponent(true, nonSystemNetwork);
const button = queryByText('Delete this network');
expect(button).toBeVisible();
});
async function renderComponent(isAdmin: boolean, network: DockerNetwork) {
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
const queries = render(
<UserContext.Provider value={{ user }}>
<NetworkDetailsTable
network={network}
onRemoveNetworkClicked={() => {}}
/>
</UserContext.Provider>
);
await expect(queries.findByText('Network details')).resolves.toBeVisible();
return queries;
}
function getNetwork(networkName: string): DockerNetwork {
return {
Attachable: false,
Containers: {
a761fcafdae3bdae42cf3702c8554b3e1b0334f85dd6b65b3584aff7246279e4: {
EndpointID:
'404afa6e25cede7c0fd70180777b662249cd83e40fa9a41aa593d2bac0fc5e18',
IPv4Address: '172.17.0.2/16',
IPv6Address: '',
MacAddress: '02:42:ac:11:00:02',
Name: 'portainer',
},
},
Driver: 'bridge',
IPAM: {
Config: [
{
Gateway: '172.17.0.1',
Subnet: '172.17.0.0/16',
},
],
Driver: 'default',
Options: null,
},
Id: '4c52a72e3772fdfb5823cf519b759e3f716e6d98cfb3bfef056e32c9c878329f',
Internal: false,
Name: networkName,
Options: {
'com.docker.network.bridge.default_bridge': 'true',
'com.docker.network.bridge.enable_icc': 'true',
'com.docker.network.bridge.enable_ip_masquerade': 'true',
'com.docker.network.bridge.host_binding_ipv4': '0.0.0.0',
'com.docker.network.bridge.name': 'docker0',
'com.docker.network.driver.mtu': '1500',
},
Portainer: {
ResourceControl: {
Id: 41,
ResourceId:
'85d807847e4a4adb374a2a105124eda607ef584bef2eb6acf8091f3afd8446db',
Type: 4,
UserAccesses: [
{
UserId: 2,
AccessLevel: 1,
},
],
TeamAccesses: [],
Public: true,
System: false,
AdministratorsOnly: true,
},
},
Scope: 'local',
};
}

View file

@ -0,0 +1,120 @@
import { Fragment } from 'react';
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
import { Authorized } from '@/portainer/hooks/useUser';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { DetailsTable } from '@@/DetailsTable';
import { Button } from '@@/buttons';
import { isSystemNetwork } from '../network.helper';
import { DockerNetwork, IPConfig } from '../types';
interface Props {
network: DockerNetwork;
onRemoveNetworkClicked: () => void;
}
export function NetworkDetailsTable({
network,
onRemoveNetworkClicked,
}: Props) {
const allowRemoveNetwork = !isSystemNetwork(network.Name);
const ipv4Configs: IPConfig[] = DockerNetworkHelper.getIPV4Configs(
network.IPAM?.Config
);
const ipv6Configs: IPConfig[] = DockerNetworkHelper.getIPV6Configs(
network.IPAM?.Config
);
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<Widget>
<WidgetTitle title="Network details" icon="fa-sitemap" />
<WidgetBody className="nopadding">
<DetailsTable dataCy="networkDetails-detailsTable">
{/* networkRowContent */}
<DetailsTable.Row label="Name">{network.Name}</DetailsTable.Row>
<DetailsTable.Row label="Id">
{network.Id}
{allowRemoveNetwork && (
<Authorized authorizations="DockerNetworkDelete">
<Button
data-cy="networkDetails-deleteNetwork"
size="xsmall"
color="danger"
onClick={() => onRemoveNetworkClicked()}
>
<i
className="fa fa-trash-alt space-right"
aria-hidden="true"
/>
Delete this network
</Button>
</Authorized>
)}
</DetailsTable.Row>
<DetailsTable.Row label="Driver">
{network.Driver}
</DetailsTable.Row>
<DetailsTable.Row label="Scope">{network.Scope}</DetailsTable.Row>
<DetailsTable.Row label="Attachable">
{String(network.Attachable)}
</DetailsTable.Row>
<DetailsTable.Row label="Internal">
{String(network.Internal)}
</DetailsTable.Row>
{/* IPV4 ConfigRowContent */}
{ipv4Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV4 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV4 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV4 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV4 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
{/* IPV6 ConfigRowContent */}
{ipv6Configs.map((config) => (
<Fragment key={config.Subnet}>
<DetailsTable.Row
label={`IPV6 Subnet${getConfigDetails(config.Subnet)}`}
>
{`IPV6 Gateway${getConfigDetails(config.Gateway)}`}
</DetailsTable.Row>
<DetailsTable.Row
label={`IPV6 IP Range${getConfigDetails(config.IPRange)}`}
>
{`IPV6 Excluded IPs${getAuxiliaryAddresses(
config.AuxiliaryAddresses
)}`}
</DetailsTable.Row>
</Fragment>
))}
</DetailsTable>
</WidgetBody>
</Widget>
</div>
</div>
);
function getConfigDetails(configValue?: string) {
return configValue ? ` - ${configValue}` : '';
}
function getAuxiliaryAddresses(auxiliaryAddresses?: object) {
return auxiliaryAddresses
? ` - ${Object.values(auxiliaryAddresses).join(' - ')}`
: '';
}
}

View file

@ -0,0 +1,34 @@
import { render } from '@/react-tools/test-utils';
import { NetworkOptions } from '../types';
import { NetworkOptionsTable } from './NetworkOptionsTable';
const options: NetworkOptions = {
'com.docker.network.bridge.default_bridge': 'true',
'com.docker.network.bridge.enable_icc': 'true',
'com.docker.network.bridge.enable_ip_masquerade': 'true',
'com.docker.network.bridge.host_binding_ipv4': '0.0.0.0',
'com.docker.network.bridge.name': 'docker0',
'com.docker.network.driver.mtu': '1500',
};
test('Network options values should be visible', async () => {
const { findByText, findAllByText } = render(
<NetworkOptionsTable options={options} />
);
await expect(findByText('Network options')).resolves.toBeVisible();
// expect to find three 'true' values for the first 3 options
const cells = await findAllByText('true');
expect(cells).toHaveLength(3);
await expect(
findByText(options['com.docker.network.bridge.host_binding_ipv4'])
).resolves.toBeVisible();
await expect(
findByText(options['com.docker.network.bridge.name'])
).resolves.toBeVisible();
await expect(
findByText(options['com.docker.network.driver.mtu'])
).resolves.toBeVisible();
});

View file

@ -0,0 +1,35 @@
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { DetailsTable } from '@@/DetailsTable';
import { NetworkOptions } from '../types';
type Props = {
options: NetworkOptions;
};
export function NetworkOptionsTable({ options }: Props) {
const networkEntries = Object.entries(options);
if (networkEntries.length === 0) {
return null;
}
return (
<div className="row">
<div className="col-lg-12 col-md-12 col-xs-12">
<Widget>
<WidgetTitle title="Network options" icon="fa-cogs" />
<WidgetBody className="nopadding">
<DetailsTable dataCy="networkDetails-networkOptionsTable">
{networkEntries.map(([key, value]) => (
<DetailsTable.Row key={key} label={key}>
{value}
</DetailsTable.Row>
))}
</DetailsTable>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View file

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