1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

refactor(docker/containers): migrate networks table to react [EE-4665] (#10069)

This commit is contained in:
Chaim Lev-Ari 2023-09-07 15:14:03 +01:00 committed by GitHub
parent 776f6a62c3
commit b15812a74d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 632 additions and 259 deletions

View file

@ -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 (
<Formik<FormValues>
initialValues={{ networkId: '' }}
onSubmit={handleSubmit}
validationSchema={validation}
>
{({ values, errors, setFieldValue }) => (
<Form className="form-horizontal w-full">
<FormControl
label="Join a network"
className="!mb-0"
errors={errors.networkId}
>
<div className="flex items-center gap-4">
<div className="w-full">
<NetworkSelector
value={values.networkId}
onChange={(value) => setFieldValue('networkId', value)}
hiddenNetworks={selectedNetworks}
/>
</div>
<LoadingButton
loadingText="Joining network..."
isLoading={connectMutation.isLoading}
>
Join Network
</LoadingButton>
</div>
</FormControl>
</Form>
)}
</Formik>
);
function handleSubmit({ networkId }: { networkId: string }) {
connectMutation.mutate(
{ containerId, networkId, nodeName },
{
onSuccess() {
router.stateService.reload();
},
}
);
}
}
function validation(): SchemaOf<FormValues> {
return object({
networkId: string().required('Please select a network'),
});
}

View file

@ -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<TableNetwork> = Object.entries(dataset || {})
.filter(isNetworkDefined)
.map(([id, network]) => ({
...network,
id,
name: id,
}));
return (
<ExpandableDatatable<TableNetwork>
columns={columns}
dataset={networks}
settingsManager={tableState}
title="Connected Networks"
titleIcon={Share2}
disableSelect
getRowCanExpand={(row) => !!row.original.GlobalIPv6Address}
isLoading={!dataset}
renderSubRow={({ original: item }) => (
<tr className="datatable-highlighted">
<td colSpan={2} />
<td>{item.GlobalIPv6Address}</td>
<td colSpan={3}>{item.IPv6Gateway || '-'}</td>
</tr>
)}
description={
<ConnectNetworkForm
containerId={container.Id}
nodeName={nodeName}
selectedNetworks={networks.map((n) => 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];
}

View file

@ -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<TableNetwork, unknown>) {
const router = useRouter();
const environmentId = useEnvironmentId();
const disconnectMutation = useDisconnectContainer();
return (
<Authorized authorizations="DockerNetworkDisconnect">
<LoadingButton
color="dangerlight"
isLoading={disconnectMutation.isLoading}
loadingText="Leaving network..."
type="button"
onClick={handleSubmit}
>
Leave network
</LoadingButton>
</Authorized>
);
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();
},
}
);
}
}

View file

@ -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<TableNetwork>(),
{
...buildNameColumn<TableNetwork>('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,
];

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { TableNetwork } from './types';
export const columnHelper = createColumnHelper<TableNetwork>();

View file

@ -0,0 +1 @@
export { ContainerNetworksDatatable } from './ContainerNetworksDatatable';

View file

@ -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<TableNetwork> & {
table: 'container-networks';
containerId: string;
};
export function isContainerNetworkTableMeta(
meta?: TableMeta<TableNetwork>
): meta is ContainerNetworkTableMeta {
return !!meta && meta.table === 'container-networks';
}

View file

@ -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<Option<string>>;
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 (
<PortainerSelect
value={value}
onChange={onChange}
options={options}
isLoading={networksQuery.isLoading}
bindToBody
placeholder="Select a network"
/>
);
}
export function useNetworksForSelector<T = DockerNetwork[]>({
select,
}: {
select?(networks: Array<DockerNetwork>): T;
} = {}) {
const environmentId = useEnvironmentId();
const isSwarmQuery = useIsSwarm(environmentId);
const dockerApiVersion = useApiVersion(environmentId);
return useNetworks(
environmentId,
{
local: true,
swarmAttachable: isSwarmQuery && dockerApiVersion >= 1.25,
},
{
select,
}
);
}

View file

@ -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);
}

View file

@ -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,
};

View file

@ -0,0 +1,23 @@
interface Filters {
/* dangling=<boolean> 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=<key>` or `label=<key>=<value>` 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;
}

View file

@ -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, 'environmentId'>) =>
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');
}
}

View file

@ -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<T = Array<DockerNetwork>>(
environmentId: EnvironmentId,
query: NetworksQuery,
{
enabled = true,
onSuccess,
select,
}: {
enabled?: boolean;
onSuccess?(networks: T): void;
select?(networks: Array<DockerNetwork>): 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<Array<DockerNetwork>>(
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');
}
}

View file

@ -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 };
}

View file

@ -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<VersionResponse>(
const { data } = await axios.get<SystemVersion>(
buildUrl(environmentId, 'version')
);
return data;
@ -20,9 +17,9 @@ export async function getVersion(environmentId: EnvironmentId) {
}
}
export function useVersion<TSelect = VersionResponse>(
export function useVersion<TSelect = SystemVersion>(
environmentId: EnvironmentId,
select?: (info: VersionResponse) => TSelect
select?: (info: SystemVersion) => TSelect
) {
return useQuery(
['environment', environmentId, 'docker', 'version'],
@ -32,3 +29,8 @@ export function useVersion<TSelect = VersionResponse>(
}
);
}
export function useApiVersion(environmentId: EnvironmentId) {
const query = useVersion(environmentId, (info) => info.ApiVersion);
return query.data ? parseFloat(query.data) : 0;
}

View file

@ -1,5 +0,0 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = {
root: (environmentId: EnvironmentId) => ['docker', environmentId] as const,
};

View file

@ -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;
}

View file

@ -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';