From b15812a74d75e61747ec3d78ce9c18ab7c5490e1 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 7 Sep 2023 15:14:03 +0100 Subject: [PATCH] refactor(docker/containers): migrate networks table to react [EE-4665] (#10069) --- .../containerNetworksDatatable.html | 117 ------------------ .../containerNetworksDatatable.js | 17 --- .../containerNetworksDatatableController.js | 82 ------------ app/docker/react/components/containers.ts | 17 ++- .../views/containers/edit/container.html | 30 ++--- app/portainer/react/components/index.ts | 1 + .../form-components/PortainerSelect.tsx | 5 + app/react/docker/containers/ItemView/.keep | 0 .../ConnectNetworkForm.tsx | 85 +++++++++++++ .../ContainerNetworksDatatable.tsx | 73 +++++++++++ .../ContainerNetworksDatatable/actions.tsx | 60 +++++++++ .../ContainerNetworksDatatable/columns.tsx | 30 +++++ .../ContainerNetworksDatatable/helper.ts | 5 + .../ContainerNetworksDatatable/index.ts | 1 + .../ContainerNetworksDatatable/types.ts | 15 +++ .../containers/components/NetworkSelector.tsx | 71 +++++++++++ app/react/docker/networks/queries/buildUrl.ts | 20 +++ .../docker/networks/queries/queryKeys.ts | 12 ++ app/react/docker/networks/queries/types.ts | 23 ++++ .../networks/queries/useConnectContainer.ts | 73 +++++++++++ .../docker/networks/queries/useNetworks.ts | 59 +++++++++ app/react/docker/proxy/queries/useInfo.ts | 11 ++ app/react/docker/proxy/queries/useVersion.ts | 16 +-- app/react/docker/queries/utils.ts | 5 - app/react/docker/queries/utils/container.ts | 36 ++++++ app/react/docker/queries/utils/index.ts | 14 +++ app/react/sidebar/DockerSidebar.tsx | 7 +- app/setup-tests/setup-handlers/docker.ts | 6 +- 28 files changed, 632 insertions(+), 259 deletions(-) delete mode 100644 app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html delete mode 100644 app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js delete mode 100644 app/docker/components/datatables/container-networks-datatable/containerNetworksDatatableController.js delete mode 100644 app/react/docker/containers/ItemView/.keep create mode 100644 app/react/docker/containers/ItemView/ContainerNetworksDatatable/ConnectNetworkForm.tsx create mode 100644 app/react/docker/containers/ItemView/ContainerNetworksDatatable/ContainerNetworksDatatable.tsx create mode 100644 app/react/docker/containers/ItemView/ContainerNetworksDatatable/actions.tsx create mode 100644 app/react/docker/containers/ItemView/ContainerNetworksDatatable/columns.tsx create mode 100644 app/react/docker/containers/ItemView/ContainerNetworksDatatable/helper.ts create mode 100644 app/react/docker/containers/ItemView/ContainerNetworksDatatable/index.ts create mode 100644 app/react/docker/containers/ItemView/ContainerNetworksDatatable/types.ts create mode 100644 app/react/docker/containers/components/NetworkSelector.tsx create mode 100644 app/react/docker/networks/queries/buildUrl.ts create mode 100644 app/react/docker/networks/queries/queryKeys.ts create mode 100644 app/react/docker/networks/queries/types.ts create mode 100644 app/react/docker/networks/queries/useConnectContainer.ts create mode 100644 app/react/docker/networks/queries/useNetworks.ts delete mode 100644 app/react/docker/queries/utils.ts create mode 100644 app/react/docker/queries/utils/container.ts create mode 100644 app/react/docker/queries/utils/index.ts 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 - - - - - GatewayMAC AddressActions
- - {{ 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 }) => ( +
+ +
+
+ setFieldValue('networkId', value)} + hiddenNetworks={selectedNetworks} + /> +
+ + Join Network + +
+
+
+ )} + + ); + + 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>; + onChange: (value: string) => void; + hiddenNetworks?: string[]; +}) { + const networksQuery = useNetworksForSelector({ + select(networks) { + return networks.map((n) => ({ label: n.Name, value: n.Name })); + }, + }); + + const networks = networksQuery.data; + + const options = useMemo( + () => + (networks || []) + .concat(additionalOptions) + .filter((n) => !hiddenNetworks.includes(n.value)) + .sort((a, b) => a.label.localeCompare(b.label)), + [additionalOptions, hiddenNetworks, networks] + ); + + return ( + + ); +} + +export function useNetworksForSelector({ + select, +}: { + select?(networks: Array): T; +} = {}) { + const environmentId = useEnvironmentId(); + + const isSwarmQuery = useIsSwarm(environmentId); + const dockerApiVersion = useApiVersion(environmentId); + + return useNetworks( + environmentId, + { + local: true, + swarmAttachable: isSwarmQuery && dockerApiVersion >= 1.25, + }, + { + select, + } + ); +} diff --git a/app/react/docker/networks/queries/buildUrl.ts b/app/react/docker/networks/queries/buildUrl.ts new file mode 100644 index 000000000..38697791a --- /dev/null +++ b/app/react/docker/networks/queries/buildUrl.ts @@ -0,0 +1,20 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildUrl as buildDockerUrl } from '../../proxy/queries/build-url'; +import { NetworkId } from '../types'; + +export function buildUrl( + environmentId: EnvironmentId, + { id, action }: { id?: NetworkId; action?: string } = {} +) { + let baseUrl = 'networks'; + if (id) { + baseUrl += `/${id}`; + } + + if (action) { + baseUrl += `/${action}`; + } + + return buildDockerUrl(environmentId, baseUrl); +} diff --git a/app/react/docker/networks/queries/queryKeys.ts b/app/react/docker/networks/queries/queryKeys.ts new file mode 100644 index 000000000..20b912209 --- /dev/null +++ b/app/react/docker/networks/queries/queryKeys.ts @@ -0,0 +1,12 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys as dockerQueryKeys } from '../../queries/utils'; + +import { NetworksQuery } from './types'; + +export const queryKeys = { + base: (environmentId: EnvironmentId) => + [...dockerQueryKeys.root(environmentId), 'networks'] as const, + list: (environmentId: EnvironmentId, query: NetworksQuery) => + [...queryKeys.base(environmentId), 'list', query] as const, +}; diff --git a/app/react/docker/networks/queries/types.ts b/app/react/docker/networks/queries/types.ts new file mode 100644 index 000000000..fe876434a --- /dev/null +++ b/app/react/docker/networks/queries/types.ts @@ -0,0 +1,23 @@ +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[]; + // Matches a network's driver + driver?: string[]; + // Matches all or part of a network ID + id?: string[]; + // `label=` or `label==` of a network label. + label?: string[]; + // Matches all or part of a network name. + name?: string[]; + // Filters networks by scope (swarm, global, or local). + scope?: ('swarm' | 'global' | 'local')[]; + // Filters networks by type. The custom keyword returns all user-defined networks. + type?: ('custom' | 'builtin')[]; +} + +export interface NetworksQuery { + local?: boolean; + swarm?: boolean; + swarmAttachable?: boolean; + filters?: Filters; +} diff --git a/app/react/docker/networks/queries/useConnectContainer.ts b/app/react/docker/networks/queries/useConnectContainer.ts new file mode 100644 index 000000000..b23182f48 --- /dev/null +++ b/app/react/docker/networks/queries/useConnectContainer.ts @@ -0,0 +1,73 @@ +import { EndpointSettings } from 'docker-types/generated/1.41'; +import { AxiosRequestHeaders } from 'axios'; +import { useMutation, useQueryClient } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + mutationOptions, + withError, + withInvalidate, +} from '@/react-tools/react-query'; + +import { queryKeys as dockerQueryKeys } from '../../queries/utils'; + +import { buildUrl } from './buildUrl'; + +interface ConnectContainerPayload { + Container: string; + EndpointConfig?: EndpointSettings; +} + +export function useConnectContainerMutation(environmentId: EnvironmentId) { + const queryClient = useQueryClient(); + + return useMutation( + (params: Omit) => + connectContainer({ ...params, environmentId }), + mutationOptions( + withError('Failed connecting container to network'), + withInvalidate(queryClient, [dockerQueryKeys.containers(environmentId)]) + ) + ); +} + +interface ConnectContainer { + environmentId: EnvironmentId; + networkId: string; + containerId: string; + aliases?: EndpointSettings['Aliases']; + nodeName?: string; +} + +export async function connectContainer({ + environmentId, + containerId, + networkId, + aliases, + nodeName, +}: ConnectContainer) { + const payload: ConnectContainerPayload = { + Container: containerId, + }; + if (aliases) { + payload.EndpointConfig = { + Aliases: aliases, + }; + } + + const headers: AxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + try { + await axios.post( + buildUrl(environmentId, { id: networkId, action: 'connect' }), + payload + ); + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to connect container'); + } +} diff --git a/app/react/docker/networks/queries/useNetworks.ts b/app/react/docker/networks/queries/useNetworks.ts new file mode 100644 index 000000000..64a42d255 --- /dev/null +++ b/app/react/docker/networks/queries/useNetworks.ts @@ -0,0 +1,59 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildUrl } from '../../proxy/queries/build-url'; +import { DockerNetwork } from '../types'; + +import { queryKeys } from './queryKeys'; +import { NetworksQuery } from './types'; + +export function useNetworks>( + environmentId: EnvironmentId, + query: NetworksQuery, + { + enabled = true, + onSuccess, + select, + }: { + enabled?: boolean; + onSuccess?(networks: T): void; + select?(networks: Array): T; + } = {} +) { + return useQuery( + queryKeys.list(environmentId, query), + () => getNetworks(environmentId, query), + { enabled, onSuccess, select } + ); +} + +export async function getNetworks( + environmentId: EnvironmentId, + { local, swarm, swarmAttachable, filters }: NetworksQuery +) { + try { + const { data } = await axios.get>( + buildUrl(environmentId, 'networks'), + filters && { + params: { + filters, + }, + } + ); + + return !local && !swarm && !swarmAttachable + ? data + : data.filter( + (network) => + (local && network.Scope === 'local') || + (swarm && network.Scope === 'swarm') || + (swarmAttachable && + network.Scope === 'swarm' && + network.Attachable === true) + ); + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve networks'); + } +} diff --git a/app/react/docker/proxy/queries/useInfo.ts b/app/react/docker/proxy/queries/useInfo.ts index aac9076fc..6f318189c 100644 --- a/app/react/docker/proxy/queries/useInfo.ts +++ b/app/react/docker/proxy/queries/useInfo.ts @@ -41,3 +41,14 @@ export function useIsSwarm(environmentId: EnvironmentId) { return !!query.data; } + +export function useSystemLimits(environmentId: EnvironmentId) { + const infoQuery = useInfo(environmentId); + + const maxCpu = infoQuery.data?.NCPU || 32; + const maxMemory = infoQuery.data?.MemTotal + ? Math.floor(infoQuery.data.MemTotal / 1000 / 1000) + : 32768; + + return { maxCpu, maxMemory }; +} diff --git a/app/react/docker/proxy/queries/useVersion.ts b/app/react/docker/proxy/queries/useVersion.ts index 83d98ad44..e2912b4c1 100644 --- a/app/react/docker/proxy/queries/useVersion.ts +++ b/app/react/docker/proxy/queries/useVersion.ts @@ -1,17 +1,14 @@ import { useQuery } from 'react-query'; +import { SystemVersion } from 'docker-types/generated/1.41'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { buildUrl } from './build-url'; -export interface VersionResponse { - ApiVersion: string; -} - export async function getVersion(environmentId: EnvironmentId) { try { - const { data } = await axios.get( + const { data } = await axios.get( buildUrl(environmentId, 'version') ); return data; @@ -20,9 +17,9 @@ export async function getVersion(environmentId: EnvironmentId) { } } -export function useVersion( +export function useVersion( environmentId: EnvironmentId, - select?: (info: VersionResponse) => TSelect + select?: (info: SystemVersion) => TSelect ) { return useQuery( ['environment', environmentId, 'docker', 'version'], @@ -32,3 +29,8 @@ export function useVersion( } ); } + +export function useApiVersion(environmentId: EnvironmentId) { + const query = useVersion(environmentId, (info) => info.ApiVersion); + return query.data ? parseFloat(query.data) : 0; +} diff --git a/app/react/docker/queries/utils.ts b/app/react/docker/queries/utils.ts deleted file mode 100644 index 3f29e8ae7..000000000 --- a/app/react/docker/queries/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; - -export const queryKeys = { - root: (environmentId: EnvironmentId) => ['docker', environmentId] as const, -}; diff --git a/app/react/docker/queries/utils/container.ts b/app/react/docker/queries/utils/container.ts new file mode 100644 index 000000000..fabba745e --- /dev/null +++ b/app/react/docker/queries/utils/container.ts @@ -0,0 +1,36 @@ +import { DockerContainer } from '@/react/docker/containers/types'; +import { EdgeStack } from '@/react/edge/edge-stacks/types'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerSnapshotUrl, queryKeys as rootQueryKeys } from './root'; + +export interface ContainersQueryParams { + edgeStackId?: EdgeStack['Id']; +} + +export const queryKeys = { + ...rootQueryKeys, + containers: (environmentId: EnvironmentId) => + [...queryKeys.snapshot(environmentId), 'containers'] as const, + containersQuery: ( + environmentId: EnvironmentId, + params: ContainersQueryParams + ) => [...queryKeys.containers(environmentId), params] as const, + container: ( + environmentId: EnvironmentId, + containerId: DockerContainer['Id'] + ) => [...queryKeys.containers(environmentId), containerId] as const, +}; + +export function buildDockerSnapshotContainersUrl( + environmentId: EnvironmentId, + containerId?: DockerContainer['Id'] +) { + let url = `${buildDockerSnapshotUrl(environmentId)}/containers`; + + if (containerId) { + url += `/${containerId}`; + } + + return url; +} diff --git a/app/react/docker/queries/utils/index.ts b/app/react/docker/queries/utils/index.ts new file mode 100644 index 000000000..eb0e0ddd4 --- /dev/null +++ b/app/react/docker/queries/utils/index.ts @@ -0,0 +1,14 @@ +import { queryKeys as containerQueryKeys } from './container'; +import { queryKeys as rootQueryKeys } from './root'; + +export const queryKeys = { + ...rootQueryKeys, + ...containerQueryKeys, +}; + +export { + buildDockerSnapshotContainersUrl, + type ContainersQueryParams, +} from './container'; + +export { buildDockerSnapshotUrl } from './root'; diff --git a/app/react/sidebar/DockerSidebar.tsx b/app/react/sidebar/DockerSidebar.tsx index 1ec43458c..68a89c16e 100644 --- a/app/react/sidebar/DockerSidebar.tsx +++ b/app/react/sidebar/DockerSidebar.tsx @@ -17,7 +17,7 @@ import { } from '@/react/portainer/environments/types'; import { Authorized, useUser, isEnvironmentAdmin } from '@/react/hooks/useUser'; import { useInfo } from '@/react/docker/proxy/queries/useInfo'; -import { useVersion } from '@/react/docker/proxy/queries/useVersion'; +import { useApiVersion } from '@/react/docker/proxy/queries/useVersion'; import { SidebarItem } from './SidebarItem'; import { DashboardLink } from './items/DashboardLink'; @@ -40,12 +40,9 @@ export function DockerSidebar({ environmentId, environment }: Props) { (info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable ); - const envVersionQuery = useVersion(environmentId, (version) => - parseFloat(version.ApiVersion) - ); + const apiVersion = useApiVersion(environmentId); const isSwarmManager = envInfoQuery.data; - const apiVersion = envVersionQuery.data || 0; const setupSubMenuProps = isSwarmManager ? { diff --git a/app/setup-tests/setup-handlers/docker.ts b/app/setup-tests/setup-handlers/docker.ts index 3c2c0d28c..931f7fcc3 100644 --- a/app/setup-tests/setup-handlers/docker.ts +++ b/app/setup-tests/setup-handlers/docker.ts @@ -1,7 +1,5 @@ import { DefaultBodyType, PathParams, rest } from 'msw'; -import { SystemInfo } from 'docker-types/generated/1.41'; - -import { VersionResponse } from '@/react/docker/proxy/queries/useVersion'; +import { SystemInfo, SystemVersion } from 'docker-types/generated/1.41'; export const dockerHandlers = [ rest.get( @@ -16,7 +14,7 @@ export const dockerHandlers = [ }) ) ), - rest.get( + rest.get( '/api/endpoints/:endpointId/docker/version', (req, res, ctx) => res(ctx.json({ ApiVersion: '1.24' })) ),