diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html
deleted file mode 100644
index 55e4b7014..000000000
--- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
-
-
- Network |
-
- IP Address
-
-
-
-
- |
- Gateway |
- MAC Address |
- Actions |
-
-
-
-
-
-
- {{ key }}
- |
- {{ value.IPAddress || '-' }} |
- {{ value.Gateway || '-' }} |
- {{ value.MacAddress || '-' }} |
-
-
- |
-
-
- |
-
- {{ value.GlobalIPv6Address }}
- |
-
- {{ value.IPv6Gateway || '-' }}
- |
-
-
- Loading... |
-
-
- No network available. |
-
-
-
-
-
-
-
-
diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js
deleted file mode 100644
index 570f93e16..000000000
--- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js
+++ /dev/null
@@ -1,17 +0,0 @@
-angular.module('portainer.docker').component('containerNetworksDatatable', {
- templateUrl: './containerNetworksDatatable.html',
- controller: 'ContainerNetworksDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- container: '<',
- availableNetworks: '<',
- joinNetworkAction: '<',
- joinNetworkActionInProgress: '<',
- leaveNetworkActionInProgress: '<',
- leaveNetworkAction: '<',
- nodeName: '<',
- },
-});
diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js
deleted file mode 100644
index d2edde7b1..000000000
--- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import _ from 'lodash-es';
-
-angular.module('portainer.docker').controller('ContainerNetworksDatatableController', [
- '$scope',
- '$controller',
- 'DatatableService',
- function ($scope, $controller, DatatableService) {
- angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
- this.state = Object.assign(this.state, {
- expandedItems: [],
- expandAll: true,
- });
-
- this.expandItem = function (item, expanded) {
- if (!this.itemCanExpand(item)) {
- return;
- }
-
- item.Expanded = expanded;
- if (!expanded) {
- item.Highlighted = false;
- }
- if (!item.Expanded) {
- this.state.expandAll = false;
- }
- };
-
- this.itemCanExpand = function (item) {
- return item.GlobalIPv6Address !== '';
- };
-
- this.hasExpandableItems = function () {
- return _.filter(this.dataset, (item) => this.itemCanExpand(item)).length;
- };
-
- this.expandAll = function () {
- this.state.expandAll = !this.state.expandAll;
- _.forEach(this.dataset, (item) => {
- if (this.itemCanExpand(item)) {
- this.expandItem(item, this.state.expandAll);
- }
- });
- };
-
- this.$onInit = function () {
- this.setDefaults();
- this.prepareTableFromDataset();
-
- this.state.orderBy = this.orderBy;
- var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
- if (storedOrder !== null) {
- this.state.reverseOrder = storedOrder.reverse;
- this.state.orderBy = storedOrder.orderBy;
- }
-
- var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
- if (textFilter !== null) {
- this.state.textFilter = textFilter;
- this.onTextFilterChange();
- }
-
- var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
- if (storedFilters !== null) {
- this.filters = storedFilters;
- }
- if (this.filters && this.filters.state) {
- this.filters.state.open = false;
- }
-
- var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
- if (storedSettings !== null) {
- this.settings = storedSettings;
- this.settings.open = false;
- }
-
- _.forEach(this.dataset, (item) => {
- item.Expanded = true;
- item.Highlighted = true;
- });
- };
- },
-]);
diff --git a/app/docker/react/components/containers.ts b/app/docker/react/components/containers.ts
index 7a3b6d5a6..c93d7e49e 100644
--- a/app/docker/react/components/containers.ts
+++ b/app/docker/react/components/containers.ts
@@ -9,11 +9,20 @@ import {
CommandsTabValues,
commandsTabValidation,
} from '@/react/docker/containers/CreateView/CommandsTab';
+import { r2a } from '@/react-tools/react2angular';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { ContainerNetworksDatatable } from '@/react/docker/containers/ItemView/ContainerNetworksDatatable';
-const ngModule = angular.module(
- 'portainer.docker.react.components.containers',
- []
-);
+const ngModule = angular
+ .module('portainer.docker.react.components.containers', [])
+ .component(
+ 'dockerContainerNetworksDatatable',
+ r2a(withUIRouter(withCurrentUser(ContainerNetworksDatatable)), [
+ 'container',
+ 'dataset',
+ 'nodeName',
+ ])
+ );
export const containersModule = ngModule.name;
diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html
index dbfdebf7a..c342b4146 100644
--- a/app/docker/views/containers/edit/container.html
+++ b/app/docker/views/containers/edit/container.html
@@ -348,21 +348,15 @@
-
+
+
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts
index 5e4d42e43..8333cc92e 100644
--- a/app/portainer/react/components/index.ts
+++ b/app/portainer/react/components/index.ts
@@ -180,6 +180,7 @@ export const ngModule = angular
'isMulti',
'isClearable',
'components',
+ 'isLoading',
])
)
.component(
diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx
index fa4e1128c..9dac97f8a 100644
--- a/app/react/components/form-components/PortainerSelect.tsx
+++ b/app/react/components/form-components/PortainerSelect.tsx
@@ -26,6 +26,7 @@ interface SharedProps extends AutomationTestingProps {
disabled?: boolean;
isClearable?: boolean;
bindToBody?: boolean;
+ isLoading?: boolean;
}
interface MultiProps extends SharedProps {
@@ -82,6 +83,7 @@ export function SingleSelect({
isClearable,
bindToBody,
components,
+ isLoading,
}: SingleProps) {
const selectedValue =
value || (typeof value === 'number' && value === 0)
@@ -103,6 +105,7 @@ export function SingleSelect({
isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
+ isLoading={isLoading}
/>
);
}
@@ -142,6 +145,7 @@ export function MultiSelect({
isClearable,
bindToBody,
components,
+ isLoading,
}: Omit, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value);
return (
@@ -161,6 +165,7 @@ export function MultiSelect({
isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
+ isLoading={isLoading}
/>
);
}
diff --git a/app/react/docker/containers/ItemView/.keep b/app/react/docker/containers/ItemView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx
new file mode 100644
index 000000000..4a0dfda26
--- /dev/null
+++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx
@@ -0,0 +1,85 @@
+import { Form, Formik } from 'formik';
+import { SchemaOf, object, string } from 'yup';
+import { useRouter } from '@uirouter/react';
+
+import { useAuthorizations } from '@/react/hooks/useUser';
+import { useConnectContainerMutation } from '@/react/docker/networks/queries/useConnectContainer';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { FormControl } from '@@/form-components/FormControl';
+import { LoadingButton } from '@@/buttons';
+
+import { NetworkSelector } from '../../components/NetworkSelector';
+
+interface FormValues {
+ networkId: string;
+}
+
+export function ConnectNetworkForm({
+ nodeName,
+ containerId,
+ selectedNetworks,
+}: {
+ nodeName?: string;
+ containerId: string;
+ selectedNetworks: string[];
+}) {
+ const environmentId = useEnvironmentId();
+ const authorized = useAuthorizations('DockerNetworkConnect');
+ const connectMutation = useConnectContainerMutation(environmentId);
+ const router = useRouter();
+ if (!authorized) {
+ return null;
+ }
+
+ return (
+
+ initialValues={{ networkId: '' }}
+ onSubmit={handleSubmit}
+ validationSchema={validation}
+ >
+ {({ values, errors, setFieldValue }) => (
+
+ )}
+
+ );
+
+ function handleSubmit({ networkId }: { networkId: string }) {
+ connectMutation.mutate(
+ { containerId, networkId, nodeName },
+ {
+ onSuccess() {
+ router.stateService.reload();
+ },
+ }
+ );
+ }
+}
+
+function validation(): SchemaOf {
+ return object({
+ networkId: string().required('Please select a network'),
+ });
+}
diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx
new file mode 100644
index 000000000..89fdcb61b
--- /dev/null
+++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx
@@ -0,0 +1,73 @@
+import { Share2 } from 'lucide-react';
+import { EndpointSettings, NetworkSettings } from 'docker-types/generated/1.41';
+
+import { createPersistedStore } from '@@/datatables/types';
+import { useTableState } from '@@/datatables/useTableState';
+import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
+import { withMeta } from '@@/datatables/extend-options/withMeta';
+
+import { DockerContainer } from '../../types';
+
+import { TableNetwork } from './types';
+import { columns } from './columns';
+import { ConnectNetworkForm } from './ConnectNetworkForm';
+
+const storageKey = 'container-networks';
+const store = createPersistedStore(storageKey, 'name');
+
+export function ContainerNetworksDatatable({
+ dataset,
+ container,
+ nodeName,
+}: {
+ dataset: NetworkSettings['Networks'];
+ container: DockerContainer;
+ nodeName?: string;
+}) {
+ const tableState = useTableState(store, storageKey);
+
+ const networks: Array = Object.entries(dataset || {})
+ .filter(isNetworkDefined)
+ .map(([id, network]) => ({
+ ...network,
+ id,
+ name: id,
+ }));
+
+ return (
+
+ columns={columns}
+ dataset={networks}
+ settingsManager={tableState}
+ title="Connected Networks"
+ titleIcon={Share2}
+ disableSelect
+ getRowCanExpand={(row) => !!row.original.GlobalIPv6Address}
+ isLoading={!dataset}
+ renderSubRow={({ original: item }) => (
+
+ |
+ {item.GlobalIPv6Address} |
+ {item.IPv6Gateway || '-'} |
+
+ )}
+ description={
+ n.id)}
+ />
+ }
+ extendTableOptions={withMeta({
+ table: 'container-networks',
+ containerId: container.Id,
+ })}
+ />
+ );
+}
+
+function isNetworkDefined(
+ value: [string, EndpointSettings | undefined]
+): value is [string, EndpointSettings] {
+ return value.length > 1 && !!value[1];
+}
diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx
new file mode 100644
index 000000000..b5a3dcbf4
--- /dev/null
+++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx
@@ -0,0 +1,60 @@
+import { CellContext } from '@tanstack/react-table';
+import { useRouter } from '@uirouter/react';
+
+import { Authorized } from '@/react/hooks/useUser';
+import { useDisconnectContainer } from '@/react/docker/networks/queries';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { LoadingButton } from '@@/buttons';
+
+import { TableNetwork, isContainerNetworkTableMeta } from './types';
+import { columnHelper } from './helper';
+
+export const actions = columnHelper.display({
+ header: 'Actions',
+ cell: Cell,
+});
+
+function Cell({
+ row,
+ table: {
+ options: { meta },
+ },
+}: CellContext) {
+ const router = useRouter();
+ const environmentId = useEnvironmentId();
+ const disconnectMutation = useDisconnectContainer();
+
+ return (
+
+
+ Leave network
+
+
+ );
+
+ function handleSubmit() {
+ if (!isContainerNetworkTableMeta(meta)) {
+ throw new Error('Invalid row meta');
+ }
+
+ disconnectMutation.mutate(
+ {
+ environmentId,
+ networkId: row.original.id,
+ containerId: meta.containerId,
+ },
+ {
+ onSuccess() {
+ router.stateService.reload();
+ },
+ }
+ );
+ }
+}
diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/columns.tsx b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/columns.tsx
new file mode 100644
index 000000000..7d3a8d82d
--- /dev/null
+++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/columns.tsx
@@ -0,0 +1,30 @@
+import { buildExpandColumn } from '@@/datatables/expand-column';
+import { buildNameColumn } from '@@/datatables/buildNameColumn';
+
+import { TableNetwork } from './types';
+import { columnHelper } from './helper';
+import { actions } from './actions';
+
+export const columns = [
+ buildExpandColumn(),
+ {
+ ...buildNameColumn('name', 'docker.networks.network'),
+ header: 'Network',
+ },
+ columnHelper.accessor((item) => item.IPAddress || '-', {
+ header: 'IP Address',
+ id: 'ip',
+ enableSorting: false,
+ }),
+ columnHelper.accessor((item) => item.Gateway || '-', {
+ header: 'Gateway',
+ id: 'gateway',
+ enableSorting: false,
+ }),
+ columnHelper.accessor((item) => item.MacAddress || '-', {
+ header: 'MAC Address',
+ id: 'macAddress',
+ enableSorting: false,
+ }),
+ actions,
+];
diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/helper.ts b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/helper.ts
new file mode 100644
index 000000000..1230b9a6c
--- /dev/null
+++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { TableNetwork } from './types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/index.ts b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/index.ts
new file mode 100644
index 000000000..c31d0badc
--- /dev/null
+++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/index.ts
@@ -0,0 +1 @@
+export { ContainerNetworksDatatable } from './ContainerNetworksDatatable';
diff --git a/app/react/docker/containers/ItemView/ContainerNetworksDatatable/types.ts b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/types.ts
new file mode 100644
index 000000000..7775b0216
--- /dev/null
+++ b/app/react/docker/containers/ItemView/ContainerNetworksDatatable/types.ts
@@ -0,0 +1,15 @@
+import { TableMeta } from '@tanstack/react-table';
+import { EndpointSettings } from 'docker-types/generated/1.41';
+
+export type TableNetwork = EndpointSettings & { id: string; name: string };
+
+export type ContainerNetworkTableMeta = TableMeta & {
+ table: 'container-networks';
+ containerId: string;
+};
+
+export function isContainerNetworkTableMeta(
+ meta?: TableMeta
+): meta is ContainerNetworkTableMeta {
+ return !!meta && meta.table === 'container-networks';
+}
diff --git a/app/react/docker/containers/components/NetworkSelector.tsx b/app/react/docker/containers/components/NetworkSelector.tsx
new file mode 100644
index 000000000..40cdfe52a
--- /dev/null
+++ b/app/react/docker/containers/components/NetworkSelector.tsx
@@ -0,0 +1,71 @@
+import { useMemo } from 'react';
+
+import { useNetworks } from '@/react/docker/networks/queries/useNetworks';
+import { DockerNetwork } from '@/react/docker/networks/types';
+import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
+import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
+
+export function NetworkSelector({
+ onChange,
+ additionalOptions = [],
+ value,
+ hiddenNetworks = [],
+}: {
+ value: string;
+ additionalOptions?: Array