diff --git a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html
deleted file mode 100644
index d335615a7..000000000
--- a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
- |
- |
-
- {{ item.Name | truncate: 40 }}
- System
- |
-{{ item.StackName ? item.StackName : '-' }} |
-{{ item.Driver }} |
-{{ item.Attachable }} |
-{{ item.IPAM.Driver }} |
-{{ item.IPAM.IPV4Configs[0].Subnet ? item.IPAM.IPV4Configs[0].Subnet : '-' }} |
-{{ item.IPAM.IPV4Configs[0].Gateway ? item.IPAM.IPV4Configs[0].Gateway : '-' }} |
-{{ item.IPAM.IPV6Configs[0].Subnet ? item.IPAM.IPV6Configs[0].Subnet : '-' }} |
-{{ item.IPAM.IPV6Configs[0].Gateway ? item.IPAM.IPV6Configs[0].Gateway : '-' }} |
-{{ item.NodeName ? item.NodeName : '-' }} |
-
-
-
- {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = RCO.ADMINISTRATORS }}
-
- |
diff --git a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js b/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js
deleted file mode 100644
index 62380d8e4..000000000
--- a/app/docker/components/datatables/networks-datatable/network-row-content/networkRowContent.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types';
-
-angular.module('portainer.docker').directive('networkRowContent', [
- function networkRowContent() {
- var directive = {
- templateUrl: './networkRowContent.html',
- restrict: 'A',
- transclude: true,
- scope: {
- item: '<',
- parentCtrl: '<',
- allowCheckbox: '<',
- allowExpand: '<',
- },
- controller: ($scope) => {
- $scope.RCO = RCO;
- },
- };
- return directive;
- },
-]);
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html
deleted file mode 100644
index 785a63629..000000000
--- a/app/docker/components/datatables/networks-datatable/networksDatatable.html
+++ /dev/null
@@ -1,238 +0,0 @@
-
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js
deleted file mode 100644
index d40efa330..000000000
--- a/app/docker/components/datatables/networks-datatable/networksDatatable.js
+++ /dev/null
@@ -1,15 +0,0 @@
-angular.module('portainer.docker').component('networksDatatable', {
- templateUrl: './networksDatatable.html',
- controller: 'NetworksDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- reverseOrder: '<',
- showHostColumn: '<',
- removeAction: '<',
- refreshCallback: '<',
- },
-});
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatableController.js b/app/docker/components/datatables/networks-datatable/networksDatatableController.js
deleted file mode 100644
index f8e13c605..000000000
--- a/app/docker/components/datatables/networks-datatable/networksDatatableController.js
+++ /dev/null
@@ -1,80 +0,0 @@
-import _ from 'lodash-es';
-
-angular.module('portainer.docker').controller('NetworksDatatableController', [
- '$scope',
- '$controller',
- 'NetworkHelper',
- 'DatatableService',
- function ($scope, $controller, NetworkHelper, DatatableService) {
- angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
-
- this.disableRemove = function (item) {
- return NetworkHelper.isSystemNetwork(item);
- };
-
- this.state = Object.assign(this.state, {
- expandedItems: [],
- });
-
- /**
- * Do not allow system networks to be selected
- */
- this.allowSelection = function (item) {
- return !this.disableRemove(item);
- };
-
- 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;
- }
- this.onSettingsRepeaterChange();
- };
-
- this.expandItem = function (item, expanded) {
- item.Expanded = expanded;
- };
-
- this.itemCanExpand = function (item) {
- return item.Subs.length > 0;
- };
-
- this.hasExpandableItems = function () {
- return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length;
- };
-
- this.expandAll = function () {
- this.state.expandAll = !this.state.expandAll;
- _.forEach(this.state.filteredDataSet, (item) => {
- if (this.itemCanExpand(item)) {
- this.expandItem(item, this.state.expandAll);
- }
- });
- };
- },
-]);
diff --git a/app/docker/models/network.js b/app/docker/models/network.js
deleted file mode 100644
index d4ca83148..000000000
--- a/app/docker/models/network.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
-
-export function NetworkViewModel(data) {
- this.Id = data.Id;
- this.Name = data.Name;
- this.Scope = data.Scope;
- this.Driver = data.Driver;
- this.Attachable = data.Attachable;
- this.Internal = data.Internal;
- this.IPAM = data.IPAM;
- this.Containers = data.Containers;
- this.Options = data.Options;
- this.Ingress = data.Ingress;
-
- this.Labels = data.Labels;
- if (this.Labels && this.Labels['com.docker.compose.project']) {
- this.StackName = this.Labels['com.docker.compose.project'];
- } else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
- this.StackName = this.Labels['com.docker.stack.namespace'];
- }
-
- if (data.Portainer) {
- if (data.Portainer.ResourceControl) {
- this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
- }
- if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
- this.NodeName = data.Portainer.Agent.NodeName;
- }
- }
-
- this.ConfigFrom = data.ConfigFrom;
- this.ConfigOnly = data.ConfigOnly;
-}
diff --git a/app/docker/models/network.ts b/app/docker/models/network.ts
new file mode 100644
index 000000000..56b373c2f
--- /dev/null
+++ b/app/docker/models/network.ts
@@ -0,0 +1,79 @@
+import { IPAM, Network, NetworkContainer } from 'docker-types/generated/1.41';
+
+import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
+import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn';
+import { PortainerMetadata } from '@/react/docker/types';
+
+export class NetworkViewModel implements IResource {
+ Id: string;
+
+ Name: string;
+
+ Scope: string;
+
+ Driver: string;
+
+ Attachable: boolean;
+
+ Internal: boolean;
+
+ IPAM?: IPAM;
+
+ Containers?: Record;
+
+ Options?: Record;
+
+ Ingress: boolean;
+
+ Labels: Record;
+
+ StackName?: string;
+
+ NodeName?: string;
+
+ ConfigFrom?: { Network: string };
+
+ ConfigOnly?: boolean;
+
+ ResourceControl?: ResourceControlViewModel;
+
+ constructor(
+ data: Network & {
+ Portainer?: PortainerMetadata;
+ ConfigFrom?: { Network: string };
+ ConfigOnly?: boolean;
+ }
+ ) {
+ this.Id = data.Id || '';
+ this.Name = data.Name || '';
+ this.Scope = data.Scope || '';
+ this.Driver = data.Driver || '';
+ this.Attachable = data.Attachable || false;
+ this.Internal = data.Internal || false;
+ this.IPAM = data.IPAM;
+ this.Containers = data.Containers;
+ this.Options = data.Options;
+ this.Ingress = data.Ingress || false;
+
+ this.Labels = data.Labels || {};
+ if (this.Labels && this.Labels['com.docker.compose.project']) {
+ this.StackName = this.Labels['com.docker.compose.project'];
+ } else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
+ this.StackName = this.Labels['com.docker.stack.namespace'];
+ }
+
+ if (data.Portainer) {
+ if (data.Portainer.ResourceControl) {
+ this.ResourceControl = new ResourceControlViewModel(
+ data.Portainer.ResourceControl
+ );
+ }
+ if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
+ this.NodeName = data.Portainer.Agent.NodeName;
+ }
+ }
+
+ this.ConfigFrom = data.ConfigFrom;
+ this.ConfigOnly = data.ConfigOnly;
+ }
+}
diff --git a/app/docker/models/node.ts b/app/docker/models/node.ts
index 8cf806881..d0abfbc79 100644
--- a/app/docker/models/node.ts
+++ b/app/docker/models/node.ts
@@ -10,9 +10,7 @@ import {
ResourceObject,
} from 'docker-types/generated/1.41';
-type WithRequiredProperty = Type & {
- [Property in Key]-?: Type[Property];
-};
+import { WithRequiredProperty } from '@/types';
export class NodeViewModel {
Model: Node;
diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts
index cfcdbbcb3..a76d7e2a8 100644
--- a/app/docker/react/components/index.ts
+++ b/app/docker/react/components/index.ts
@@ -22,6 +22,7 @@ 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 { containersModule } from './containers';
import { servicesModule } from './services';
@@ -57,6 +58,14 @@ const ngModule = angular
['environment', 'stackName']
)
)
+ .component(
+ 'networksDatatable',
+ r2a(withUIRouter(withCurrentUser(NetworksDatatable)), [
+ 'dataset',
+ 'onRefresh',
+ 'onRemove',
+ ])
+ )
.component(
'gpusList',
r2a(withControlledInput(GpusList), ['value', 'onChange'])
diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html
index 7b38e2e8b..67496334e 100644
--- a/app/docker/views/networks/networks.html
+++ b/app/docker/views/networks/networks.html
@@ -1,16 +1,3 @@
-
+
diff --git a/app/react/docker/networks/ListView/.keep b/app/react/docker/networks/ListView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/docker/networks/ListView/NestedNetwordsTable.tsx b/app/react/docker/networks/ListView/NestedNetwordsTable.tsx
new file mode 100644
index 000000000..116b23edf
--- /dev/null
+++ b/app/react/docker/networks/ListView/NestedNetwordsTable.tsx
@@ -0,0 +1,20 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { NestedDatatable } from '@@/datatables/NestedDatatable';
+
+import { useIsSwarm } from '../../proxy/queries/useInfo';
+
+import { useColumns } from './columns';
+import { DecoratedNetwork } from './types';
+
+export function NestedNetworksDatatable({
+ dataset,
+}: {
+ dataset: Array;
+}) {
+ const environmentId = useEnvironmentId();
+ const isSwarm = useIsSwarm(environmentId);
+
+ const columns = useColumns(isSwarm);
+ return ;
+}
diff --git a/app/react/docker/networks/ListView/NetworksDatatable.tsx b/app/react/docker/networks/ListView/NetworksDatatable.tsx
new file mode 100644
index 000000000..26ba1fa2f
--- /dev/null
+++ b/app/react/docker/networks/ListView/NetworksDatatable.tsx
@@ -0,0 +1,113 @@
+import { Plus, Share2, Trash2 } from 'lucide-react';
+
+import { Authorized } from '@/react/hooks/useUser';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
+import {
+ BasicTableSettings,
+ createPersistedStore,
+ refreshableSettings,
+ RefreshableTableSettings,
+} from '@@/datatables/types';
+import { Button } from '@@/buttons';
+import { TableSettingsMenu } from '@@/datatables';
+import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
+import { useRepeater } from '@@/datatables/useRepeater';
+import { useTableState } from '@@/datatables/useTableState';
+import { Link } from '@@/Link';
+
+import { useIsSwarm } from '../../proxy/queries/useInfo';
+
+import { useColumns } from './columns';
+import { DecoratedNetwork } from './types';
+import { NestedNetworksDatatable } from './NestedNetwordsTable';
+
+const storageKey = 'docker.networks';
+
+interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
+
+const settingsStore = createPersistedStore(
+ storageKey,
+ 'name',
+ (set) => ({
+ ...refreshableSettings(set),
+ })
+);
+
+type DatasetType = Array;
+interface Props {
+ dataset: DatasetType;
+ onRemove(selectedItems: DatasetType): void;
+ onRefresh(): Promise;
+}
+
+export function NetworksDatatable({ dataset, onRemove, onRefresh }: Props) {
+ const settings = useTableState(settingsStore, storageKey);
+
+ const environmentId = useEnvironmentId();
+ const isSwarm = useIsSwarm(environmentId);
+
+ const columns = useColumns(isSwarm);
+
+ useRepeater(settings.autoRefreshRate, onRefresh);
+
+ return (
+
+ settingsManager={settings}
+ title="Networks"
+ titleIcon={Share2}
+ dataset={dataset}
+ columns={columns}
+ getRowCanExpand={({ original: item }) =>
+ !!(item.Subs && item.Subs?.length > 0)
+ }
+ isRowSelectable={({ original: item }) => !item.ResourceControl?.System}
+ renderSubRow={(row) => (
+ <>
+ {row.original.Subs && (
+
+
+
+ |
+
+ )}
+ >
+ )}
+ emptyContentLabel="No networks available."
+ renderTableActions={(selectedRows) => (
+
+
+
+
+
+
+
+
+ )}
+ renderTableSettings={() => (
+
+
+
+ )}
+ getRowId={(row) => `${row.Name}-${row.Id}`}
+ />
+ );
+}
diff --git a/app/react/docker/networks/ListView/columns/helper.ts b/app/react/docker/networks/ListView/columns/helper.ts
new file mode 100644
index 000000000..bdc6af12a
--- /dev/null
+++ b/app/react/docker/networks/ListView/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { DecoratedNetwork } from '../types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/docker/networks/ListView/columns/index.ts b/app/react/docker/networks/ListView/columns/index.ts
new file mode 100644
index 000000000..b2be3b05c
--- /dev/null
+++ b/app/react/docker/networks/ListView/columns/index.ts
@@ -0,0 +1,63 @@
+import _ from 'lodash';
+import { useMemo } from 'react';
+
+import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
+
+import { buildExpandColumn } from '@@/datatables/expand-column';
+
+import { DecoratedNetwork } from '../types';
+
+import { columnHelper } from './helper';
+import { name } from './name';
+
+export function useColumns(isHostColumnVisible?: boolean) {
+ return useMemo(
+ () =>
+ _.compact([
+ buildExpandColumn(),
+ name,
+ columnHelper.accessor((item) => item.StackName || '-', {
+ header: 'Stack',
+ }),
+ columnHelper.accessor('Driver', {
+ header: 'Driver',
+ }),
+ columnHelper.accessor('Attachable', {
+ header: 'Attachable',
+ }),
+ columnHelper.accessor('IPAM.Driver', {
+ header: 'IPAM Driver',
+ }),
+ columnHelper.accessor(
+ (item) => item.IPAM?.IPV4Configs?.[0]?.Subnet ?? '-',
+ {
+ header: 'IPV4 IPAM Subnet',
+ }
+ ),
+ columnHelper.accessor(
+ (item) => item.IPAM?.IPV4Configs?.[0]?.Gateway ?? '-',
+ {
+ header: 'IPV4 IPAM Gateway',
+ }
+ ),
+ columnHelper.accessor(
+ (item) => item.IPAM?.IPV6Configs?.[0]?.Subnet ?? '-',
+ {
+ header: 'IPV6 IPAM Subnet',
+ }
+ ),
+ columnHelper.accessor(
+ (item) => item.IPAM?.IPV6Configs?.[0]?.Gateway ?? '-',
+ {
+ header: 'IPV6 IPAM Gateway',
+ }
+ ),
+ isHostColumnVisible &&
+ columnHelper.accessor('NodeName', {
+ header: 'Node',
+ }),
+ createOwnershipColumn(),
+ ]),
+ [isHostColumnVisible]
+ );
+}
diff --git a/app/react/docker/networks/ListView/columns/name.tsx b/app/react/docker/networks/ListView/columns/name.tsx
new file mode 100644
index 000000000..77321a4a9
--- /dev/null
+++ b/app/react/docker/networks/ListView/columns/name.tsx
@@ -0,0 +1,31 @@
+import { truncate } from '@/portainer/filters/filters';
+
+import { Link } from '@@/Link';
+
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor('Name', {
+ header: 'Name',
+ id: 'name',
+ cell({ row: { original: item } }) {
+ return (
+ <>
+
+ {truncate(item.Name, 40)}
+
+ {item.ResourceControl?.System && (
+
+ System
+
+ )}
+ >
+ );
+ },
+});
diff --git a/app/react/docker/networks/ListView/types.ts b/app/react/docker/networks/ListView/types.ts
new file mode 100644
index 000000000..56cad2570
--- /dev/null
+++ b/app/react/docker/networks/ListView/types.ts
@@ -0,0 +1,11 @@
+import { IPAMConfig } from 'docker-types/generated/1.41';
+
+import { NetworkViewModel } from '@/docker/models/network';
+
+export type DecoratedNetwork = NetworkViewModel & {
+ Subs?: DecoratedNetwork[];
+ IPAM: NetworkViewModel['IPAM'] & {
+ IPV4Configs?: Array;
+ IPV6Configs?: Array;
+ };
+};