refactor(docker networks): migrate docker network detail view to react EE-2196 (#6700)

* Migrate network details to react
pull/6907/head
Ali 2022-05-10 09:01:15 +12:00 committed by GitHub
parent 9650aa56c7
commit 2e0555dbca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 981 additions and 261 deletions

View File

@ -3,8 +3,9 @@ import angular from 'angular';
import { EnvironmentStatus } from '@/portainer/environments/types';
import containersModule from './containers';
import { componentsModule } from './components';
import { networksModule } from './networks';
angular.module('portainer.docker', ['portainer.app', containersModule, componentsModule]).config([
angular.module('portainer.docker', ['portainer.app', containersModule, componentsModule, networksModule]).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
@ -323,8 +324,7 @@ angular.module('portainer.docker', ['portainer.app', containersModule, component
url: '/:id?nodeName',
views: {
'content@': {
templateUrl: './views/networks/edit/network.html',
controller: 'NetworkController',
component: 'networkDetailsView',
},
},
};

View File

@ -2,10 +2,16 @@ import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { NetworkId } from '../networks/types';
import { genericHandler } from '../rest/response/handlers';
import { ContainerId, DockerContainer } from './types';
export interface Filters {
label?: string[];
network?: NetworkId[];
}
export async function startContainer(
endpointId: EnvironmentId,
id: ContainerId
@ -86,15 +92,37 @@ export async function removeContainer(
}
}
export async function getContainers(
environmentId: EnvironmentId,
filters?: Filters
) {
try {
const { data } = await axios.get<DockerContainer[]>(
urlBuilder(environmentId, '', 'json'),
{
params: { all: 0, filters },
}
);
return data;
} catch (e) {
throw new PortainerError('Unable to retrieve containers', e as Error);
}
}
function urlBuilder(
endpointId: EnvironmentId,
id: ContainerId,
id?: ContainerId,
action?: string
) {
const url = `/endpoints/${endpointId}/docker/containers/${id}`;
let url = `/endpoints/${endpointId}/docker/containers`;
if (id) {
url += `/${id}`;
}
if (action) {
return `${url}/${action}`;
url += `/${action}`;
}
return url;

View File

@ -0,0 +1,18 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import { getContainers, Filters } from './containers.service';
export function useContainers(environmentId: EnvironmentId, filters?: Filters) {
return useQuery(
['environments', environmentId, 'docker', 'containers', { filters }],
() => getContainers(environmentId, filters),
{
meta: {
title: 'Failure',
message: 'Unable to get containers in network',
},
}
);
}

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,97 @@
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { DetailsTable } from '@/portainer/components/DetailsTable';
import { Button } from '@/portainer/components/Button';
import { Authorized } from '@/portainer/hooks/useUser';
import { EnvironmentId } from '@/portainer/environments/types';
import { Link } from '@/portainer/components/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
dataCy={`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,123 @@
import { render } from '@/react-tools/test-utils';
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { ResourceControlOwnership } from '@/portainer/access-control/types';
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: [],
Ownership: ResourceControlOwnership.PUBLIC,
Public: true,
System: false,
},
},
Scope: 'local',
};
}

View File

@ -0,0 +1,119 @@
import { Fragment } from 'react';
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { DetailsTable } from '@/portainer/components/DetailsTable';
import { Button } from '@/portainer/components/Button';
import { Authorized } from '@/portainer/hooks/useUser';
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
dataCy="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,130 @@
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 { PageHeader } from '@/portainer/components/PageHeader';
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 '@/docker/containers/types';
import { useNetwork, useDeleteNetwork } from '../queries';
import { isSystemNetwork } from '../network.helper';
import { useContainers } from '../../containers/queries';
import { DockerNetwork, NetworkContainer } from '../types';
import { NetworkDetailsTable } from './NetworkDetailsTable';
import { NetworkOptionsTable } from './NetworkOptionsTable';
import { NetworkContainersTable } from './NetworkContainersTable';
export function NetworkDetailsView() {
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;
}
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={networkQuery.data.Portainer?.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,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 '@/portainer/components/widget';
import { DetailsTable } from '@/portainer/components/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,5 @@
import { react2angular } from '@/react-tools/react2angular';
import { NetworkDetailsView } from './NetworkDetailsView';
export const NetworkDetailsViewAngular = react2angular(NetworkDetailsView, []);

View File

@ -0,0 +1,7 @@
import angular from 'angular';
import { NetworkDetailsViewAngular } from './edit';
export const networksModule = angular
.module('portainer.docker.networks', [])
.component('networkDetailsView', NetworkDetailsViewAngular).name;

View File

@ -0,0 +1,5 @@
const systemNetworks = ['host', 'bridge', 'none'];
export function isSystemNetwork(networkName: string) {
return systemNetworks.includes(networkName);
}

View File

@ -0,0 +1,71 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import { ContainerId } from '../containers/types';
import { NetworkId, DockerNetwork } from './types';
type NetworkAction = 'connect' | 'disconnect' | 'create';
export async function getNetwork(
environmentId: EnvironmentId,
networkId: NetworkId
) {
try {
const { data: network } = await axios.get<DockerNetwork>(
buildUrl(environmentId, networkId)
);
return network;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve network details');
}
}
export async function deleteNetwork(
environmentId: EnvironmentId,
networkId: NetworkId
) {
try {
await axios.delete(buildUrl(environmentId, networkId));
return networkId;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to remove network');
}
}
export async function disconnectContainer(
environmentId: EnvironmentId,
networkId: NetworkId,
containerId: ContainerId
) {
try {
await axios.post(buildUrl(environmentId, networkId, 'disconnect'), {
Container: containerId,
Force: false,
});
return { networkId, environmentId };
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to disconnect container from network'
);
}
}
function buildUrl(
environmentId: EnvironmentId,
networkId?: NetworkId,
action?: NetworkAction
) {
let url = `endpoints/${environmentId}/docker/networks`;
if (networkId) {
url += `/${networkId}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View File

@ -0,0 +1,83 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import {
error as notifyError,
success as notifySuccess,
} from '@/portainer/services/notifications';
import { ContainerId } from '../containers/types';
import {
getNetwork,
deleteNetwork,
disconnectContainer,
} from './network.service';
import { NetworkId } from './types';
export function useNetwork(environmentId: EnvironmentId, networkId: NetworkId) {
return useQuery(
['environments', environmentId, 'docker', 'networks', networkId],
() => getNetwork(environmentId, networkId),
{
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get network');
},
}
);
}
export function useDeleteNetwork() {
return useMutation(
({
environmentId,
networkId,
}: {
environmentId: EnvironmentId;
networkId: NetworkId;
}) => deleteNetwork(environmentId, networkId),
{
onSuccess: (networkId) => {
notifySuccess('Network successfully removed', networkId);
},
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to remove network');
},
}
);
}
export function useDisconnectContainer() {
const client = useQueryClient();
return useMutation(
({
containerId,
environmentId,
networkId,
}: {
containerId: ContainerId;
environmentId: EnvironmentId;
networkId: NetworkId;
}) => disconnectContainer(environmentId, networkId, containerId),
{
onSuccess: ({ networkId, environmentId }) => {
notifySuccess('Container successfully disconnected', networkId);
return client.invalidateQueries([
'environments',
environmentId,
'docker',
'networks',
networkId,
]);
},
onError: (err) => {
notifyError(
'Failure',
err as Error,
'Unable to disconnect container from network'
);
},
}
);
}

View File

@ -0,0 +1,50 @@
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { ContainerId } from '../containers/types';
export type IPConfig = {
Subnet: string;
Gateway: string;
IPRange?: string;
AuxiliaryAddresses?: Record<string, string>;
};
export type NetworkId = string;
export type NetworkOptions = Record<string, string>;
type IpamOptions = Record<string, string> | null;
export type NetworkResponseContainer = {
EndpointID: string;
IPv4Address: string;
IPv6Address: string;
MacAddress: string;
Name: string;
};
export interface NetworkContainer extends NetworkResponseContainer {
Id: ContainerId;
}
export type NetworkResponseContainers = Record<
ContainerId,
NetworkResponseContainer
>;
export interface DockerNetwork {
Name: string;
Id: NetworkId;
Driver: string;
Scope: string;
Attachable: boolean;
Internal: boolean;
IPAM: {
Config: IPConfig[];
Driver: string;
Options: IpamOptions;
};
Portainer: { ResourceControl?: ResourceControlViewModel };
Options: NetworkOptions;
Containers: NetworkResponseContainers;
}

View File

@ -1,133 +0,0 @@
<rd-header>
<rd-header-title title-text="Network details"></rd-header-title>
<rd-header-content>
<a ui-sref="docker.networks">Networks</a> &gt; <a ui-sref="docker.networks.network({id: network.Id})">{{ network.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-sitemap" title-text="Network details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>{{ network.Name }}</td>
</tr>
<tr>
<td>ID</td>
<td>
{{ network.Id }}
<button authorization="DockerNetworkDelete" ng-if="allowRemove()" class="btn btn-xs btn-danger" ng-click="removeNetwork(network.Id)"
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this network</button
>
</td>
</tr>
<tr>
<td>Driver</td>
<td>{{ network.Driver }}</td>
</tr>
<tr>
<td>Scope</td>
<td>{{ network.Scope }}</td>
</tr>
<tr>
<td>Attachable</td>
<td>{{ network.Attachable }}</td>
</tr>
<tr>
<td>Internal</td>
<td>{{ network.Internal }}</td>
</tr>
<tr ng-if="network.IPAM.IPV4Configs.length > 0" ng-repeat-start="config in network.IPAM.IPV4Configs">
<td>IPV4 Subnet - {{ config.Subnet }}</td>
<td>IPV4 Gateway - {{ config.Gateway }}</td>
</tr>
<tr ng-if="network.IPAM.IPV4Configs.length > 0" ng-repeat-end>
<td>IPV4 IP range - {{ config.IPRange }}</td>
<td
>IPV4 Excluded Ips<span ng-repeat="auxAddress in config.AuxiliaryAddresses"> - {{ auxAddress }}</span></td
>
</tr>
<tr ng-if="network.IPAM.IPV6Configs.length > 0" ng-repeat-start="config in network.IPAM.IPV6Configs">
<td>IPV6 Subnet - {{ config.Subnet }}</td>
<td>IPV6 Gateway - {{ config.Gateway }}</td>
</tr>
<tr ng-if="network.IPAM.IPV6Configs.length > 0" ng-repeat-end>
<td>IPV6 IP range - {{ config.IPRange }}</td>
<td
>IPV6 Excluded Ips<span ng-repeat="auxAddress in config.AuxiliaryAddresses"> - {{ auxAddress }}</span></td
>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- access-control-panel -->
<access-control-panel
ng-if="network"
resource-id="network.Id"
resource-control="network.ResourceControl"
resource-type="resourceType"
disable-ownership-change="isSystemNetwork()"
on-update-success="(onUpdateResourceControlSuccess)"
>
</access-control-panel>
<!-- !access-control-panel -->
<div class="row" ng-if="!(network.Options | emptyobject)">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-cogs" title-text="Network options"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr ng-repeat="(key, value) in network.Options">
<td>{{ key }}</td>
<td>{{ value }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="containersInNetwork.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-server" title-text="Containers in network"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<th>Container Name</th>
<th>IPv4 Address</th>
<th>IPv6 Address</th>
<th>MacAddress</th>
<th authorization="DockerNetworkDisconnect">Actions</th>
</thead>
<tbody>
<tr ng-repeat="container in containersInNetwork">
<td
><a ui-sref="docker.containers.container({ id: container.Id, nodeName: nodeName })">{{ container.Name }}</a></td
>
<td>{{ container.IPv4Address || '-' }}</td>
<td>{{ container.IPv6Address || '-' }}</td>
<td>{{ container.MacAddress || '-' }}</td>
<td authorization="DockerNetworkDisconnect">
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(network, container)"
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Leave Network</button
>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -1,120 +0,0 @@
import { ResourceControlType } from '@/portainer/access-control/types';
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
angular.module('portainer.docker').controller('NetworkController', [
'$scope',
'$state',
'$transition$',
'$filter',
'NetworkService',
'Container',
'Notifications',
'HttpRequestHelper',
'NetworkHelper',
function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, NetworkHelper) {
$scope.resourceType = ResourceControlType.Network;
$scope.onUpdateResourceControlSuccess = function () {
$state.reload();
};
$scope.removeNetwork = function removeNetwork() {
NetworkService.remove($transition$.params().id, $transition$.params().id)
.then(function success() {
Notifications.success('Network removed', $transition$.params().id);
$state.go('docker.networks', {});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove network');
});
};
$scope.containerLeaveNetwork = function containerLeaveNetwork(network, container) {
HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
NetworkService.disconnectContainer($transition$.params().id, container.Id, false)
.then(function success() {
Notifications.success('Container left network', $transition$.params().id);
$state.go('docker.networks.network', { id: network.Id }, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to disconnect container from network');
});
};
$scope.isSystemNetwork = function () {
return $scope.network && NetworkHelper.isSystemNetwork($scope.network);
};
$scope.allowRemove = function () {
return !$scope.isSystemNetwork();
};
function filterContainersInNetwork(network, containers) {
var containersInNetwork = [];
containers.forEach(function (container) {
var containerInNetwork = network.Containers[container.Id];
if (containerInNetwork) {
containerInNetwork.Id = container.Id;
// Name is not available in Docker 1.9
if (!containerInNetwork.Name) {
containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]);
}
containersInNetwork.push(containerInNetwork);
}
});
$scope.containersInNetwork = containersInNetwork;
}
function getContainersInNetwork(network) {
var apiVersion = $scope.applicationState.endpoint.apiVersion;
if (network.Containers) {
if (apiVersion < 1.24) {
Container.query(
{},
function success(data) {
var containersInNetwork = data.filter(function filter(container) {
if (container.HostConfig.NetworkMode === network.Name) {
return container;
}
});
filterContainersInNetwork(network, containersInNetwork);
},
function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers in network');
}
);
} else {
Container.query(
{
filters: { network: [$transition$.params().id] },
},
function success(data) {
filterContainersInNetwork(network, data);
},
function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers in network');
}
);
}
}
}
function initView() {
var nodeName = $transition$.params().nodeName;
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
$scope.nodeName = nodeName;
NetworkService.network($transition$.params().id)
.then(function success(data) {
$scope.network = data;
getContainersInNetwork(data);
$scope.network.IPAM.IPV4Configs = DockerNetworkHelper.getIPV4Configs($scope.network.IPAM.Config);
$scope.network.IPAM.IPV6Configs = DockerNetworkHelper.getIPV6Configs($scope.network.IPAM.Config);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve network info');
});
}
initView();
},
]);

View File

@ -0,0 +1,15 @@
import { ReactNode } from 'react';
interface Props {
children?: ReactNode;
label: string;
}
export function DetailsRow({ label, children }: Props) {
return (
<tr>
<td>{label}</td>
{children && <td data-cy={`detailsTable-${label}Value`}>{children}</td>}
</tr>
);
}

View File

@ -0,0 +1,33 @@
import { Meta, Story } from '@storybook/react';
import { DetailsTable } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
type Args = {
key1: string;
val1: string;
key2: string;
val2: string;
};
export default {
component: DetailsTable,
title: 'Components/Tables/DetailsTable',
} as Meta;
function Template({ key1, val1, key2, val2 }: Args) {
return (
<DetailsTable>
<DetailsRow label={key1}>{val1}</DetailsRow>
<DetailsRow label={key2}>{val2}</DetailsRow>
</DetailsTable>
);
}
export const Default: Story<Args> = Template.bind({});
Default.args = {
key1: 'Name',
val1: 'My Cool App',
key2: 'Id',
val2: 'dmsjs1532',
};

View File

@ -0,0 +1,24 @@
import { render } from '@/react-tools/test-utils';
import { DetailsTable } from './index';
// should display child row elements
test('should display child row elements', () => {
const person = {
name: 'Bob',
id: 'dmsjs1532',
};
const { queryByText } = render(
<DetailsTable>
<DetailsTable.Row label="Name">{person.name}</DetailsTable.Row>
<DetailsTable.Row label="Id">{person.id}</DetailsTable.Row>
</DetailsTable>
);
const nameRow = queryByText(person.name);
expect(nameRow).toBeVisible();
const idRow = queryByText(person.id);
expect(idRow).toBeVisible();
});

View File

@ -0,0 +1,27 @@
import { PropsWithChildren } from 'react';
type Props = {
headers?: string[];
dataCy?: string;
};
export function DetailsTable({
headers = [],
dataCy,
children,
}: PropsWithChildren<Props>) {
return (
<table className="table" data-cy={dataCy}>
{headers.length > 0 && (
<thead>
<tr>
{headers.map((header) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
)}
<tbody>{children}</tbody>
</table>
);
}

View File

@ -0,0 +1,13 @@
import { DetailsTable as MainComponent } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
interface DetailsTableSubcomponents {
Row: typeof DetailsRow;
}
const DetailsTable = MainComponent as typeof MainComponent &
DetailsTableSubcomponents;
DetailsTable.Row = DetailsRow;
export { DetailsTable };

View File

@ -58,14 +58,18 @@ export function parseAxiosError(
if ('isAxiosError' in err) {
const { error, details } = parseError(err as AxiosError);
resultErr = error;
resultMsg = msg ? `${msg}: ${details}` : details;
if (msg && details) {
resultMsg = `${msg}: ${details}`;
} else {
resultMsg = msg || details;
}
}
return new PortainerError(resultMsg, resultErr);
}
function defaultErrorParser(axiosError: AxiosError) {
const message = axiosError.response?.data.message;
const message = axiosError.response?.data.message || '';
const details = axiosError.response?.data.details || message;
const error = new Error(message);
return { error, details };