diff --git a/app/docker/components/network-macvlan-form/networkMacvlanFormController.js b/app/docker/components/network-macvlan-form/networkMacvlanFormController.js
index c654f979d..3080095fa 100644
--- a/app/docker/components/network-macvlan-form/networkMacvlanFormController.js
+++ b/app/docker/components/network-macvlan-form/networkMacvlanFormController.js
@@ -12,11 +12,18 @@ angular.module('portainer.docker').controller('NetworkMacvlanFormController', [
this.options = [];
+ ctrl.onChangeSelectedNodes = onChangeSelectedNodes.bind(ctrl);
+ function onChangeSelectedNodes(nodes) {
+ return $scope.$evalAsync(() => {
+ ctrl.data.DatatableState.selectedItems = nodes;
+ });
+ }
+
ctrl.requiredNodeSelection = function () {
if (ctrl.data.Scope !== 'local' || ctrl.data.DatatableState === undefined) {
return false;
}
- return ctrl.data.DatatableState.selectedItemCount === 0;
+ return ctrl.data.DatatableState.selectedItems.length;
};
ctrl.requiredConfigSelection = function () {
diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js
index 97dfe59c4..4f5944e25 100644
--- a/app/docker/filters/filters.js
+++ b/app/docker/filters/filters.js
@@ -1,5 +1,5 @@
import _ from 'lodash-es';
-import { joinCommand, taskStatusBadge, trimSHA } from './utils';
+import { joinCommand, taskStatusBadge, nodeStatusBadge, trimSHA } from './utils';
function includeString(text, values) {
return values.some(function (val) {
@@ -75,15 +75,7 @@ angular
return 'success';
};
})
- .filter('nodestatusbadge', function () {
- 'use strict';
- return function (text) {
- if (text === 'down' || text === 'Unhealthy') {
- return 'danger';
- }
- return 'success';
- };
- })
+ .filter('nodestatusbadge', () => nodeStatusBadge)
.filter('dockerNodeAvailabilityBadge', function () {
'use strict';
return function (text) {
diff --git a/app/docker/filters/utils.ts b/app/docker/filters/utils.ts
index 5acd7f9be..144df280e 100644
--- a/app/docker/filters/utils.ts
+++ b/app/docker/filters/utils.ts
@@ -1,5 +1,5 @@
+import { NodeStatus, TaskState } from 'docker-types/generated/1.41';
import _ from 'lodash';
-import { TaskState } from 'docker-types/generated/1.41';
export function trimSHA(imageName: string) {
if (!imageName) {
@@ -53,3 +53,11 @@ export function taskStatusBadge(text?: TaskState) {
}
return 'default';
}
+
+export function nodeStatusBadge(text: NodeStatus['State']) {
+ if (text === 'down' || text === 'unknown' || text === 'disconnected') {
+ return 'danger';
+ }
+
+ return 'success';
+}
diff --git a/app/docker/models/node.js b/app/docker/models/node.js
deleted file mode 100644
index 9386bf984..000000000
--- a/app/docker/models/node.js
+++ /dev/null
@@ -1,47 +0,0 @@
-export function NodeViewModel(data) {
- this.Model = data;
- this.Id = data.ID;
- this.Version = data.Version.Index;
- this.Name = data.Spec.Name;
- this.Role = data.Spec.Role;
- this.CreatedAt = data.CreatedAt;
- this.UpdatedAt = data.UpdatedAt;
- this.Availability = data.Spec.Availability;
-
- var labels = data.Spec.Labels;
- if (labels) {
- this.Labels = Object.keys(labels).map(function (key) {
- return { key: key, value: labels[key], originalKey: key, originalValue: labels[key], added: true };
- });
- } else {
- this.Labels = [];
- }
-
- var engineLabels = data.Description.Engine.Labels;
- if (engineLabels) {
- this.EngineLabels = Object.keys(engineLabels).map(function (key) {
- return { key: key, value: engineLabels[key] };
- });
- } else {
- this.EngineLabels = [];
- }
-
- this.Hostname = data.Description.Hostname;
- this.PlatformArchitecture = data.Description.Platform.Architecture;
- this.PlatformOS = data.Description.Platform.OS;
- this.CPUs = data.Description.Resources.NanoCPUs;
- this.Memory = data.Description.Resources.MemoryBytes;
- this.EngineVersion = data.Description.Engine.EngineVersion;
- this.Plugins = data.Description.Engine.Plugins;
- this.Status = data.Status.State;
-
- if (data.Status.Addr) {
- this.Addr = data.Status.Addr;
- }
-
- if (data.ManagerStatus) {
- this.Leader = data.ManagerStatus.Leader;
- this.Reachability = data.ManagerStatus.Reachability;
- this.ManagerAddr = data.ManagerStatus.Addr;
- }
-}
diff --git a/app/docker/models/node.ts b/app/docker/models/node.ts
new file mode 100644
index 000000000..8cf806881
--- /dev/null
+++ b/app/docker/models/node.ts
@@ -0,0 +1,120 @@
+import {
+ Node,
+ EngineDescription,
+ ManagerStatus,
+ NodeDescription,
+ NodeSpec,
+ NodeStatus,
+ ObjectVersion,
+ Platform,
+ ResourceObject,
+} from 'docker-types/generated/1.41';
+
+type WithRequiredProperty = Type & {
+ [Property in Key]-?: Type[Property];
+};
+
+export class NodeViewModel {
+ Model: Node;
+
+ Id: Node['ID'];
+
+ Version: ObjectVersion['Index'];
+
+ Name: NodeSpec['Name'];
+
+ Role: NodeSpec['Role'];
+
+ CreatedAt: Node['CreatedAt'];
+
+ UpdatedAt: Node['UpdatedAt'];
+
+ Availability: NodeSpec['Availability'];
+
+ Labels: Array<{
+ key: string;
+ value: string;
+ originalKey: string;
+ originalValue: string;
+ added: boolean;
+ }>;
+
+ EngineLabels: Array<{ key: string; value: string }>;
+
+ Hostname: NodeDescription['Hostname'];
+
+ PlatformArchitecture: Platform['Architecture'];
+
+ PlatformOS: Platform['OS'];
+
+ CPUs: ResourceObject['NanoCPUs'];
+
+ Memory: ResourceObject['MemoryBytes'];
+
+ EngineVersion: EngineDescription['EngineVersion'];
+
+ Plugins: EngineDescription['Plugins'];
+
+ Status: NodeStatus['State'];
+
+ Addr: WithRequiredProperty['Addr'] = '';
+
+ Leader: ManagerStatus['Leader'];
+
+ Reachability: ManagerStatus['Reachability'];
+
+ ManagerAddr: ManagerStatus['Addr'];
+
+ constructor(data: Node) {
+ this.Model = data;
+ this.Id = data.ID;
+ this.Version = data.Version?.Index;
+ this.Name = data.Spec?.Name;
+ this.Role = data.Spec?.Role;
+ this.CreatedAt = data.CreatedAt;
+ this.UpdatedAt = data.UpdatedAt;
+ this.Availability = data.Spec?.Availability;
+
+ const labels = data.Spec?.Labels;
+ if (labels) {
+ this.Labels = Object.keys(labels).map((key) => ({
+ key,
+ value: labels[key],
+ originalKey: key,
+ originalValue: labels[key],
+ added: true,
+ }));
+ } else {
+ this.Labels = [];
+ }
+
+ const engineLabels = data.Description?.Engine?.Labels;
+ if (engineLabels) {
+ this.EngineLabels = Object.keys(engineLabels).map((key) => ({
+ key,
+ value: engineLabels[key],
+ }));
+ } else {
+ this.EngineLabels = [];
+ }
+
+ this.Hostname = data.Description?.Hostname;
+ this.PlatformArchitecture = data.Description?.Platform?.Architecture;
+ this.PlatformOS = data.Description?.Platform?.OS;
+ this.CPUs = data.Description?.Resources?.NanoCPUs;
+ this.Memory = data.Description?.Resources?.MemoryBytes;
+ this.EngineVersion = data.Description?.Engine?.EngineVersion;
+ this.Plugins = data.Description?.Engine?.Plugins;
+ this.Status = data.Status?.State;
+
+ if (data.Status?.Addr) {
+ this.Addr = data.Status?.Addr;
+ }
+
+ if (data.ManagerStatus) {
+ this.Leader = data.ManagerStatus.Leader;
+ this.Reachability = data.ManagerStatus.Reachability;
+ this.ManagerAddr = data.ManagerStatus.Addr;
+ }
+ }
+}
diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts
index 0f6bf847f..4e466be12 100644
--- a/app/docker/react/components/index.ts
+++ b/app/docker/react/components/index.ts
@@ -27,11 +27,13 @@ import { StacksDatatable } from '@/react/docker/stacks/ListView/StacksDatatable'
import { containersModule } from './containers';
import { servicesModule } from './services';
+import { networksModule } from './networks';
const ngModule = angular
.module('portainer.docker.react.components', [
containersModule,
servicesModule,
+ networksModule,
])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
diff --git a/app/docker/react/components/networks.ts b/app/docker/react/components/networks.ts
new file mode 100644
index 000000000..eb6b4e72c
--- /dev/null
+++ b/app/docker/react/components/networks.ts
@@ -0,0 +1,18 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { MacvlanNodesSelector } from '@/react/docker/networks/CreateView/MacvlanNodesSelector/MacvlanNodesSelector';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+
+export const networksModule = angular
+ .module('portainer.docker.react.components.networks', [])
+ .component(
+ 'macvlanNodesSelector',
+ r2a(withUIRouter(MacvlanNodesSelector), [
+ 'dataset',
+ 'isIpColumnVisible',
+ 'haveAccessToNode',
+ 'value',
+ 'onChange',
+ ])
+ ).name;
diff --git a/app/react/docker/networks/CreateView/MacvlanNodesSelector/MacvlanNodesSelector.tsx b/app/react/docker/networks/CreateView/MacvlanNodesSelector/MacvlanNodesSelector.tsx
new file mode 100644
index 000000000..fd071f209
--- /dev/null
+++ b/app/react/docker/networks/CreateView/MacvlanNodesSelector/MacvlanNodesSelector.tsx
@@ -0,0 +1,55 @@
+import { HardDrive } from 'lucide-react';
+
+import { NodeViewModel } from '@/docker/models/node';
+
+import { Datatable } from '@@/datatables';
+import { createPersistedStore } from '@@/datatables/types';
+import { useTableState } from '@@/datatables/useTableState';
+import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
+import { withMeta } from '@@/datatables/extend-options/withMeta';
+import { withControlledSelected } from '@@/datatables/extend-options/withControlledSelected';
+
+import { useColumns } from './columns';
+
+const tableKey = 'macvlan-nodes-selector';
+const store = createPersistedStore(tableKey);
+
+export function MacvlanNodesSelector({
+ dataset,
+ isIpColumnVisible,
+ haveAccessToNode,
+ value,
+ onChange,
+}: {
+ dataset?: Array;
+ isIpColumnVisible: boolean;
+ haveAccessToNode: boolean;
+ value: Array;
+ onChange(value: Array): void;
+}) {
+ const columns = useColumns(isIpColumnVisible);
+ const tableState = useTableState(store, tableKey);
+
+ return (
+
+ title="Select the nodes where you want to deploy the local configuration"
+ titleIcon={HardDrive}
+ columns={columns}
+ dataset={dataset || []}
+ isLoading={!dataset}
+ emptyContentLabel="No node available"
+ settingsManager={tableState}
+ extendTableOptions={mergeOptions(
+ withMeta({
+ table: 'macvlan-nodes',
+ haveAccessToNode,
+ }),
+ withControlledSelected(
+ (ids) =>
+ onChange(dataset?.filter((n) => n.Id && ids.includes(n.Id)) || []),
+ value.map((n) => n.Id || '')
+ )
+ )}
+ />
+ );
+}
diff --git a/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/column-helper.ts b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/column-helper.ts
new file mode 100644
index 000000000..ea1f2d20a
--- /dev/null
+++ b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/column-helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { NodeViewModel } from '@/docker/models/node';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/index.ts b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/index.ts
new file mode 100644
index 000000000..cbbf34268
--- /dev/null
+++ b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/index.ts
@@ -0,0 +1,20 @@
+import _ from 'lodash';
+
+import { columnHelper } from './column-helper';
+import { name } from './name';
+import { status } from './status';
+
+export function useColumns(isIpColumnVisible: boolean) {
+ return _.compact([
+ name,
+ columnHelper.accessor('Role', {}),
+ columnHelper.accessor('EngineVersion', {
+ header: 'Engine',
+ }),
+ isIpColumnVisible &&
+ columnHelper.accessor('Addr', {
+ header: 'IP Address',
+ }),
+ status,
+ ]);
+}
diff --git a/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/name.tsx b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/name.tsx
new file mode 100644
index 000000000..ffb99a51a
--- /dev/null
+++ b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/name.tsx
@@ -0,0 +1,38 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { NodeViewModel } from '@/docker/models/node';
+
+import { Link } from '@@/Link';
+
+import { isTableMeta } from '../types';
+
+import { columnHelper } from './column-helper';
+
+export const name = columnHelper.accessor('Hostname', {
+ header: 'Name',
+ cell: Cell,
+});
+
+function Cell({
+ getValue,
+ row: { original: item },
+ table: {
+ options: { meta },
+ },
+}: CellContext) {
+ if (!isTableMeta(meta)) {
+ throw new Error('Invalid table meta');
+ }
+
+ const value = getValue();
+
+ if (!meta.haveAccessToNode) {
+ return <>{value}>;
+ }
+
+ return (
+
+ {value}
+
+ );
+}
diff --git a/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/status.tsx b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/status.tsx
new file mode 100644
index 000000000..88642d7ac
--- /dev/null
+++ b/app/react/docker/networks/CreateView/MacvlanNodesSelector/columns/status.tsx
@@ -0,0 +1,16 @@
+import clsx from 'clsx';
+
+import { nodeStatusBadge } from '@/docker/filters/utils';
+
+import { columnHelper } from './column-helper';
+
+export const status = columnHelper.accessor('Status', {
+ cell: ({ getValue }) => {
+ const value = getValue();
+ return (
+
+ {value}
+
+ );
+ },
+});
diff --git a/app/react/docker/networks/CreateView/MacvlanNodesSelector/index.ts b/app/react/docker/networks/CreateView/MacvlanNodesSelector/index.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/react/docker/networks/CreateView/MacvlanNodesSelector/types.ts b/app/react/docker/networks/CreateView/MacvlanNodesSelector/types.ts
new file mode 100644
index 000000000..290c64a5d
--- /dev/null
+++ b/app/react/docker/networks/CreateView/MacvlanNodesSelector/types.ts
@@ -0,0 +1,14 @@
+import { TableMeta as BaseTableMeta } from '@tanstack/react-table';
+
+import { NodeViewModel } from '@/docker/models/node';
+
+export type TableMeta = BaseTableMeta & {
+ table: 'macvlan-nodes';
+ haveAccessToNode: boolean;
+};
+
+export function isTableMeta(
+ meta?: BaseTableMeta
+): meta is TableMeta {
+ return !!meta && meta.table === 'macvlan-nodes';
+}