diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js index 8dcb06ad82..03a6de4bad 100644 --- a/app/agent/services/agentService.js +++ b/app/agent/services/agentService.js @@ -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; diff --git a/app/docker/__module.js b/app/docker/__module.js index 8f71001f2d..8b20235587 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -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: { diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index e180200b91..df6aa7cd93 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -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']) diff --git a/app/docker/react/views/index.ts b/app/docker/react/views/index.ts index 07da95a7fa..67d3ac6e24 100644 --- a/app/docker/react/views/index.ts +++ b/app/docker/react/views/index.ts @@ -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', diff --git a/app/docker/react/views/networks.ts b/app/docker/react/views/networks.ts new file mode 100644 index 0000000000..d7d6c1120a --- /dev/null +++ b/app/docker/react/views/networks.ts @@ -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; diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html deleted file mode 100644 index 67496334e7..0000000000 --- a/app/docker/views/networks/networks.html +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/docker/views/networks/networksController.js b/app/docker/views/networks/networksController.js deleted file mode 100644 index 5b191bfea6..0000000000 --- a/app/docker/views/networks/networksController.js +++ /dev/null @@ -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(); - }, -]); diff --git a/app/react/docker/agent/queries/useAgentNodes.ts b/app/react/docker/agent/queries/useAgentNodes.ts index 1264949ee3..b96c605af4 100644 --- a/app/react/docker/agent/queries/useAgentNodes.ts +++ b/app/react/docker/agent/queries/useAgentNodes.ts @@ -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>( +export function useAgentNodes>( environmentId: EnvironmentId, apiVersion: number, { select, enabled, }: { - select?: (data: Array) => T; + select?: (data: Array) => T; enabled?: boolean; } = {} ) { @@ -34,7 +34,7 @@ export function useAgentNodes>( async function getNodes(environmentId: EnvironmentId, apiVersion: number) { try { - const response = await axios.get>( + const response = await axios.get>( buildAgentUrl(environmentId, apiVersion, 'agents') ); return response.data; diff --git a/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx b/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx index 494fbb5036..8e1b9964d8 100644 --- a/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx +++ b/app/react/docker/networks/ItemView/NetworkDetailsTable.test.tsx @@ -91,7 +91,7 @@ function getNetwork(networkName: string): DockerNetwork { }, ], Driver: 'default', - Options: null, + Options: undefined, }, Id: '4c52a72e3772fdfb5823cf519b759e3f716e6d98cfb3bfef056e32c9c878329f', Internal: false, diff --git a/app/react/docker/networks/ListView/ListView.tsx b/app/react/docker/networks/ListView/ListView.tsx new file mode 100644 index 0000000000..03dc565f62 --- /dev/null +++ b/app/react/docker/networks/ListView/ListView.tsx @@ -0,0 +1,19 @@ +import { PageHeader } from '@@/PageHeader'; + +import { useDeleteNetworkListMutation } from '../queries/useDeleteNetworkListMutation'; + +import { NetworksDatatable } from './NetworksDatatable'; + +export function ListView() { + const removeMutation = useDeleteNetworkListMutation(); + + return ( + <> + + + removeMutation.mutate({ networks })} + /> + + ); +} diff --git a/app/react/docker/networks/ListView/NetworksDatatable.tsx b/app/react/docker/networks/ListView/NetworksDatatable.tsx index 668ad7e59c..c67c1329f3 100644 --- a/app/react/docker/networks/ListView/NetworksDatatable.tsx +++ b/app/react/docker/networks/ListView/NetworksDatatable.tsx @@ -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( }) ); -type DatasetType = Array; interface Props { - dataset: DatasetType; - onRemove(selectedItems: DatasetType): void; - onRefresh(): Promise; + 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 ( 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 })) + ) + } /> diff --git a/app/react/docker/networks/ListView/types.ts b/app/react/docker/networks/ListView/types.ts index b2b9013022..cc00b28f46 100644 --- a/app/react/docker/networks/ListView/types.ts +++ b/app/react/docker/networks/ListView/types.ts @@ -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; IPV6Configs?: Array; diff --git a/app/react/docker/networks/ListView/useNetworksData.test.ts b/app/react/docker/networks/ListView/useNetworksData.test.ts new file mode 100644 index 0000000000..c0efc19bf2 --- /dev/null +++ b/app/react/docker/networks/ListView/useNetworksData.test.ts @@ -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 { + 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 { + return { + IPAddress: '10.0.0.1', + NodeName: 'node1', + NodeRole: 'worker', + ...overrides, + }; +} diff --git a/app/react/docker/networks/ListView/useNetworksData.ts b/app/react/docker/networks/ListView/useNetworksData.ts new file mode 100644 index 0000000000..44525c75d2 --- /dev/null +++ b/app/react/docker/networks/ListView/useNetworksData.ts @@ -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; + IPV6Configs?: Array; + } = 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, + agents: Array = [] +): Array { + const nonSwarmNetworks = networks.filter((item) => item.Scope !== 'swarm'); + const swarmNetworks = networks.filter((item) => item.Scope === 'swarm'); + + const swarmNetworksById = new Map>(); + 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; + } +} diff --git a/app/react/docker/networks/queries/useDeleteNetworkListMutation.tsx b/app/react/docker/networks/queries/useDeleteNetworkListMutation.tsx new file mode 100644 index 0000000000..9ced2a9574 --- /dev/null +++ b/app/react/docker/networks/queries/useDeleteNetworkListMutation.tsx @@ -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'), + }); +} diff --git a/app/react/docker/networks/queries/useDeleteNetworkMutation.ts b/app/react/docker/networks/queries/useDeleteNetworkMutation.ts index 1f1c359a37..71ae203d82 100644 --- a/app/react/docker/networks/queries/useDeleteNetworkMutation.ts +++ b/app/react/docker/networks/queries/useDeleteNetworkMutation.ts @@ -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)); + }, + }); } /** diff --git a/app/react/docker/networks/queries/useNetworks.ts b/app/react/docker/networks/queries/useNetworks.ts index 9844c47e00..d4479f31c1 100644 --- a/app/react/docker/networks/queries/useNetworks.ts +++ b/app/react/docker/networks/queries/useNetworks.ts @@ -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>( environmentId: EnvironmentId, query: NetworksQuery, @@ -17,16 +26,24 @@ export function useNetworks>( enabled = true, onSuccess, select, + autoRefreshRate, }: { enabled?: boolean; onSuccess?(networks: T): void; select?(networks: Array): 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>( + const { data } = await axios.get>( 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 ?? '', + }; + } +} diff --git a/app/react/docker/networks/types.ts b/app/react/docker/networks/types.ts index 9f1cfc3b1d..e53069c8de 100644 --- a/app/react/docker/networks/types.ts +++ b/app/react/docker/networks/types.ts @@ -12,7 +12,7 @@ export type NetworkId = string; export type NetworkOptions = Record; -type IpamOptions = Record | null; +type IpamOptions = Record | undefined; export type NetworkResponseContainer = { EndpointID: string;