refactor(networks): migrate networks list view to react [BE-6563] (#3071)

This commit is contained in:
Chaim Lev-Ari
2026-07-12 14:37:44 +03:00
committed by GitHub
parent 7bce72d7d5
commit 0eb35db424
18 changed files with 369 additions and 135 deletions
+4
View File
@@ -20,6 +20,10 @@ function AgentServiceFactory(Agent, AgentVersion1, HttpRequestHelper, Host, Stat
return Host.info({ endpointId }).$promise;
}
/**
* @deprecated
* useAgentNodes instead
*/
async function agents(endpointId) {
const agentVersion = getAgentApiVersion();
const service = agentVersion > 1 ? Agent : AgentVersion1;
+1 -2
View File
@@ -251,8 +251,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/networks',
views: {
'content@': {
templateUrl: './views/networks/networks.html',
controller: 'NetworksController',
component: 'networksListView',
},
},
data: {
-9
View File
@@ -16,7 +16,6 @@ import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolum
import { ProcessesDatatable } from '@/react/docker/containers/StatsView/ProcessesDatatable';
import { SecretsDatatable } from '@/react/docker/secrets/ListView/SecretsDatatable';
import { StacksDatatable } from '@/react/docker/stacks/ListView/StacksDatatable';
import { NetworksDatatable } from '@/react/docker/networks/ListView/NetworksDatatable';
import { HostDetailsPanel } from '@/react/docker/host/HostDetailsPanel/HostDetailsPanel';
import { containersModule } from './containers';
@@ -37,14 +36,6 @@ const ngModule = angular
])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
.component(
'networksDatatable',
r2a(withUIRouter(withCurrentUser(NetworksDatatable)), [
'dataset',
'onRefresh',
'onRemove',
])
)
.component(
'gpusList',
r2a(withControlledInput(GpusList), ['value', 'onChange'])
+2
View File
@@ -11,6 +11,7 @@ import { containersModule } from './containers';
import { configsModule } from './configs';
import { imagesModule } from './images';
import { stacksModule } from './stacks';
import { networksModule } from './networks';
export const viewsModule = angular
.module('portainer.docker.react.views', [
@@ -18,6 +19,7 @@ export const viewsModule = angular
configsModule,
imagesModule,
stacksModule,
networksModule,
])
.component(
'dockerDashboardView',
+13
View File
@@ -0,0 +1,13 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/docker/networks/ListView/ListView';
export const networksModule = angular
.module('portainer.docker.react.views.networks', [])
.component(
'networksListView',
r2a(withUIRouter(withCurrentUser(ListView)), [])
).name;
-3
View File
@@ -1,3 +0,0 @@
<page-header title="'Network list'" breadcrumbs="['Networks']" reload="true"> </page-header>
<networks-datatable ng-if="networks" dataset="networks" on-refresh="(getNetworks)" on-remove="(removeAction)"></networks-datatable>
@@ -1,87 +0,0 @@
import _ from 'lodash-es';
import DockerNetworkHelper from '@/docker/helpers/networkHelper';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
angular.module('portainer.docker').controller('NetworksController', [
'$q',
'$scope',
'$state',
'NetworkService',
'Notifications',
'HttpRequestHelper',
'endpoint',
'AgentService',
function ($q, $scope, $state, NetworkService, Notifications, HttpRequestHelper, endpoint, AgentService) {
$scope.removeAction = async function (selectedItems) {
async function doRemove(network) {
HttpRequestHelper.setPortainerAgentTargetHeader(network.NodeName);
return NetworkService.remove(network.Id)
.then(function success() {
Notifications.success('Network successfully removed', network.Name);
var index = $scope.networks.indexOf(network);
$scope.networks.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove network');
});
}
await processItemsInBatches(selectedItems, doRemove);
$state.reload();
};
$scope.getNetworks = getNetworks;
function groupSwarmNetworksManagerNodesFirst(networks, agents) {
const getRole = (item) => _.find(agents, (agent) => agent.NodeName === item.NodeName).NodeRole;
const nonSwarmNetworks = _.remove(networks, (item) => item.Scope !== 'swarm');
const grouped = _.toArray(_.groupBy(networks, (item) => item.Id));
const sorted = _.map(grouped, (arr) => _.sortBy(arr, (item) => getRole(item)));
const arr = _.map(sorted, (a) => {
const item = a[0];
for (let i = 1; i < a.length; i++) {
item.Subs.push(a[i]);
}
return item;
});
const res = _.concat(arr, ...nonSwarmNetworks);
return res;
}
function getNetworks() {
const req = {
networks: NetworkService.networks(true, true, true),
};
if ($scope.applicationState.endpoint.mode.agentProxy && $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
req.agents = AgentService.agents(endpoint.Id);
}
$q.all(req)
.then((data) => {
const networks = _.forEach(data.networks, (item) => (item.Subs = []));
if ($scope.applicationState.endpoint.mode.agentProxy && $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
$scope.networks = groupSwarmNetworksManagerNodesFirst(data.networks, data.agents);
} else {
$scope.networks = networks;
}
_.forEach($scope.networks, (network) => {
network.IPAM.IPV4Configs = DockerNetworkHelper.getIPV4Configs(network.IPAM.Config);
network.IPAM.IPV6Configs = DockerNetworkHelper.getIPV6Configs(network.IPAM.Config);
});
})
.catch((err) => {
$scope.networks = [];
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
}
function initView() {
getNetworks();
}
initView();
},
]);
@@ -5,20 +5,20 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildAgentUrl } from './build-url';
interface Node {
export interface AgentNode {
IPAddress: string;
NodeName: string;
NodeRole: string;
}
export function useAgentNodes<T = Array<Node>>(
export function useAgentNodes<T = Array<AgentNode>>(
environmentId: EnvironmentId,
apiVersion: number,
{
select,
enabled,
}: {
select?: (data: Array<Node>) => T;
select?: (data: Array<AgentNode>) => T;
enabled?: boolean;
} = {}
) {
@@ -34,7 +34,7 @@ export function useAgentNodes<T = Array<Node>>(
async function getNodes(environmentId: EnvironmentId, apiVersion: number) {
try {
const response = await axios.get<Array<Node>>(
const response = await axios.get<Array<AgentNode>>(
buildAgentUrl(environmentId, apiVersion, 'agents')
);
return response.data;
@@ -91,7 +91,7 @@ function getNetwork(networkName: string): DockerNetwork {
},
],
Driver: 'default',
Options: null,
Options: undefined,
},
Id: '4c52a72e3772fdfb5823cf519b759e3f716e6d98cfb3bfef056e32c9c878329f',
Internal: false,
@@ -0,0 +1,19 @@
import { PageHeader } from '@@/PageHeader';
import { useDeleteNetworkListMutation } from '../queries/useDeleteNetworkListMutation';
import { NetworksDatatable } from './NetworksDatatable';
export function ListView() {
const removeMutation = useDeleteNetworkListMutation();
return (
<>
<PageHeader title="Network List" breadcrumbs={['Networks']} reload />
<NetworksDatatable
onRemove={(networks) => removeMutation.mutate({ networks })}
/>
</>
);
}
@@ -13,7 +13,6 @@ import {
import { AddButton } from '@@/buttons';
import { TableSettingsMenu } from '@@/datatables';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useRepeater } from '@@/datatables/useRepeater';
import { useTableState } from '@@/datatables/useTableState';
import { DeleteButton } from '@@/buttons/DeleteButton';
@@ -22,6 +21,7 @@ import { useIsSwarm } from '../../proxy/queries/useInfo';
import { useColumns } from './columns';
import { DecoratedNetwork } from './types';
import { NestedNetworksDatatable } from './NestedNetworksTable';
import { useNetworksData } from './useNetworksData';
const storageKey = 'docker.networks';
@@ -35,30 +35,29 @@ const settingsStore = createPersistedStore<TableSettings>(
})
);
type DatasetType = Array<DecoratedNetwork>;
interface Props {
dataset: DatasetType;
onRemove(selectedItems: DatasetType): void;
onRefresh(): Promise<void>;
onRemove(selectedItems: Array<{ nodeName?: string; id: string }>): void;
}
export function NetworksDatatable({ dataset, onRemove, onRefresh }: Props) {
export function NetworksDatatable({ onRemove }: Props) {
const settings = useTableState(settingsStore, storageKey);
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
const datasetQuery = useNetworksData(settings.autoRefreshRate);
const columns = useColumns(isSwarm);
useRepeater(settings.autoRefreshRate, onRefresh);
const dataset = datasetQuery.data;
return (
<ExpandableDatatable<DecoratedNetwork>
settingsManager={settings}
title="Networks"
titleIcon={Network}
dataset={dataset}
dataset={dataset || []}
columns={columns}
isLoading={datasetQuery.isLoading}
getRowCanExpand={({ original: item }) =>
!!(item.Subs && item.Subs?.length > 0)
}
@@ -83,7 +82,11 @@ export function NetworksDatatable({ dataset, onRemove, onRefresh }: Props) {
disabled={selectedRows.length === 0}
data-cy="network-removeNetworkButton"
confirmMessage="Do you want to remove the selected network(s)?"
onConfirmed={() => onRemove(selectedRows)}
onConfirmed={() =>
onRemove(
selectedRows.map((n) => ({ id: n.Id, nodeName: n.NodeName }))
)
}
/>
</Authorized>
<Authorized authorizations="DockerNetworkCreate">
+1 -1
View File
@@ -3,7 +3,7 @@ import { IPAMConfig } from 'docker-types';
import { NetworkViewModel } from '@/docker/models/network';
export type DecoratedNetwork = NetworkViewModel & {
Subs?: DecoratedNetwork[];
Subs: DecoratedNetwork[];
IPAM: NetworkViewModel['IPAM'] & {
IPV4Configs?: Array<IPAMConfig>;
IPV6Configs?: Array<IPAMConfig>;
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { AgentNode } from '../../agent/queries/useAgentNodes';
import { groupSwarmNetworksManagerNodesFirst } from './useNetworksData';
import { DecoratedNetwork } from './types';
describe('groupSwarmNetworksManagerNodesFirst', () => {
it('should leave non-swarm networks untouched and unsubbed', () => {
const local = createNetwork({ Id: 'net1', Scope: 'local' });
const result = groupSwarmNetworksManagerNodesFirst([local], []);
expect(result).toEqual([local]);
});
it('should group swarm networks sharing an Id, nesting the rest under Subs', () => {
const manager = createNetwork({
Id: 'net1',
NodeName: 'node-manager',
});
const worker = createNetwork({
Id: 'net1',
NodeName: 'node-worker',
});
const agents = [
createAgent({ NodeName: 'node-manager', NodeRole: 'manager' }),
createAgent({ NodeName: 'node-worker', NodeRole: 'worker' }),
];
const [result] = groupSwarmNetworksManagerNodesFirst(
[worker, manager],
agents
);
expect(result.NodeName).toBe('node-manager');
expect(result.Subs).toEqual([worker]);
});
it('should place networks with no matching agent role after known roles', () => {
const manager = createNetwork({ Id: 'net1', NodeName: 'node-manager' });
const unknown = createNetwork({ Id: 'net1', NodeName: 'node-unknown' });
const agents = [
createAgent({ NodeName: 'node-manager', NodeRole: 'manager' }),
];
const [result] = groupSwarmNetworksManagerNodesFirst(
[unknown, manager],
agents
);
expect(result.NodeName).toBe('node-manager');
expect(result.Subs).toEqual([unknown]);
});
it('should put grouped swarm networks before non-swarm networks', () => {
const swarm = createNetwork({ Id: 'net1', Scope: 'swarm' });
const local = createNetwork({ Id: 'net2', Scope: 'local' });
const result = groupSwarmNetworksManagerNodesFirst([local, swarm], []);
expect(result.map((n) => n.Id)).toEqual(['net1', 'net2']);
});
});
function createNetwork(
overrides: Partial<DecoratedNetwork> = {}
): DecoratedNetwork {
return {
Id: 'network1',
Name: 'test-network',
Scope: 'swarm',
Driver: 'overlay',
Attachable: false,
Internal: false,
Ingress: false,
Labels: {},
IPAM: {},
Subs: [],
...overrides,
} as DecoratedNetwork;
}
function createAgent(overrides: Partial<AgentNode> = {}): AgentNode {
return {
IPAddress: '10.0.0.1',
NodeName: 'node1',
NodeRole: 'worker',
...overrides,
};
}
@@ -0,0 +1,95 @@
import _ from 'lodash';
import { IPAMConfig } from 'docker-types';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { NetworkViewModel } from '@/docker/models/network';
import DockerNetworkHelper from '@/docker/helpers/networkHelper';
import { useAgentNodes, AgentNode } from '../../agent/queries/useAgentNodes';
import { useIsSwarmAgent } from '../../proxy/queries/useIsSwarmAgent';
import { useNetworks } from '../queries/useNetworks';
import { useApiVersion } from '../../agent/queries/useApiVersion';
import { DecoratedNetwork } from './types';
export function useNetworksData(autoRefreshRate?: number) {
const environmentId = useEnvironmentId();
const networksQuery = useNetworks(
environmentId,
{
local: true,
swarm: true,
swarmAttachable: true,
},
{
select: (networks) =>
networks.map((n) => {
const network = new NetworkViewModel(n);
const ipam: NetworkViewModel['IPAM'] & {
IPV4Configs?: Array<IPAMConfig>;
IPV6Configs?: Array<IPAMConfig>;
} = network.IPAM ?? {};
ipam.IPV4Configs = DockerNetworkHelper.getIPV4Configs(ipam.Config);
ipam.IPV6Configs = DockerNetworkHelper.getIPV6Configs(ipam.Config);
network.IPAM = ipam;
return {
...network,
IPAM: ipam,
Subs: [],
} satisfies DecoratedNetwork;
}),
autoRefreshRate,
}
);
const isSwarmAgent = useIsSwarmAgent();
const apiVersionQuery = useApiVersion(environmentId);
const agentsQuery = useAgentNodes(environmentId, apiVersionQuery.data || 1, {
enabled: isSwarmAgent,
});
if (!networksQuery.data) {
return { isLoading: true };
}
const networks = groupSwarmNetworksManagerNodesFirst(
networksQuery.data,
agentsQuery.data
);
return {
data: networks,
isLoading: networksQuery.isLoading,
};
}
export function groupSwarmNetworksManagerNodesFirst(
networks: Array<DecoratedNetwork>,
agents: Array<AgentNode> = []
): Array<DecoratedNetwork> {
const nonSwarmNetworks = networks.filter((item) => item.Scope !== 'swarm');
const swarmNetworks = networks.filter((item) => item.Scope === 'swarm');
const swarmNetworksById = new Map<string, Array<DecoratedNetwork>>();
swarmNetworks.forEach((item) => {
const group = swarmNetworksById.get(item.Id) ?? [];
group.push(item);
swarmNetworksById.set(item.Id, group);
});
const groupedSwarmNetworks = Array.from(swarmNetworksById.values()).map(
(group) => {
const [item, ...rest] = _.sortBy(group, getRole);
item.Subs = rest;
return item;
}
);
return [...groupedSwarmNetworks, ...nonSwarmNetworks];
function getRole(item: NetworkViewModel) {
return agents.find((agent) => agent.NodeName === item.NodeName)?.NodeRole;
}
}
@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { notifySuccess } from '@/portainer/services/notifications';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { queryKeys } from './queryKeys';
import { deleteNetwork } from './useDeleteNetworkMutation';
export function useDeleteNetworkListMutation() {
const environmentId = useEnvironmentId();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
networks,
}: {
networks: Array<{ nodeName?: string; id: string }>;
}) =>
processItemsInBatches(networks, ({ id, nodeName }) =>
deleteNetwork(environmentId, id, { nodeName }).then(() =>
notifySuccess('Network successfully removed', id)
)
),
...withInvalidate(queryClient, [queryKeys.base(environmentId)]),
...withError('Failed to remove networks'),
});
}
@@ -1,10 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { withError } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
@@ -17,14 +13,20 @@ import { queryKeys } from './queryKeys';
export function useDeleteNetwork(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation(
({ networkId, nodeName }: { networkId: NetworkId; nodeName?: string }) =>
deleteNetwork(environmentId, networkId, { nodeName }),
mutationOptions(
withInvalidate(queryClient, [queryKeys.base(environmentId)]),
withError('Unable to remove network')
)
);
return useMutation({
mutationFn: ({
networkId,
nodeName,
}: {
networkId: NetworkId;
nodeName?: string;
}) => deleteNetwork(environmentId, networkId, { nodeName }),
...withError('Unable to remove network'),
onSuccess(_, { networkId }) {
queryClient.cancelQueries(queryKeys.item(environmentId, networkId));
return queryClient.invalidateQueries(queryKeys.base(environmentId));
},
});
}
/**
@@ -1,15 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { IPAMConfig, Network } from 'docker-types';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { DockerNetwork } from '../types';
import { withFiltersQueryParam } from '../../proxy/queries/utils';
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
import { PortainerResponse } from '../../types';
import { DockerNetwork, IPConfig, NetworkResponseContainers } from '../types';
import { queryKeys } from './queryKeys';
import { NetworksQuery } from './types';
export type NetworkListResponseItem = PortainerResponse<
Network & {
ConfigFrom?: { Network: string };
ConfigOnly?: boolean;
}
>;
export function useNetworks<T = Array<DockerNetwork>>(
environmentId: EnvironmentId,
query: NetworksQuery,
@@ -17,16 +26,24 @@ export function useNetworks<T = Array<DockerNetwork>>(
enabled = true,
onSuccess,
select,
autoRefreshRate,
}: {
enabled?: boolean;
onSuccess?(networks: T): void;
select?(networks: Array<DockerNetwork>): T;
autoRefreshRate?: number;
} = {}
) {
return useQuery(
queryKeys.list(environmentId, query),
() => getNetworks(environmentId, query),
{ enabled, onSuccess, select }
{
enabled,
onSuccess,
select,
refetchInterval: autoRefreshRate ?? false,
}
);
}
@@ -38,16 +55,18 @@ export async function getNetworks(
{ local, swarm, swarmAttachable, filters }: NetworksQuery
) {
try {
const { data } = await axios.get<Array<DockerNetwork>>(
const { data } = await axios.get<Array<NetworkListResponseItem>>(
buildDockerProxyUrl(environmentId, 'networks'),
{
params: { ...withFiltersQueryParam(filters) },
}
);
const parsed = data.map(toDockerNetwork);
return !local && !swarm && !swarmAttachable
? data
: data.filter(
? parsed
: parsed.filter(
(network) =>
(local && network.Scope === 'local') ||
(swarm && network.Scope === 'swarm') ||
@@ -59,3 +78,60 @@ export async function getNetworks(
throw parseAxiosError(err, 'Unable to retrieve networks');
}
}
function toDockerNetwork(req: NetworkListResponseItem): DockerNetwork {
return {
...req,
Name: req.Name || '',
Id: req.Id || '',
Driver: req.Driver || '',
Scope: req.Scope || '',
Attachable: req.Attachable ?? false,
Internal: req.Internal ?? false,
IPAM: toIpam(req.IPAM),
Options: req.Options ?? {},
Containers: toContainers(req.Containers),
};
function toContainers(
req: NetworkListResponseItem['Containers']
): NetworkResponseContainers {
if (!req) return {};
return Object.fromEntries(
Object.entries(req).map(([id, c]) => [
id,
{
EndpointID: c.EndpointID ?? '',
IPv4Address: c.IPv4Address ?? '',
IPv6Address: c.IPv6Address ?? '',
MacAddress: c.MacAddress ?? '',
Name: c.Name ?? '',
},
])
);
}
function toIpam(req: NetworkListResponseItem['IPAM']): DockerNetwork['IPAM'] {
if (!req) {
return {
Config: [],
Driver: '',
Options: {},
};
}
return {
Config: req.Config?.map(toIpamConfig) || [],
Driver: req.Driver || '',
Options: req.Options,
};
}
function toIpamConfig(req: IPAMConfig): IPConfig {
return {
Subnet: req.Subnet ?? '',
Gateway: req.Gateway ?? '',
};
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export type NetworkId = string;
export type NetworkOptions = Record<string, string>;
type IpamOptions = Record<string, string> | null;
type IpamOptions = Record<string, string> | undefined;
export type NetworkResponseContainer = {
EndpointID: string;