mirror of https://github.com/portainer/portainer
refactor(docker networks): migrate docker network detail view to react EE-2196 (#6700)
* Migrate network details to reactpull/6907/head
parent
9650aa56c7
commit
2e0555dbca
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
||||
}
|
|
@ -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(' - ')}`
|
||||
: '';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { react2angular } from '@/react-tools/react2angular';
|
||||
|
||||
import { NetworkDetailsView } from './NetworkDetailsView';
|
||||
|
||||
export const NetworkDetailsViewAngular = react2angular(NetworkDetailsView, []);
|
|
@ -0,0 +1,7 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { NetworkDetailsViewAngular } from './edit';
|
||||
|
||||
export const networksModule = angular
|
||||
.module('portainer.docker.networks', [])
|
||||
.component('networkDetailsView', NetworkDetailsViewAngular).name;
|
|
@ -0,0 +1,5 @@
|
|||
const systemNetworks = ['host', 'bridge', 'none'];
|
||||
|
||||
export function isSystemNetwork(networkName: string) {
|
||||
return systemNetworks.includes(networkName);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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> > <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>
|
|
@ -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();
|
||||
},
|
||||
]);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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();
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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 };
|
||||
|
|
Loading…
Reference in New Issue