diff --git a/app/react/components/Tip/TextTip/TextTip.tsx b/app/react/components/Tip/TextTip/TextTip.tsx index afcace5e65..9f492f26eb 100644 --- a/app/react/components/Tip/TextTip/TextTip.tsx +++ b/app/react/components/Tip/TextTip/TextTip.tsx @@ -11,6 +11,7 @@ export interface Props { color?: Color; className?: string; childrenWrapperClassName?: string; + /** Whether the tip should be displayed inline or as a block. Defaults to inline. */ inline?: boolean; children: ReactNode; } diff --git a/app/react/edge/components/ConnectivityTestModal/ConnectivityTestModal.tsx b/app/react/edge/components/ConnectivityTestModal/ConnectivityTestModal.tsx new file mode 100644 index 0000000000..076c062a3a --- /dev/null +++ b/app/react/edge/components/ConnectivityTestModal/ConnectivityTestModal.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; + +import { useAgentDetails } from '@/react/portainer/environments/queries/useAgentDetails'; + +import { Code } from '@@/Code'; +import { CopyButton } from '@@/buttons/CopyButton'; +import { Modal } from '@@/modals/Modal'; +import { Button } from '@@/buttons'; +import { NavTabs } from '@@/NavTabs'; +import { NavContainer } from '@@/NavTabs/NavContainer'; +import { SwitchField } from '@@/form-components/SwitchField'; + +export type ConnectivityEnvironment = 'docker' | 'podman' | 'kubernetes'; + +const ALL_ENVIRONMENTS: ConnectivityEnvironment[] = [ + 'docker', + 'podman', + 'kubernetes', +]; + +const ENVIRONMENT_LABELS: Record = { + docker: 'Docker', + podman: 'Podman', + kubernetes: 'Kubernetes', +}; + +interface Props { + onDismiss: () => void; + portainerUrl: string; + tunnelServerAddr?: string; + /** When provided, show only this environment. When omitted, show tabs for all environments. */ + environment?: ConnectivityEnvironment; +} + +export function ConnectivityTestModal({ + onDismiss, + portainerUrl, + tunnelServerAddr, + environment, +}: Props) { + const environments = environment ? [environment] : ALL_ENVIRONMENTS; + const [selectedTab, setSelectedTab] = useState( + environments[0] + ); + const [insecurePoll, setInsecurePoll] = useState(true); + const agentDetails = useAgentDetails(); + const agentVersion = agentDetails?.agentVersion ?? 'latest'; + + const options = environments.map((env) => { + const command = buildCommand( + env, + portainerUrl, + tunnelServerAddr, + insecurePoll, + agentVersion + ); + const label = ENVIRONMENT_LABELS[env]; + return { + id: env, + label, + children: ( + <> + {command} +
+ + Copy command + +
+ + ), + }; + }); + + return ( + + + +

+ Run the command in the environment where the Edge Agent will be + deployed to verify it can reach the Portainer server. +

+
+ +
+ + setSelectedTab(id)} + /> + +
+ + + +
+ ); +} + +function buildCommand( + environment: ConnectivityEnvironment, + portainerUrl: string, + tunnelServerAddr?: string, + allowInsecurePoll?: boolean, + agentVersion = 'latest' +): string { + const envVars = [ + 'EDGE_CONNECTIVITY_CHECK=1', + `EDGE_CONNECTIVITY_CHECK_URL=${portainerUrl}`, + `EDGE_INSECURE_POLL=${allowInsecurePoll ? '1' : '0'}`, + ...(tunnelServerAddr + ? [`EDGE_CONNECTIVITY_CHECK_TUNNEL_ADDR=${tunnelServerAddr}`] + : []), + ]; + + const image = `portainer/agent:${agentVersion}`; + + switch (environment) { + case 'kubernetes': + return [ + `kubectl run portainer-connectivity-check \\`, + ` --rm --attach --restart=Never \\`, + ` --image=${image} \\`, + ...envVars.map((v, i) => + i < envVars.length - 1 ? ` --env="${v}" \\` : ` --env="${v}"` + ), + ].join('\n'); + case 'podman': + return [ + `sudo podman run --rm \\`, + ...envVars.map((v) => ` -e ${v} \\`), + ` docker.io/${image}`, + ].join('\n'); + case 'docker': + default: + return [ + `docker run --rm \\`, + ...envVars.map((v) => ` -e ${v} \\`), + ` ${image}`, + ].join('\n'); + } +} diff --git a/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx b/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx index 33d27b287c..119b8982f4 100644 --- a/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx +++ b/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; -import { Laptop } from 'lucide-react'; +import { Laptop, Network } from 'lucide-react'; import { generateKey } from '@/react/portainer/environments/environment.service/edge'; import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; @@ -8,12 +8,14 @@ import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts'; import { useSettings } from '@/react/portainer/settings/queries'; import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c'; import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c'; +import { ConnectivityTestModal } from '@/react/edge/components/ConnectivityTestModal/ConnectivityTestModal'; import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; import { TextTip } from '@@/Tip/TextTip'; import { BoxSelector } from '@@/BoxSelector'; import { FormSection } from '@@/form-components/FormSection'; import { CopyButton } from '@@/buttons'; +import { Button } from '@@/buttons'; import { Link } from '@@/Link'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; @@ -140,6 +142,8 @@ function EdgeKeyInfo({ tunnelUrl?: string; asyncMode: boolean; }) { + const [isConnectivityModalOpen, setIsConnectivityModalOpen] = useState(false); + if (isLoading || !edgeKey) { return
Generating key for {url} ...
; } @@ -192,7 +196,25 @@ function EdgeKeyInfo({ /> )} + + + + {isConnectivityModalOpen && url && ( + setIsConnectivityModalOpen(false)} + /> + )} ); } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx index 4a4fb5d63f..d984729c25 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx @@ -133,7 +133,7 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) { titleSize="sm" isFoldable defaultFolded={false} - className="[&>label]:mb-5" + className="mb-8" >

These are legacy options that don't support edge features or diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx index 550203ebad..c11fd36432 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardKubernetes/WizardKubernetes.tsx @@ -116,7 +116,7 @@ export function WizardKubernetes({ onCreate }: Props) { titleSize="sm" isFoldable defaultFolded={false} - className="[&>label]:mb-5" + className="mb-8" >

These are legacy options that don't support edge features or diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/WizardPodman.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/WizardPodman.tsx index 0ad2543c7d..d92dd89ce7 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/WizardPodman.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardPodman/WizardPodman.tsx @@ -108,7 +108,7 @@ export function WizardPodman({ onCreate }: Props) { titleSize="sm" isFoldable defaultFolded={false} - className="[&>label]:mb-5" + className="mb-8" >

These are legacy options that don't support edge features or diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentFieldset.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentFieldset.tsx index d519881922..371f094375 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentFieldset.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentFieldset.tsx @@ -1,14 +1,38 @@ +import { useState } from 'react'; +import { useFormikContext } from 'formik'; +import { Network } from 'lucide-react'; + import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { PortainerTunnelAddrField } from '@/react/portainer/common/PortainerTunnelAddrField'; import { PortainerUrlField } from '@/react/portainer/common/PortainerUrlField'; import { NameField } from '@/react/portainer/environments/common/NameField/NameField'; +import { ContainerEngine } from '@/react/portainer/environments/types'; +import { + ConnectivityEnvironment, + ConnectivityTestModal, +} from '@/react/edge/components/ConnectivityTestModal/ConnectivityTestModal'; + +import { Button } from '@@/buttons'; + +import { FormValues } from './types'; interface EdgeAgentFormProps { readonly?: boolean; asyncMode?: boolean; + containerEngine?: ContainerEngine; } -export function EdgeAgentFieldset({ readonly, asyncMode }: EdgeAgentFormProps) { +export function EdgeAgentFieldset({ + readonly, + asyncMode, + containerEngine = ContainerEngine.Docker, +}: EdgeAgentFormProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + const { values } = useFormikContext(); + + const showTunnelField = isBE && !asyncMode; + const environment = toConnectivityEnvironment(containerEngine); + return ( <> @@ -17,13 +41,52 @@ export function EdgeAgentFieldset({ readonly, asyncMode }: EdgeAgentFormProps) { readonly={readonly} required /> - {isBE && !asyncMode && ( + {showTunnelField && ( )} + +

+
+ +
+
+ + {isModalOpen && ( + setIsModalOpen(false)} + /> + )} ); } + +function toConnectivityEnvironment( + containerEngine: ContainerEngine +): ConnectivityEnvironment { + switch (containerEngine) { + case ContainerEngine.Kubernetes: + return 'kubernetes'; + case ContainerEngine.Podman: + return 'podman'; + case ContainerEngine.Docker: + default: + return 'docker'; + } +} diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx index 94531ef6b5..b5fb95b5b5 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx @@ -60,7 +60,11 @@ export function EdgeAgentForm({ > {({ isValid, setFieldValue, values }) => (
- + diff --git a/app/react/portainer/settings/EdgeComputeView/EdgeComputeSettings/EdgeComputeSettings.tsx b/app/react/portainer/settings/EdgeComputeView/EdgeComputeSettings/EdgeComputeSettings.tsx index c999b6a1b1..e4ee715bf7 100644 --- a/app/react/portainer/settings/EdgeComputeView/EdgeComputeSettings/EdgeComputeSettings.tsx +++ b/app/react/portainer/settings/EdgeComputeView/EdgeComputeSettings/EdgeComputeSettings.tsx @@ -1,16 +1,19 @@ import { Formik, Form } from 'formik'; -import { Laptop } from 'lucide-react'; +import { Laptop, Network } from 'lucide-react'; +import { useState } from 'react'; import { Settings } from '@/react/portainer/settings/types'; import { PortainerUrlField } from '@/react/portainer/common/PortainerUrlField'; import { PortainerTunnelAddrField } from '@/react/portainer/common/PortainerTunnelAddrField'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { ConnectivityTestModal } from '@/react/edge/components/ConnectivityTestModal/ConnectivityTestModal'; import { Switch } from '@@/form-components/SwitchField/Switch'; import { FormControl } from '@@/form-components/FormControl'; import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; import { LoadingButton } from '@@/buttons/LoadingButton'; import { TextTip } from '@@/Tip/TextTip'; +import { Button } from '@@/buttons'; import { validationSchema } from './EdgeComputeSettings.validation'; import { FormValues } from './types'; @@ -21,6 +24,8 @@ interface Props { } export function EdgeComputeSettings({ settings, onSubmit }: Props) { + const [isConnectivityModalOpen, setIsConnectivityModalOpen] = useState(false); + if (!settings) { return null; } @@ -92,6 +97,28 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) { /> + +
+
+ +
+
+ + {isConnectivityModalOpen && ( + setIsConnectivityModalOpen(false)} + /> + )} )}