feat(environments): offer edge connectivity test before adding edge environments [c9s-149] (#2527)

This commit is contained in:
Ali
2026-05-12 16:25:39 +12:00
committed by GitHub
parent b3a9386607
commit edff47fd41
9 changed files with 283 additions and 8 deletions
@@ -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;
}
@@ -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<ConnectivityEnvironment, string> = {
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<ConnectivityEnvironment>(
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: (
<>
<Code>{command}</Code>
<div className="mt-2">
<CopyButton
copyText={command}
data-cy="copy-connectivity-test-command-button"
>
Copy command
</CopyButton>
</div>
</>
),
};
});
return (
<Modal onDismiss={onDismiss} aria-label="Test connectivity" size="lg">
<Modal.Header title="Test connectivity" />
<Modal.Body>
<p className="mb-4">
Run the command in the environment where the Edge Agent will be
deployed to verify it can reach the Portainer server.
</p>
<div className="mb-4">
<SwitchField
checked={insecurePoll}
onChange={setInsecurePoll}
label="Allow self-signed certificates"
labelClass="col-sm-4 col-lg-3"
tooltip="Include EDGE_INSECURE_POLL=1 in the script. Enable this if your Portainer instance uses a self-signed or untrusted certificate."
data-cy="connectivity-insecure-poll-switch"
/>
</div>
<NavContainer>
<NavTabs
selectedId={selectedTab}
options={options}
onSelect={(id: ConnectivityEnvironment) => setSelectedTab(id)}
/>
</NavContainer>
</Modal.Body>
<Modal.Footer>
<Button
onClick={onDismiss}
color="default"
data-cy="close-connectivity-test-modal-button"
>
Close
</Button>
</Modal.Footer>
</Modal>
);
}
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');
}
}
@@ -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 <div>Generating key for {url} ... </div>;
}
@@ -192,7 +196,25 @@ function EdgeKeyInfo({
/>
</FormControl>
)}
<Button
color="default"
className="!ml-0 mb-8"
icon={Network}
onClick={() => setIsConnectivityModalOpen(true)}
data-cy="edge-auto-create-test-connectivity-button"
>
Test connectivity
</Button>
</EdgeScriptForm>
{isConnectivityModalOpen && url && (
<ConnectivityTestModal
portainerUrl={url}
tunnelServerAddr={!asyncMode ? tunnelUrl : undefined}
onDismiss={() => setIsConnectivityModalOpen(false)}
/>
)}
</>
);
}
@@ -133,7 +133,7 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
className="mb-8"
>
<p className="text-muted mb-2 text-xs">
These are legacy options that don&apos;t support edge features or
@@ -116,7 +116,7 @@ export function WizardKubernetes({ onCreate }: Props) {
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
className="mb-8"
>
<p className="text-muted mb-2 text-xs">
These are legacy options that don&apos;t support edge features or
@@ -108,7 +108,7 @@ export function WizardPodman({ onCreate }: Props) {
titleSize="sm"
isFoldable
defaultFolded={false}
className="[&>label]:mb-5"
className="mb-8"
>
<p className="text-muted mb-2 text-xs">
These are legacy options that don&apos;t support edge features or
@@ -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<FormValues>();
const showTunnelField = isBE && !asyncMode;
const environment = toConnectivityEnvironment(containerEngine);
return (
<>
<NameField readonly={readonly} />
@@ -17,13 +41,52 @@ export function EdgeAgentFieldset({ readonly, asyncMode }: EdgeAgentFormProps) {
readonly={readonly}
required
/>
{isBE && !asyncMode && (
{showTunnelField && (
<PortainerTunnelAddrField
fieldName="tunnelServerAddr"
readonly={readonly}
required
/>
)}
<div className="form-group">
<div className="col-sm-12">
<Button
color="default"
className="!ml-0"
icon={Network}
onClick={() => setIsModalOpen(true)}
data-cy="edge-agent-test-connectivity-button"
>
Test connectivity
</Button>
</div>
</div>
{isModalOpen && (
<ConnectivityTestModal
portainerUrl={values.portainerUrl}
tunnelServerAddr={
showTunnelField ? values.tunnelServerAddr : undefined
}
environment={environment}
onDismiss={() => 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';
}
}
@@ -60,7 +60,11 @@ export function EdgeAgentForm({
>
{({ isValid, setFieldValue, values }) => (
<Form>
<EdgeAgentFieldset readonly={readonly} asyncMode={asyncMode} />
<EdgeAgentFieldset
readonly={readonly}
asyncMode={asyncMode}
containerEngine={containerEngine}
/>
<MoreSettingsSection>
<FormSection title="Check-in Intervals">
@@ -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) {
/>
<PortainerTunnelAddrField fieldName="Edge.TunnelServerAddress" />
<div className="form-group">
<div className="col-sm-12">
<Button
color="default"
icon={Network}
onClick={() => setIsConnectivityModalOpen(true)}
data-cy="edge-compute-test-connectivity-button"
className="!ml-0"
>
Test connectivity
</Button>
</div>
</div>
{isConnectivityModalOpen && (
<ConnectivityTestModal
portainerUrl={values.EdgePortainerUrl}
tunnelServerAddr={values.Edge.TunnelServerAddress}
onDismiss={() => setIsConnectivityModalOpen(false)}
/>
)}
</>
)}