diff --git a/app/docker/react/components/containers.ts b/app/docker/react/components/containers.ts index 1aa96e5b9..2ce3ea260 100644 --- a/app/docker/react/components/containers.ts +++ b/app/docker/react/components/containers.ts @@ -20,6 +20,11 @@ import { VolumesTab, volumesTabUtils, } from '@/react/docker/containers/CreateView/VolumesTab'; +import { + networkTabUtils, + NetworkTab, + type NetworkTabValues, +} from '@/react/docker/containers/CreateView/NetworkTab'; const ngModule = angular .module('portainer.docker.react.components.containers', []) @@ -57,3 +62,11 @@ withFormValidation( ['allowBindMounts'], volumesTabUtils.validation ); + +withFormValidation, NetworkTabValues>( + ngModule, + withUIRouter(withReactQuery(NetworkTab)), + 'dockerCreateContainerNetworkTab', + [], + networkTabUtils.validation +); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 41150384f..25ea99a1e 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -8,12 +8,14 @@ import { buildConfirmButton } from '@@/modals/utils'; import { commandsTabUtils } from '@/react/docker/containers/CreateView/CommandsTab'; import { volumesTabUtils } from '@/react/docker/containers/CreateView/VolumesTab'; +import { networkTabUtils } from '@/react/docker/containers/CreateView/NetworkTab'; import { ContainerCapabilities, ContainerCapability } from '@/docker/models/containerCapabilities'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { ContainerDetailsViewModel } from '@/docker/models/container'; import './createcontainer.css'; import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab'; +import { getContainers } from '@/react/docker/containers/queries/containers'; angular.module('portainer.docker').controller('CreateContainerController', [ '$q', @@ -74,7 +76,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ selectedGPUs: ['all'], capabilities: ['compute', 'utility'], }, - NetworkContainer: null, Labels: [], ExtraHosts: [], MacAddress: '', @@ -94,10 +95,9 @@ angular.module('portainer.docker').controller('CreateContainerController', [ commands: commandsTabUtils.getDefaultViewModel(), envVars: envVarsTabUtils.getDefaultViewModel(), volumes: volumesTabUtils.getDefaultViewModel(), + network: networkTabUtils.getDefaultViewModel(), }; - $scope.extraNetworks = {}; - $scope.state = { formValidationError: '', actionInProgress: false, @@ -132,6 +132,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.formValues.volumes = volumes; }); }; + $scope.onNetworkChange = function (network) { + return $scope.$evalAsync(() => { + $scope.formValues.network = network; + }); + }; function onAlwaysPullChange(checked) { return $scope.$evalAsync(() => { @@ -280,50 +285,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ config.HostConfig.PortBindings = bindings; } - function prepareNetworkConfig(config) { - var mode = config.HostConfig.NetworkMode; - var container = $scope.formValues.NetworkContainer; - var containerName = container; - if (container && typeof container === 'object') { - containerName = container.Names[0]; - } - var networkMode = mode; - if (containerName) { - networkMode += ':' + containerName; - config.Hostname = ''; - } - config.HostConfig.NetworkMode = networkMode; - config.MacAddress = $scope.formValues.MacAddress; - - config.NetworkingConfig.EndpointsConfig[networkMode] = { - IPAMConfig: { - IPv4Address: $scope.formValues.IPv4, - IPv6Address: $scope.formValues.IPv6, - }, - }; - - if (networkMode && _.get($scope.config.NetworkingConfig.EndpointsConfig[networkMode], 'Aliases')) { - var aliases = $scope.config.NetworkingConfig.EndpointsConfig[networkMode].Aliases; - config.NetworkingConfig.EndpointsConfig[networkMode].Aliases = _.filter(aliases, (o) => { - return !_.startsWith($scope.fromContainer.Id, o); - }); - } - - var dnsServers = []; - if ($scope.formValues.DnsPrimary) { - dnsServers.push($scope.formValues.DnsPrimary); - } - if ($scope.formValues.DnsSecondary) { - dnsServers.push($scope.formValues.DnsSecondary); - } - config.HostConfig.Dns = dnsServers; - - config.HostConfig.ExtraHosts = _.map( - _.filter($scope.formValues.ExtraHosts, (v) => v.value), - 'value' - ); - } - function prepareLabels(config) { var labels = {}; $scope.formValues.Labels.forEach(function (label) { @@ -439,8 +400,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [ config = commandsTabUtils.toRequest(config, $scope.formValues.commands); config = envVarsTabUtils.toRequest(config, $scope.formValues.envVars); config = volumesTabUtils.toRequest(config, $scope.formValues.volumes); + config = networkTabUtils.toRequest(config, $scope.formValues.network, $scope.fromContainer.Id); - prepareNetworkConfig(config); prepareImageConfig(config); preparePortBindings(config); prepareLabels(config); @@ -457,70 +418,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.config.HostConfig.PortBindings = bindings; } - $scope.resetNetworkConfig = function () { - $scope.config.NetworkingConfig = { - EndpointsConfig: {}, - }; - }; - - function loadFromContainerNetworkConfig(d) { - $scope.config.NetworkingConfig = { - EndpointsConfig: {}, - }; - var networkMode = d.HostConfig.NetworkMode; - if (networkMode === 'default') { - $scope.config.HostConfig.NetworkMode = 'bridge'; - if (!_.find($scope.availableNetworks, { Name: 'bridge' })) { - $scope.config.HostConfig.NetworkMode = 'nat'; - } - } - if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) { - var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1]; - $scope.config.HostConfig.NetworkMode = 'container'; - for (var c in $scope.runningContainers) { - if ($scope.runningContainers[c].Id == netContainer) { - $scope.formValues.NetworkContainer = $scope.runningContainers[c]; - } - } - } - $scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2; - if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) { - if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) { - if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) { - $scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address; - } - if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) { - $scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address; - } - } - } - $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; - if (Object.keys(d.NetworkSettings.Networks).length > 1) { - var firstNetwork = d.NetworkSettings.Networks[Object.keys(d.NetworkSettings.Networks)[0]]; - $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = firstNetwork; - $scope.extraNetworks = angular.copy(d.NetworkSettings.Networks); - delete $scope.extraNetworks[Object.keys(d.NetworkSettings.Networks)[0]]; - } - $scope.formValues.MacAddress = d.Config.MacAddress; - - if (d.HostConfig.Dns && d.HostConfig.Dns[0]) { - $scope.formValues.DnsPrimary = d.HostConfig.Dns[0]; - if (d.HostConfig.Dns[1]) { - $scope.formValues.DnsSecondary = d.HostConfig.Dns[1]; - } - } - - // ExtraHosts - if ($scope.config.HostConfig.ExtraHosts) { - var extraHosts = $scope.config.HostConfig.ExtraHosts; - for (var i = 0; i < extraHosts.length; i++) { - var host = extraHosts[i]; - $scope.formValues.ExtraHosts.push({ value: host }); - } - $scope.config.HostConfig.ExtraHosts = []; - } - } - function loadFromContainerLabels() { for (var l in $scope.config.Labels) { if ({}.hasOwnProperty.call($scope.config.Labels, l)) { @@ -640,16 +537,14 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.fromContainer = fromContainer; $scope.state.mode = 'duplicate'; - $scope.config = ContainerHelper.configFromContainer(fromContainer.Model); + $scope.config = ContainerHelper.configFromContainer(angular.copy(d)); $scope.formValues.commands = commandsTabUtils.toViewModel(d); $scope.formValues.envVars = envVarsTabUtils.toViewModel(d); $scope.formValues.volumes = volumesTabUtils.toViewModel(d); + $scope.formValues.network = networkTabUtils.toViewModel(d, $scope.availableNetworks, $scope.runningContainers); loadFromContainerPortBindings(d); - - loadFromContainerNetworkConfig(d); - loadFromContainerLabels(d); loadFromContainerDevices(d); loadFromContainerDeviceRequests(d); @@ -691,11 +586,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [ .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve networks'); }); - - Container.query( - {}, - function (d) { - var containers = d; + getContainers(endpoint.Id) + .then((containers) => { $scope.runningContainers = containers; $scope.gpuUseAll = _.get($scope, 'endpoint.Snapshots[0].GpuUseAll', false); $scope.gpuUseList = _.get($scope, 'endpoint.Snapshots[0].GpuUseList', []); @@ -706,11 +598,10 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.fromContainer = {}; $scope.formValues.capabilities = $scope.areContainerCapabilitiesEnabled ? new ContainerCapabilities() : []; } - }, - function (e) { + }) + .catch((e) => { Notifications.error('Failure', e, 'Unable to retrieve running containers'); - } - ); + }); SystemService.info() .then(function success(data) { @@ -928,11 +819,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [ } function connectToExtraNetworks(newContainerId) { - if (!$scope.extraNetworks) { + if (!$scope.formValues.network.extraNetworks) { return $q.when(); } - var connectionPromises = _.forOwn($scope.extraNetworks, function (network, networkName) { + var connectionPromises = _.forOwn($scope.formValues.network.extraNetworks, function (network, networkName) { if (_.has(network, 'Aliases')) { var aliases = _.filter(network.Aliases, (o) => { return !_.startsWith($scope.fromContainer.Id, o); diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index 140d62c23..bf75ebd6a 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -202,7 +202,7 @@
  • Capabilities
  • -
    +
    @@ -220,122 +220,10 @@
    -
    -
    -
    -
    - You don't have any shared networks. Head over to the networks view to create one. -
    -
    - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    -
    - - - add additional entry - -
    - -
    -
    -
    - value - -
    - -
    -
    - -
    - -
    +
    - +
    diff --git a/app/react/docker/containers/CreateView/NetworkTab/ContainerSelector.tsx b/app/react/docker/containers/CreateView/NetworkTab/ContainerSelector.tsx new file mode 100644 index 000000000..e93da7ec9 --- /dev/null +++ b/app/react/docker/containers/CreateView/NetworkTab/ContainerSelector.tsx @@ -0,0 +1,36 @@ +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; + +import { useContainers } from '../../queries/containers'; +import { ContainerStatus } from '../../types'; + +export function ContainerSelector({ + onChange, + value, +}: { + value: string; + onChange: (value: string) => void; +}) { + const environmentId = useEnvironmentId(); + + const containersQuery = useContainers>>(environmentId, { + filters: { status: [ContainerStatus.Running] }, + select(containers) { + return containers.map((n) => { + const name = n.Names[0]; + + return { label: name, value: name }; + }); + }, + }); + + return ( + + ); +} diff --git a/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx b/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx new file mode 100644 index 000000000..6fb758e00 --- /dev/null +++ b/app/react/docker/containers/CreateView/NetworkTab/NetworkTab.tsx @@ -0,0 +1,139 @@ +import { FormikErrors } from 'formik'; +import { useState } from 'react'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; +import { InputList, ItemProps } from '@@/form-components/InputList'; +import { InputGroup } from '@@/form-components/InputGroup'; +import { FormError } from '@@/form-components/FormError'; + +import { NetworkSelector } from '../../components/NetworkSelector'; + +import { CONTAINER_MODE, Values } from './types'; +import { ContainerSelector } from './ContainerSelector'; + +export function NetworkTab({ + values: initialValues, + onChange, + errors, +}: { + values: Values; + onChange(values: Values): void; + errors?: FormikErrors; +}) { + const [values, setControlledValues] = useState(initialValues); + + return ( +
    + + handleChange({ networkMode })} + /> + + + {values.networkMode === CONTAINER_MODE && ( + + handleChange({ container })} + /> + + )} + + + handleChange({ hostname: e.target.value })} + placeholder="e.g. web01" + /> + + + + handleChange({ domain: e.target.value })} + placeholder="e.g. example.com" + /> + + + + handleChange({ macAddress: e.target.value })} + placeholder="e.g. 12-34-56-78-9a-bc" + /> + + + + handleChange({ ipv4Address: e.target.value })} + placeholder="e.g. 172.20.0.7" + /> + + + + handleChange({ ipv6Address: e.target.value })} + placeholder="e.g. a:b:c:d::1234" + /> + + + + handleChange({ primaryDns: e.target.value })} + placeholder="e.g. 1.1.1.1, 2606:4700:4700::1111" + /> + + + + handleChange({ secondaryDns: e.target.value })} + placeholder="e.g. 1.0.0.1, 2606:4700:4700::1001" + /> + + + handleChange({ hostsFileEntries })} + errors={errors?.hostsFileEntries} + item={HostsFileEntryItem} + /> +
    + ); + + function handleChange(newValues: Partial) { + onChange({ ...values, ...newValues }); + setControlledValues((values) => ({ ...values, ...newValues })); + } +} + +function HostsFileEntryItem({ + item, + onChange, + disabled, + error, + readOnly, +}: ItemProps) { + return ( +
    + + value + onChange(e.target.value)} + disabled={disabled} + readOnly={readOnly} + /> + + + {error && {error}} +
    + ); +} diff --git a/app/react/docker/containers/CreateView/NetworkTab/index.ts b/app/react/docker/containers/CreateView/NetworkTab/index.ts new file mode 100644 index 000000000..e13bf2e06 --- /dev/null +++ b/app/react/docker/containers/CreateView/NetworkTab/index.ts @@ -0,0 +1,14 @@ +import { validation } from './validation'; +import { toRequest } from './toRequest'; +import { toViewModel, getDefaultViewModel } from './toViewModel'; + +export { NetworkTab } from './NetworkTab'; + +export { type Values as NetworkTabValues } from './types'; + +export const networkTabUtils = { + toRequest, + toViewModel, + validation, + getDefaultViewModel, +}; diff --git a/app/react/docker/containers/CreateView/NetworkTab/toRequest.ts b/app/react/docker/containers/CreateView/NetworkTab/toRequest.ts new file mode 100644 index 000000000..d6749303a --- /dev/null +++ b/app/react/docker/containers/CreateView/NetworkTab/toRequest.ts @@ -0,0 +1,42 @@ +import { CreateContainerRequest } from '../types'; + +import { CONTAINER_MODE, Values } from './types'; + +export function toRequest( + oldConfig: CreateContainerRequest, + values: Values, + fromContainerId: string +): CreateContainerRequest { + let mode = values.networkMode; + let hostName = values.hostname; + if (mode === CONTAINER_MODE && values.container) { + mode += `:${values.container}`; + hostName = ''; + } + + return { + ...oldConfig, + Hostname: hostName, + MacAddress: values.macAddress, + HostConfig: { + ...oldConfig.HostConfig, + NetworkMode: mode, + Dns: [values.primaryDns, values.secondaryDns].filter((d) => d), + ExtraHosts: values.hostsFileEntries, + }, + NetworkingConfig: { + ...oldConfig.NetworkingConfig, + EndpointsConfig: { + [mode]: { + IPAMConfig: { + IPv4Address: values.ipv4Address, + IPv6Address: values.ipv6Address, + }, + Aliases: oldConfig.NetworkingConfig.EndpointsConfig?.[ + mode + ]?.Aliases?.filter((al) => !fromContainerId.startsWith(al)), + }, + }, + }, + }; +} diff --git a/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts new file mode 100644 index 000000000..41f677acf --- /dev/null +++ b/app/react/docker/containers/CreateView/NetworkTab/toViewModel.ts @@ -0,0 +1,102 @@ +import { DockerNetwork } from '@/react/docker/networks/types'; + +import { ContainerJSON } from '../../queries/container'; +import { DockerContainer } from '../../types'; + +import { CONTAINER_MODE, Values } from './types'; + +export function getDefaultViewModel(hasBridgeNetwork: boolean) { + return { + networkMode: hasBridgeNetwork ? 'bridge' : 'nat', + hostname: '', + domain: '', + macAddress: '', + ipv4Address: '', + ipv6Address: '', + primaryDns: '', + secondaryDns: '', + hostsFileEntries: [], + container: '', + }; +} + +export function toViewModel( + config: ContainerJSON, + networks: Array, + runningContainers: Array = [] +): Values { + const dns = config.HostConfig?.Dns; + const [primaryDns = '', secondaryDns = ''] = dns || []; + + const hostsFileEntries = config.HostConfig?.ExtraHosts || []; + + const [networkMode, container = ''] = getNetworkMode( + config, + networks, + runningContainers + ); + + const networkSettings = config.NetworkSettings?.Networks?.[networkMode]; + let ipv4Address = ''; + let ipv6Address = ''; + if (networkSettings && networkSettings.IPAMConfig) { + ipv4Address = networkSettings.IPAMConfig.IPv4Address || ''; + ipv6Address = networkSettings.IPAMConfig.IPv6Address || ''; + } + + const macAddress = networkSettings?.MacAddress || ''; + + return { + networkMode, + hostname: config.Config?.Hostname || '', + domain: config.Config?.Domainname || '', + macAddress, + ipv4Address, + ipv6Address, + primaryDns, + secondaryDns, + hostsFileEntries, + container, + }; +} + +function getNetworkMode( + config: ContainerJSON, + networks: Array, + runningContainers: Array = [] +) { + let networkMode = config.HostConfig?.NetworkMode || ''; + if (!networkMode) { + const networks = Object.keys(config.NetworkSettings?.Networks || {}); + if (networks.length > 0) { + [networkMode] = networks; + } + } + + if (networkMode.startsWith('container:')) { + const networkContainerId = networkMode.split(/^container:/)[1]; + const container = + runningContainers.find((c) => c.Id === networkContainerId)?.Names[0] || + ''; + return [CONTAINER_MODE, container] as const; + } + + const networkNames = networks.map((n) => n.Name); + + if (networkNames.includes(networkMode)) { + return [networkMode] as const; + } + + if ( + networkNames.includes('bridge') && + (!networkMode || networkMode === 'default' || networkMode === 'bridge') + ) { + return ['bridge'] as const; + } + + if (networkNames.includes('nat')) { + return ['nat'] as const; + } + + return [networks[0].Name] as const; +} diff --git a/app/react/docker/containers/CreateView/NetworkTab/types.ts b/app/react/docker/containers/CreateView/NetworkTab/types.ts new file mode 100644 index 000000000..8b2f7e155 --- /dev/null +++ b/app/react/docker/containers/CreateView/NetworkTab/types.ts @@ -0,0 +1,14 @@ +export const CONTAINER_MODE = 'container'; + +export interface Values { + networkMode: string; + hostname: string; + domain: string; + macAddress: string; + ipv4Address: string; + ipv6Address: string; + primaryDns: string; + secondaryDns: string; + hostsFileEntries: Array; + container: string; +} diff --git a/app/react/docker/containers/CreateView/NetworkTab/validation.ts b/app/react/docker/containers/CreateView/NetworkTab/validation.ts new file mode 100644 index 000000000..df9b9de75 --- /dev/null +++ b/app/react/docker/containers/CreateView/NetworkTab/validation.ts @@ -0,0 +1,23 @@ +import { array, object, SchemaOf, string } from 'yup'; + +import { Values } from './types'; + +export function validation(): SchemaOf { + return object({ + networkMode: string().default(''), + hostname: string().default(''), + domain: string().default(''), + macAddress: string().default(''), + ipv4Address: string().default(''), + ipv6Address: string().default(''), + primaryDns: string().default(''), + secondaryDns: string().default(''), + hostsFileEntries: array(string().required('Entry is required')).default([]), + container: string() + .default('') + .when('network', { + is: 'container', + then: string().required('Container is required'), + }), + }); +} diff --git a/app/react/docker/containers/queries/containers.ts b/app/react/docker/containers/queries/containers.ts index 2ae04163e..338e36a1f 100644 --- a/app/react/docker/containers/queries/containers.ts +++ b/app/react/docker/containers/queries/containers.ts @@ -10,6 +10,7 @@ import { withGlobalError } from '@/react-tools/react-query'; import { urlBuilder } from '../containers.service'; import { DockerContainerResponse } from '../types/response'; import { parseListViewModel } from '../utils'; +import { DockerContainer } from '../types'; import { Filters } from './types'; import { queryKeys } from './query-keys'; @@ -20,14 +21,15 @@ interface UseContainers { nodeName?: string; } -export function useContainers( +export function useContainers( environmentId: EnvironmentId, { autoRefreshRate, - + select, ...params }: UseContainers & { autoRefreshRate?: number; + select?: (data: DockerContainer[]) => T; } = {} ) { return useQuery( @@ -38,11 +40,12 @@ export function useContainers( refetchInterval() { return autoRefreshRate ?? false; }, + select, } ); } -async function getContainers( +export async function getContainers( environmentId: EnvironmentId, { all = true, filters, nodeName }: UseContainers = {} ) { diff --git a/app/react/docker/containers/queries/types.ts b/app/react/docker/containers/queries/types.ts index f6abcf4eb..2a81b49af 100644 --- a/app/react/docker/containers/queries/types.ts +++ b/app/react/docker/containers/queries/types.ts @@ -1,6 +1,8 @@ import { NetworkId } from '../../networks/types'; +import { ContainerStatus } from '../types'; export interface Filters { label?: string[]; network?: NetworkId[]; + status?: ContainerStatus[]; } diff --git a/app/react/docker/networks/queries/types.ts b/app/react/docker/networks/queries/types.ts index fe876434a..94bfdf163 100644 --- a/app/react/docker/networks/queries/types.ts +++ b/app/react/docker/networks/queries/types.ts @@ -1,6 +1,6 @@ interface Filters { /* dangling= When set to true (or 1), returns all networks that are not in use by a container. When set to false (or 0), only networks that are in use by one or more containers are returned. */ - dangling?: boolean[]; + dangling?: [boolean]; // Matches a network's driver driver?: string[]; // Matches all or part of a network ID