feat(podman): support add podman envs in the wizard [r8s-20] (#12056)

pull/12262/head^2
Ali 2 months ago committed by GitHub
parent db616bc8a5
commit 32e94d4e4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -85,6 +85,8 @@ dev-client: ## Run the client in development mode
dev-server: build-server ## Run the server in development mode
@./dev/run_container.sh
dev-server-podman: build-server ## Run the server in development mode
@./dev/run_container_podman.sh
##@ Format
.PHONY: format format-client format-server

@ -38,6 +38,7 @@
"TenantID": ""
},
"ComposeSyntaxMaxVersion": "",
"ContainerEngine": "",
"Edge": {
"AsyncMode": false,
"CommandInterval": 0,

@ -40,6 +40,7 @@ type endpointCreatePayload struct {
AzureAuthenticationKey string
TagIDs []portainer.TagID
EdgeCheckinInterval int
ContainerEngine string
}
type endpointCreationEnum int
@ -66,6 +67,11 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
}
payload.EndpointCreationType = endpointCreationEnum(endpointCreationType)
payload.ContainerEngine, err = request.RetrieveMultiPartFormValue(r, "ContainerEngine", true)
if err != nil || (payload.ContainerEngine != "" && payload.ContainerEngine != portainer.ContainerEngineDocker && payload.ContainerEngine != portainer.ContainerEnginePodman) {
return errors.New("invalid container engine value. Value must be one of: 'docker' or 'podman'")
}
groupID, _ := request.RetrieveNumericMultiPartFormValue(r, "GroupID", true)
if groupID == 0 {
groupID = 1
@ -186,6 +192,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param Name formData string true "Name that will be used to identify this environment(endpoint) (example: my-environment)"
// @param EndpointCreationType formData integer true "Environment(Endpoint) type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment)" Enum(1,2,3,4,5)
// @param ContainerEngine formData string false "Container engine used by the environment(endpoint). Value must be one of: 'docker' or 'podman'"
// @param URL formData string false "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine). Cannot be empty if EndpointCreationType is set to 4 (Edge agent environment)"
// @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)"
// @param GroupID formData int false "Environment(Endpoint) group identifier. If not specified will default to 1 (unassigned)."
@ -371,12 +378,13 @@ func (handler *Handler) createEdgeAgentEndpoint(tx dataservices.DataStoreTx, pay
edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID)
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: portainerHost,
Type: portainer.EdgeAgentOnDockerEnvironment,
GroupID: portainer.EndpointGroupID(payload.GroupID),
Gpus: payload.Gpus,
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: portainerHost,
Type: portainer.EdgeAgentOnDockerEnvironment,
ContainerEngine: payload.ContainerEngine,
GroupID: portainer.EndpointGroupID(payload.GroupID),
Gpus: payload.Gpus,
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
@ -424,13 +432,14 @@ func (handler *Handler) createUnsecuredEndpoint(tx dataservices.DataStoreTx, pay
endpointID := tx.Endpoint().GetNextIdentifier()
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: payload.URL,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.GroupID),
PublicURL: payload.PublicURL,
Gpus: payload.Gpus,
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: payload.URL,
Type: endpointType,
ContainerEngine: payload.ContainerEngine,
GroupID: portainer.EndpointGroupID(payload.GroupID),
PublicURL: payload.PublicURL,
Gpus: payload.Gpus,
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
@ -486,13 +495,14 @@ func (handler *Handler) createKubernetesEndpoint(tx dataservices.DataStoreTx, pa
func (handler *Handler) createTLSSecuredEndpoint(tx dataservices.DataStoreTx, payload *endpointCreatePayload, endpointType portainer.EndpointType, agentVersion string) (*portainer.Endpoint, *httperror.HandlerError) {
endpointID := tx.Endpoint().GetNextIdentifier()
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: payload.URL,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.GroupID),
PublicURL: payload.PublicURL,
Gpus: payload.Gpus,
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: payload.URL,
Type: endpointType,
ContainerEngine: payload.ContainerEngine,
GroupID: portainer.EndpointGroupID(payload.GroupID),
PublicURL: payload.PublicURL,
Gpus: payload.Gpus,
TLSConfig: portainer.TLSConfiguration{
TLS: payload.TLS,
TLSSkipVerify: payload.TLSSkipVerify,

@ -373,6 +373,8 @@ type (
Name string `json:"Name" example:"my-environment"`
// Environment(Endpoint) environment(endpoint) type. 1 for a Docker environment(endpoint), 2 for an agent on Docker environment(endpoint) or 3 for an Azure environment(endpoint).
Type EndpointType `json:"Type" example:"1"`
// ContainerEngine represents the container engine type. This can be 'docker' or 'podman' when interacting directly with these environmentes, otherwise '' for kubernetes environments.
ContainerEngine string `json:"ContainerEngine" example:"docker"`
// URL or IP address of the Docker host associated to this environment(endpoint)
URL string `json:"URL" example:"docker.mydomain.tld:2375"`
// Environment(Endpoint) group identifier
@ -1727,7 +1729,7 @@ const (
const (
_ EndpointType = iota
// DockerEnvironment represents an environment(endpoint) connected to a Docker environment(endpoint)
// DockerEnvironment represents an environment(endpoint) connected to a Docker environment(endpoint) via the Docker API or Socket
DockerEnvironment
// AgentOnDockerEnvironment represents an environment(endpoint) connected to a Portainer agent deployed on a Docker environment(endpoint)
AgentOnDockerEnvironment
@ -2113,3 +2115,8 @@ const (
PerDevConfigsTypeFile PerDevConfigsFilterType = "file"
PerDevConfigsTypeDir PerDevConfigsFilterType = "dir"
)
const (
ContainerEngineDocker = "docker"
ContainerEnginePodman = "podman"
)

@ -184,7 +184,9 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
if err != nil {
return errors.Wrap(err, "unable to get agent info")
}
targetSocketBind := getTargetSocketBind(info.OSType)
// ensure the targetSocketBindHost is changed to podman for podman environments
targetSocketBindHost := getTargetSocketBindHost(info.OSType, endpoint.ContainerEngine)
targetSocketBindContainer := getTargetSocketBindContainer(info.OSType)
composeDestination := filesystem.JoinPaths(stack.ProjectPath, composePathPrefix)
@ -206,7 +208,7 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
}, &container.HostConfig{
Binds: []string{
fmt.Sprintf("%s:%s", composeDestination, composeDestination),
fmt.Sprintf("%s:%s", targetSocketBind, targetSocketBind),
fmt.Sprintf("%s:%s", targetSocketBindHost, targetSocketBindContainer),
},
}, nil, nil, fmt.Sprintf("portainer-unpacker-%d-%s-%d", stack.ID, stack.Name, rand.Intn(100)))
@ -327,7 +329,19 @@ func getUnpackerImage() string {
return image
}
func getTargetSocketBind(osType string) string {
func getTargetSocketBindHost(osType string, containerEngine string) string {
targetSocketBind := "//./pipe/docker_engine"
if strings.EqualFold(osType, "linux") {
if containerEngine == portainer.ContainerEnginePodman {
targetSocketBind = "/run/podman/podman.sock"
} else {
targetSocketBind = "/var/run/docker.sock"
}
}
return targetSocketBind
}
func getTargetSocketBindContainer(osType string) string {
targetSocketBind := "//./pipe/docker_engine"
if strings.EqualFold(osType, "linux") {
targetSocketBind = "/var/run/docker.sock"

@ -203,7 +203,7 @@ input:checked + .slider:before {
/* Widget */
.widget .widget-icon {
@apply mr-1 !p-2 text-lg;
@apply mr-1 !p-2 text-lg flex-none;
@apply bg-blue-3 text-blue-8;
@apply th-dark:bg-gray-9 th-dark:text-blue-3;

@ -0,0 +1,25 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_63)">
<path d="M10.0931 24.7167C10.0916 24.6319 10.0898 24.5473 10.0898 24.4624C10.0898 16.3646 16.6544 9.80001 24.7522 9.80001C31.7306 9.80001 37.5698 14.6753 39.051 21.2057C40.4323 20.4325 42.0233 19.9898 43.7187 19.9898C49.0089 19.9898 53.2975 24.2783 53.2975 29.5686C53.2975 29.7848 53.2878 29.9987 53.2736 30.2113C57.201 31.6693 60 35.4496 60 39.8843C60 45.5814 55.3816 50.2 49.6843 50.2H12.8946C5.77316 50.1998 0 44.4267 0 37.3052C0 31.1458 4.31877 25.9961 10.0931 24.7167Z" fill="#0077BB"/>
<g clip-path="url(#clip1_3_63)">
<path d="M45.5486 31.3321C45.4597 31.2558 44.6618 30.6292 42.9481 30.6292C42.505 30.6292 42.0468 30.6748 41.6037 30.7518C41.2785 28.4127 39.4021 27.2812 39.3284 27.2207L38.8702 26.9454L38.5746 27.3889C38.2052 27.9849 37.9248 28.658 37.7622 29.3459C37.4521 30.6762 37.6436 31.9296 38.2941 32.9997C37.5107 33.458 36.2401 33.565 35.974 33.5807H14.9921C14.4457 33.5807 14.0019 34.0391 14.0019 34.6052C13.9723 36.5008 14.2824 38.3965 14.9177 40.1852C15.6419 42.1422 16.7203 43.5945 18.1094 44.4813C19.6757 45.4751 22.2321 46.0405 25.1131 46.0405C26.4133 46.0405 27.7136 45.9178 28.9994 45.6733C30.7875 45.3368 32.5012 44.6952 34.0826 43.7627C35.3828 42.9827 36.5501 41.9897 37.5404 40.8276C39.21 38.8863 40.2001 36.7154 40.9243 34.789H41.2199C43.0377 34.789 44.1602 34.0398 44.781 33.3982C45.1944 33.0004 45.5052 32.5113 45.7264 31.9609L45.8594 31.5631L45.5493 31.3336L45.5486 31.3321Z" fill="white"/>
<path d="M16.9416 32.9526H19.7489C19.8818 32.9526 20.0004 32.8456 20.0004 32.6923V30.0937C20.0004 29.9561 19.897 29.8343 19.7489 29.8343H16.9416C16.8086 29.8343 16.6901 29.9412 16.6901 30.0937V32.6923C16.7046 32.8449 16.8086 32.9526 16.9416 32.9526Z" fill="white"/>
<path d="M20.814 32.9526H23.6212C23.7542 32.9526 23.8721 32.8456 23.8721 32.6923V30.0937C23.8721 29.9561 23.7687 29.8343 23.6212 29.8343H20.814C20.681 29.8343 20.5625 29.9412 20.5625 30.0937V32.6923C20.577 32.8449 20.681 32.9526 20.814 32.9526Z" fill="white"/>
<path d="M24.7593 32.9526H27.5666C27.6996 32.9526 27.818 32.8456 27.818 32.6923V30.0937C27.818 29.9561 27.7147 29.8343 27.5666 29.8343H24.7593C24.6263 29.8343 24.5078 29.9412 24.5078 30.0937V32.6923C24.5078 32.8449 24.6112 32.9526 24.7593 32.9526Z" fill="white"/>
<path d="M28.6447 32.9526H31.452C31.585 32.9526 31.7035 32.8456 31.7035 32.6923V30.0937C31.7035 29.9561 31.6001 29.8343 31.452 29.8343H28.6447C28.5117 29.8343 28.3932 29.9412 28.3932 30.0937V32.6923C28.3932 32.8449 28.5117 32.9526 28.6447 32.9526Z" fill="white"/>
<path d="M20.814 29.2375H23.6212C23.7542 29.2375 23.8721 29.1148 23.8721 28.9773V26.3786C23.8721 26.241 23.7687 26.1184 23.6212 26.1184H20.814C20.681 26.1184 20.5625 26.2254 20.5625 26.3786V28.9773C20.577 29.1148 20.681 29.2375 20.814 29.2375Z" fill="white"/>
<path d="M24.7593 29.2375H27.5666C27.6996 29.2375 27.818 29.1148 27.818 28.9773V26.3786C27.818 26.241 27.7147 26.1184 27.5666 26.1184H24.7593C24.6263 26.1184 24.5078 26.2254 24.5078 26.3786V28.9773C24.5078 29.1148 24.6112 29.2375 24.7593 29.2375Z" fill="white"/>
<path d="M28.6447 29.2375H31.452C31.585 29.2375 31.7035 29.1148 31.7035 28.9773V26.3786C31.7035 26.241 31.585 26.1184 31.452 26.1184H28.6447C28.5117 26.1184 28.3932 26.2254 28.3932 26.3786V28.9773C28.3932 29.1148 28.5117 29.2375 28.6447 29.2375Z" fill="white"/>
<path d="M28.6447 25.5075H31.452C31.585 25.5075 31.7035 25.4006 31.7035 25.2473V22.6487C31.7035 22.5111 31.585 22.3885 31.452 22.3885H28.6447C28.5117 22.3885 28.3932 22.4954 28.3932 22.6487V25.2473C28.3932 25.3849 28.5117 25.5075 28.6447 25.5075Z" fill="white"/>
<path d="M32.5623 32.9526H35.3695C35.5025 32.9526 35.6211 32.8456 35.6211 32.6923V30.0937C35.6211 29.9561 35.5177 29.8343 35.3695 29.8343H32.5623C32.4293 29.8343 32.3108 29.9412 32.3108 30.0937V32.6923C32.3252 32.8449 32.4293 32.9526 32.5623 32.9526Z" fill="white"/>
</g>
</g>
<defs>
<clipPath id="clip0_3_63">
<rect width="60" height="60" fill="white"/>
</clipPath>
<clipPath id="clip1_3_63">
<rect width="32" height="36" fill="white" transform="translate(14 17)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

@ -0,0 +1,16 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_14_99)">
<path d="M10.0931 24.7167C10.0916 24.6319 10.0898 24.5473 10.0898 24.4624C10.0898 16.3646 16.6544 9.80001 24.7522 9.80001C31.7306 9.80001 37.5698 14.6753 39.051 21.2057C40.4323 20.4325 42.0233 19.9898 43.7187 19.9898C49.0089 19.9898 53.2975 24.2783 53.2975 29.5686C53.2975 29.7848 53.2878 29.9987 53.2736 30.2113C57.201 31.6693 60 35.4496 60 39.8843C60 45.5814 55.3816 50.2 49.6843 50.2H12.8946C5.77316 50.1998 0 44.4267 0 37.3052C0 31.1458 4.31877 25.9961 10.0931 24.7167Z" fill="#0077BB"/>
<path d="M21.5688 35.8125C21.1986 35.813 20.8437 35.9682 20.5821 36.2442C20.3205 36.5201 20.1735 36.8942 20.1735 37.2842C20.1735 37.6743 20.3205 38.0483 20.5821 38.3243C20.8437 38.6002 21.1986 38.7555 21.5688 38.756C21.9391 38.7556 22.2941 38.6004 22.5558 38.3244C22.8175 38.0485 22.9645 37.6743 22.9645 37.2842C22.9645 36.8942 22.8176 36.5201 22.5559 36.2442C22.2943 35.9682 21.939 35.813 21.5688 35.8125ZM35.6846 35.8125C35.3144 35.813 34.9595 35.9682 34.6979 36.2442C34.4363 36.5201 34.2893 36.8942 34.2893 37.2842C34.2893 37.6743 34.4363 38.0483 34.6979 38.3243C34.9595 38.6002 35.3144 38.7555 35.6846 38.756C35.8683 38.7563 36.0502 38.7185 36.22 38.6447C36.3898 38.5709 36.5441 38.4625 36.6741 38.3258C36.804 38.1891 36.9072 38.0268 36.9775 37.8481C37.0479 37.6693 37.0841 37.4777 37.0841 37.2842C37.0841 37.0907 37.0479 36.8991 36.9775 36.7204C36.9072 36.5417 36.804 36.3793 36.6741 36.2426C36.5441 36.1059 36.3898 35.9976 36.22 35.9238C36.0502 35.85 35.8683 35.8121 35.6846 35.8125Z" fill="white" stroke="white" stroke-width="0.67"/>
<path d="M23.9208 36.196L18.7289 37.588L18.8777 38.1444L24.0703 36.7524L23.9208 36.196ZM33.7463 36.196L33.5967 36.7524L38.7881 38.1444L38.9377 37.588L33.7463 36.196ZM24.4404 37.0348L19.219 40.0506L19.5072 40.549L24.7279 37.5336L24.4404 37.0348ZM33.2295 37.0348L32.9413 37.5336L38.1623 40.549L38.4506 40.0506L33.2295 37.0348ZM24.8223 38.0354L21.2267 41.636C21.4715 41.6669 21.7046 41.7057 21.9273 41.7492L25.2296 38.4423L24.8223 38.0354ZM32.8473 38.0354L32.4404 38.4423L36.9559 42.9648C37.2115 42.9481 37.4726 42.9368 37.7371 42.9314L32.8473 38.0354ZM24.9969 39.3463L23.3438 42.2121C23.5155 42.2969 23.6839 42.39 23.8472 42.4928L25.4961 39.6337L24.9969 39.3463ZM32.6731 39.3463L32.1739 39.6337L34.3825 43.4636C34.4235 43.4473 34.4631 43.4281 34.5049 43.4131C34.6344 43.3617 34.7827 43.3157 34.9381 43.2727L32.6731 39.3463Z" fill="white" stroke="white" stroke-width="0.67"/>
<path d="M28.7754 31.5535C26.0764 31.5535 23.8585 33.6515 23.8585 36.2541C23.8585 38.8567 26.0764 40.9546 28.7754 40.9546C31.475 40.9546 33.6928 38.8567 33.6928 36.2541C33.6928 33.6515 31.4741 31.5535 28.7754 31.5535ZM28.7754 32.4178C31.0305 32.4178 32.8289 34.1423 32.8289 36.2541C32.8289 38.3662 31.0305 40.0899 28.7754 40.0899C26.5208 40.0899 24.722 38.3662 24.722 36.2541C24.722 34.1423 26.5208 32.4178 28.7754 32.4178Z" fill="white" stroke="white" stroke-width="0.67"/>
<path d="M28.1864 21.375C26.1052 21.375 24.0791 21.6023 22.7736 22.1199C19.3133 23.3911 16.7045 26.8091 15.9287 30.9244C15.2386 34.1891 15.0556 36.95 14.6884 39.4499C14.5318 40.5185 14.3383 41.5365 14.0635 42.5316C13.3495 45.3148 12.4583 47.0972 12.4583 47.0972H13.3495C13.3495 47.0972 13.5653 47.4728 15.1275 41.8511C15.2966 41.0987 15.4359 40.3399 15.5452 39.5765C15.9191 37.0273 16.0946 34.299 16.7709 31.1015L16.7751 31.0973V31.0907C17.5003 27.2306 19.9525 24.0699 23.0773 22.9261L23.0815 22.9228H23.0877C24.176 22.49 26.1687 22.2393 28.1864 22.2397C30.2012 22.2397 32.2653 22.4833 33.5378 22.9261C36.6626 24.0695 39.1148 27.2302 39.8392 31.0902L39.8433 31.0986V31.1015C40.5193 34.299 40.6989 37.0273 41.0728 39.5765C41.2411 40.7244 41.4322 41.8447 41.7746 42.9331C42.8044 46.206 43.6505 47.0972 43.6505 47.0972H44.5417C44.5417 47.0972 43.3372 44.962 42.8044 43.363C42.385 42.1043 42.1276 40.8176 41.9267 39.4499C41.5603 36.9517 41.3786 34.1929 40.6897 30.9319C39.9152 26.8095 37.3047 23.3815 33.8377 22.1123H33.8302C32.3831 21.6069 30.271 21.3754 28.1864 21.375ZM21.682 26.5814C21.2382 26.579 20.7999 26.6789 20.4009 26.8734C20.002 27.0678 19.6533 27.3516 19.3819 27.7027L19.1174 28.0427L19.805 28.5708L20.0686 28.227C20.2491 27.9978 20.4762 27.8097 20.7349 27.6751C20.9936 27.5404 21.278 27.4624 21.5692 27.4462C22.1123 27.4232 22.6763 27.6417 23.0623 28.0248L23.3697 28.3322L23.9809 27.7173L23.6734 27.4136C23.1419 26.889 22.4288 26.5901 21.682 26.5814ZM35.6232 26.5814C34.8763 26.5904 34.162 26.8887 33.6305 27.4136L33.3231 27.7173L33.9309 28.3322L34.2388 28.0248C34.4359 27.8329 34.6698 27.6827 34.9263 27.5833C35.1829 27.4838 35.4568 27.4372 35.7318 27.4462C36.3062 27.4713 36.8848 27.7708 37.2357 28.2274L37.5002 28.5708L38.1836 28.0427L37.9192 27.7027C37.6485 27.3518 37.3006 27.068 36.9023 26.8736C36.5041 26.6791 36.0664 26.5791 35.6232 26.5814ZM28.7754 33.5892C28.4588 33.5892 28.1993 33.6866 27.9838 33.7593C27.7678 33.8324 27.5953 33.8821 27.4624 33.8821C27.2256 33.8821 26.9929 34.012 26.8663 34.1645C26.7401 34.3165 26.6804 34.4736 26.6345 34.6232C26.5425 34.921 26.4978 35.2239 26.4573 35.3292C26.3646 35.569 26.4531 35.7599 26.5267 35.8852C26.5998 36.0105 26.6892 36.1091 26.7899 36.211C26.992 36.4141 27.2477 36.6175 27.5059 36.8072C27.7703 37.001 28.0356 37.1773 28.2328 37.306L28.2223 37.4798C28.125 37.5591 27.8881 37.7693 27.4954 37.9021C27.2632 37.9806 27.0292 38.0086 26.8262 37.9568C26.6537 37.9125 26.482 37.7605 26.3057 37.5303C26.317 37.4392 26.3211 37.4021 26.3416 37.2521C26.378 36.9897 26.4306 36.6727 26.4544 36.5787L25.6151 36.369C25.565 36.5695 25.5224 36.8619 25.4848 37.133C25.4472 37.4037 25.4196 37.6356 25.4196 37.6356L25.4017 37.7901L25.4848 37.9201C25.7893 38.3988 26.1954 38.6891 26.6131 38.7957C27.0309 38.9026 27.4361 38.8379 27.7745 38.7238C28.2131 38.5747 28.5707 38.3353 28.7679 38.1849C28.9655 38.3353 29.321 38.5755 29.7584 38.7238C30.0968 38.8383 30.4978 38.9026 30.9156 38.7957C31.3333 38.6887 31.7398 38.3988 32.0439 37.9201L32.1275 37.7906L32.1091 37.6356C32.1091 37.6356 32.0815 37.4037 32.0439 37.133C32.0063 36.8615 31.9629 36.5695 31.9136 36.369L31.0747 36.5787C31.0981 36.6727 31.1508 36.9897 31.1875 37.2521C31.2076 37.4 31.2126 37.4359 31.223 37.527C31.0459 37.7592 30.8763 37.9125 30.7025 37.9572C30.4995 38.0086 30.266 37.9806 30.0337 37.9021C29.641 37.7693 29.4037 37.5591 29.3068 37.4798L29.2993 37.3172C29.4994 37.1877 29.7705 37.0081 30.0441 36.8076C30.3023 36.6175 30.558 36.4137 30.7606 36.2102C30.8608 36.1087 30.9511 36.0101 31.0238 35.8852C31.0977 35.7599 31.1854 35.569 31.0927 35.3287C31.0522 35.2243 31.0041 34.921 30.9122 34.6227C30.8663 34.474 30.8111 34.3161 30.6846 34.1636C30.558 34.0116 30.3253 33.8821 30.088 33.8821C29.9556 33.8821 29.783 33.8319 29.5675 33.7593C29.3515 33.6866 29.0925 33.5892 28.7754 33.5892ZM28.7754 34.4536C28.9028 34.4536 29.072 34.5029 29.2893 34.576C29.4889 34.6436 29.74 34.718 30.0262 34.7318C30.0358 34.7464 30.0617 34.7944 30.088 34.8801C30.1344 35.0305 30.1816 35.2602 30.2468 35.4837C30.2259 35.5121 30.2008 35.5477 30.1461 35.6028C30.0032 35.7465 29.7701 35.9332 29.5307 36.1087C29.1631 36.379 28.9333 36.5198 28.7754 36.6188C28.6188 36.5202 28.3844 36.3806 28.0155 36.1091C27.7762 35.9337 27.5477 35.7465 27.4048 35.6028C27.3496 35.5477 27.3254 35.5121 27.3045 35.4837C27.3689 35.2602 27.4165 35.0309 27.4624 34.8801C27.4769 34.8283 27.4978 34.7784 27.5247 34.7318C27.8104 34.7184 28.0581 34.6436 28.2583 34.576C28.4746 34.5029 28.6484 34.4536 28.7754 34.4536Z" fill="white" stroke="white" stroke-width="0.67"/>
<path d="M21.659 29.959C21.3172 29.9591 20.9788 30.0282 20.6643 30.1621C20.3498 30.2961 20.0655 30.4921 19.8284 30.7385C19.5309 31.0432 19.311 31.415 19.1872 31.8225C19.0964 32.1197 19.0583 32.4305 19.0744 32.7408C19.0946 33.1475 19.2076 33.5443 19.4046 33.9007C19.6016 34.2571 19.8775 34.5638 20.2111 34.7974C20.3844 34.9173 20.5717 35.0156 20.7688 35.0902C21.3843 35.3226 22.0661 35.3061 22.6696 35.0443C22.9014 34.9439 23.1169 34.8095 23.3092 34.6457C23.5349 34.4554 23.7269 34.2285 23.8773 33.9744C24.1939 33.4423 24.3158 32.8168 24.2224 32.2048C24.1706 31.8629 24.0533 31.5343 23.8769 31.2369C23.6825 30.9107 23.4209 30.6297 23.1095 30.4126C22.936 30.2928 22.7486 30.1946 22.5514 30.1202C22.2663 30.0132 21.9635 29.9585 21.659 29.959ZM35.6412 29.959C35.337 29.9596 35.0354 30.0146 34.7505 30.1215C34.5139 30.2102 34.2919 30.3339 34.0918 30.4882C33.858 30.6673 33.656 30.8846 33.4944 31.1308C33.1041 31.7212 32.9641 32.4424 33.105 33.136C33.2066 33.6401 33.4515 34.1042 33.8102 34.4728C33.9862 34.653 34.1871 34.8072 34.4067 34.9306C34.6296 35.0546 34.8696 35.1448 35.119 35.198C35.3777 35.2529 35.6435 35.2666 35.9065 35.2385C36.4169 35.1852 36.8992 34.9781 37.2892 34.6445C37.547 34.4264 37.7606 34.1609 37.9184 33.8624C38.1672 33.3948 38.2712 32.8637 38.2171 32.3368C38.1779 31.9487 38.0548 31.5738 37.8561 31.2381C37.6403 30.8757 37.3416 30.5697 36.9847 30.345C36.7689 30.2107 36.5346 30.1091 36.2891 30.0433C36.0779 29.9872 35.8598 29.9588 35.6412 29.959ZM21.659 30.8195C21.7471 30.8198 21.8359 30.8265 21.9231 30.8396C22.1201 30.87 22.3101 30.9353 22.4841 31.0326C22.6315 31.1143 22.7661 31.2174 22.8835 31.3384C23.0429 31.5017 23.1692 31.6943 23.2553 31.9057C23.3347 32.0987 23.3801 32.3041 23.3894 32.5127C23.3993 32.7249 23.3736 32.9373 23.3133 33.141C23.2469 33.3613 23.139 33.5669 22.9954 33.7467C22.8541 33.9221 22.6806 34.0689 22.4841 34.1791C22.1531 34.3624 21.7704 34.43 21.3967 34.3713C20.9737 34.3055 20.5914 34.082 20.3264 33.7459C20.1823 33.5664 20.0745 33.3607 20.0089 33.1401C19.9567 32.9667 19.9305 32.7865 19.9312 32.6054C19.9316 32.4244 19.9579 32.2443 20.0093 32.0707C20.0757 31.8506 20.1835 31.6451 20.3268 31.4654C20.4676 31.2902 20.6406 31.1436 20.8365 31.0334C21.0619 30.9081 21.3129 30.8358 21.5705 30.822C21.5997 30.8204 21.6294 30.8195 21.659 30.8195ZM35.6412 30.8195C35.8155 30.8197 35.9888 30.8466 36.155 30.8993C36.3698 30.9676 36.5686 31.0783 36.7399 31.2247C36.9129 31.371 37.056 31.5495 37.161 31.7503C37.2711 31.9588 37.3391 32.1869 37.3611 32.4216C37.3959 32.7803 37.3263 33.1414 37.1606 33.4614C37.0549 33.6616 36.9121 33.84 36.7399 33.9869C36.6115 34.0957 36.468 34.1852 36.3138 34.2526C36.1017 34.3449 35.8729 34.3925 35.6416 34.3925C35.4103 34.3925 35.1815 34.3449 34.9694 34.2526C34.7629 34.1634 34.5762 34.034 34.4201 33.872C34.261 33.7087 34.135 33.516 34.0491 33.3047C33.9699 33.1118 33.9247 32.9065 33.9155 32.6982C33.9081 32.5456 33.9192 32.3927 33.9485 32.2428C33.9937 32.0149 34.082 31.7976 34.2087 31.6028C34.3325 31.4145 34.491 31.2514 34.6758 31.1224C34.8383 31.0099 35.0191 30.9264 35.2101 30.8755C35.3508 30.8383 35.4957 30.8195 35.6412 30.8195Z" fill="white" stroke="white" stroke-width="0.67"/>
<path d="M20.9401 30.8772C20.6585 30.8769 20.3883 30.993 20.1889 31.1998C19.9896 31.4066 19.8773 31.6872 19.8769 31.98C19.8766 32.1254 19.9039 32.2693 19.9572 32.4037C20.0105 32.538 20.0887 32.6601 20.1875 32.763C20.2863 32.8658 20.4036 32.9474 20.5327 33.003C20.6619 33.0586 20.8003 33.0872 20.9401 33.0871C21.0799 33.0872 21.2183 33.0586 21.3474 33.003C21.4766 32.9474 21.5939 32.8658 21.6927 32.763C21.7914 32.6601 21.8697 32.538 21.923 32.4037C21.9763 32.2693 22.0036 32.1254 22.0033 31.98C22.0028 31.6872 21.8906 31.4066 21.6913 31.1998C21.4919 30.993 21.2217 30.8769 20.9401 30.8772ZM34.9151 30.8772C34.6335 30.8769 34.3633 30.993 34.164 31.1998C33.9646 31.4066 33.8524 31.6872 33.852 31.98C33.8516 32.1254 33.8789 32.2693 33.9322 32.4037C33.9855 32.538 34.0638 32.6601 34.1626 32.763C34.2613 32.8658 34.3786 32.9474 34.5078 33.003C34.6369 33.0586 34.7754 33.0872 34.9151 33.0871C35.0549 33.0871 35.1932 33.0584 35.3222 33.0028C35.4513 32.9471 35.5685 32.8655 35.6672 32.7627C35.7659 32.6598 35.8441 32.5378 35.8973 32.4035C35.9506 32.2692 35.9778 32.1253 35.9775 31.98C35.9771 31.6874 35.8649 31.4069 35.6658 31.2001C35.4666 30.9933 35.1966 30.8772 34.9151 30.8772Z" fill="white" stroke="white" stroke-width="0.67"/>
</g>
<defs>
<clipPath id="clip0_14_99">
<rect width="60" height="60" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M7.24718 12.9C6.99332 12.9003 6.74996 13.0068 6.57057 13.196C6.39117 13.3852 6.29041 13.6417 6.29041 13.9092C6.29041 14.1766 6.39117 14.4331 6.57057 14.6224C6.74996 14.8116 6.99332 14.9181 7.24718 14.9184C7.50108 14.9181 7.74451 14.8117 7.92397 14.6225C8.10343 14.4332 8.20423 14.1767 8.20423 13.9092C8.20423 13.6417 8.10347 13.3852 7.92407 13.196C7.74468 13.0068 7.50103 12.9003 7.24718 12.9ZM16.9266 12.9C16.6728 12.9003 16.4294 13.0068 16.25 13.196C16.0706 13.3852 15.9698 13.6417 15.9698 13.9092C15.9698 14.1766 16.0706 14.4331 16.25 14.6224C16.4294 14.8116 16.6728 14.9181 16.9266 14.9184C17.0525 14.9186 17.1773 14.8927 17.2937 14.8421C17.4101 14.7915 17.5159 14.7172 17.6051 14.6234C17.6942 14.5297 17.7649 14.4184 17.8132 14.2958C17.8614 14.1733 17.8862 14.0419 17.8862 13.9092C17.8862 13.7765 17.8614 13.6451 17.8132 13.5226C17.7649 13.4 17.6942 13.2887 17.6051 13.1949C17.5159 13.1012 17.4101 13.0269 17.2937 12.9763C17.1773 12.9257 17.0525 12.8998 16.9266 12.9Z" stroke-width="0.67"/>
<path d="M8.85996 13.163L5.29985 14.1175L5.40183 14.499L8.96251 13.5445L8.85996 13.163ZM15.5975 13.163L15.4949 13.5445L19.0547 14.499L19.1573 14.1175L15.5975 13.163ZM9.21631 13.7382L5.63587 15.8061L5.83352 16.1479L9.41339 14.0802L9.21631 13.7382ZM15.2431 13.7382L15.0455 14.0802L18.6256 16.1479L18.8233 15.8061L15.2431 13.7382ZM9.47813 14.4242L7.01259 16.8932C7.18045 16.9144 7.34029 16.9411 7.49298 16.9709L9.75743 14.7033L9.47813 14.4242ZM14.981 14.4242L14.702 14.7033L17.7983 17.8045C17.9736 17.793 18.1527 17.7853 18.334 17.7815L14.981 14.4242ZM9.59787 15.3232L8.46436 17.2883C8.58209 17.3464 8.69753 17.4103 8.80954 17.4808L9.94019 15.5202L9.59787 15.3232ZM14.8615 15.3232L14.5192 15.5202L16.0337 18.1465C16.0618 18.1353 16.089 18.1221 16.1177 18.1118C16.2065 18.0766 16.3082 18.0451 16.4147 18.0156L14.8615 15.3232Z" stroke-width="0.67"/>
<path d="M12.1889 9.97955C10.3381 9.97955 8.81726 11.4181 8.81726 13.2028C8.81726 14.9874 10.3381 16.426 12.1889 16.426C14.04 16.426 15.5608 14.9874 15.5608 13.2028C15.5608 11.4181 14.0394 9.97955 12.1889 9.97955ZM12.1889 10.5722C13.7352 10.5722 14.9684 11.7547 14.9684 13.2028C14.9684 14.6511 13.7352 15.833 12.1889 15.833C10.6429 15.833 9.40937 14.6511 9.40937 13.2028C9.40937 11.7547 10.6429 10.5722 12.1889 10.5722Z" stroke-width="0.67"/>
<path d="M11.785 3C10.3578 3 8.9685 3.15583 8.07332 3.51076C5.70059 4.38245 3.91165 6.72625 3.3797 9.54815C2.90647 11.7868 2.781 13.68 2.5292 15.3942C2.42178 16.127 2.28915 16.8251 2.10066 17.5074C1.61111 19.4159 1 20.6381 1 20.6381H1.61111C1.61111 20.6381 1.75909 20.8956 2.83027 17.0408C2.94622 16.5248 3.04178 16.0045 3.11673 15.481C3.37311 13.733 3.49342 11.8622 3.9572 9.66961L3.96006 9.66675V9.66216C4.45736 7.01529 6.13887 4.84794 8.28158 4.06362L8.28444 4.06133H8.28874C9.03496 3.76456 10.4014 3.59268 11.785 3.59297C13.1666 3.59297 14.5819 3.75997 15.4545 4.06362C17.5972 4.84766 19.2787 7.015 19.7754 9.66188L19.7783 9.66761V9.66961C20.2418 11.8622 20.365 13.733 20.6213 15.481C20.7368 16.2682 20.8678 17.0364 21.1026 17.7827C21.8087 20.027 22.3889 20.6381 22.3889 20.6381H23C23 20.6381 22.1741 19.174 21.8087 18.0775C21.5211 17.2144 21.3447 16.3321 21.2069 15.3942C20.9556 13.6812 20.831 11.7894 20.3587 9.55331C19.8276 6.72654 18.0375 4.37586 15.6602 3.5056H15.655C14.6627 3.15898 13.2144 3.00029 11.785 3ZM7.32481 6.57013C7.0205 6.56847 6.71991 6.63696 6.44637 6.7703C6.17283 6.90365 5.9337 7.09824 5.74757 7.33899L5.56624 7.57216L6.03775 7.93425L6.2185 7.69849C6.34221 7.54136 6.49794 7.41235 6.67535 7.32004C6.85276 7.22773 7.04778 7.17423 7.24746 7.1631C7.61986 7.14735 8.00658 7.29716 8.27126 7.55985L8.4821 7.77068L8.90119 7.34901L8.69035 7.14076C8.32585 6.78101 7.8369 6.5761 7.32481 6.57013ZM16.8845 6.57013C16.3723 6.57625 15.8825 6.78081 15.5181 7.14076L15.3073 7.34901L15.7241 7.77068L15.9352 7.55985C16.0704 7.42824 16.2307 7.32527 16.4066 7.2571C16.5825 7.18893 16.7704 7.15696 16.959 7.1631C17.3529 7.18029 17.7496 7.38568 17.9902 7.69878L18.1716 7.93425L18.6402 7.57216L18.4589 7.33899C18.2733 7.09835 18.0347 6.90379 17.7616 6.77043C17.4885 6.63708 17.1884 6.56854 16.8845 6.57013ZM12.1889 11.3755C11.9717 11.3755 11.7938 11.4422 11.646 11.4921C11.4979 11.5422 11.3796 11.5763 11.2885 11.5763C11.1261 11.5763 10.9666 11.6654 10.8798 11.7699C10.7932 11.8742 10.7523 11.9819 10.7208 12.0845C10.6577 12.2887 10.6271 12.4964 10.5993 12.5686C10.5357 12.733 10.5964 12.8639 10.6469 12.9498C10.697 13.0358 10.7583 13.1034 10.8273 13.1733C10.966 13.3125 11.1413 13.452 11.3183 13.5821C11.4997 13.715 11.6816 13.8359 11.8168 13.9241L11.8096 14.0433C11.7429 14.0977 11.5804 14.2418 11.3112 14.3329C11.1519 14.3867 10.9915 14.4059 10.8523 14.3704C10.7339 14.34 10.6162 14.2358 10.4953 14.0779C10.5031 14.0155 10.5059 13.99 10.52 13.8871C10.5449 13.7072 10.581 13.4898 10.5973 13.4254L10.0218 13.2816C9.98744 13.4191 9.95822 13.6196 9.93244 13.8055C9.90665 13.9911 9.88775 14.1501 9.88775 14.1501L9.87543 14.2561L9.93244 14.3452C10.1413 14.6735 10.4197 14.8726 10.7062 14.9456C10.9926 15.0189 11.2705 14.9745 11.5025 14.8963C11.8033 14.7941 12.0485 14.6299 12.1837 14.5268C12.3192 14.6299 12.563 14.7946 12.8629 14.8963C13.0949 14.9748 13.3699 15.0189 13.6564 14.9456C13.9429 14.8723 14.2216 14.6735 14.4301 14.3452L14.4874 14.2564L14.4748 14.1501C14.4748 14.1501 14.4559 13.9911 14.4301 13.8055C14.4043 13.6193 14.3745 13.4191 14.3407 13.2816L13.7655 13.4254C13.7816 13.4898 13.8177 13.7072 13.8429 13.8871C13.8566 13.9885 13.8601 14.0132 13.8672 14.0756C13.7458 14.2349 13.6295 14.34 13.5103 14.3707C13.3711 14.4059 13.211 14.3867 13.0517 14.3329C12.7824 14.2418 12.6197 14.0977 12.5532 14.0433L12.5481 13.9318C12.6853 13.843 12.8712 13.7198 13.0588 13.5823C13.2359 13.452 13.4112 13.3122 13.5501 13.1727C13.6189 13.1031 13.6807 13.0355 13.7306 12.9498C13.7813 12.8639 13.8414 12.733 13.7779 12.5683C13.7501 12.4967 13.7171 12.2887 13.6541 12.0842C13.6226 11.9822 13.5848 11.8739 13.498 11.7694C13.4112 11.6651 13.2516 11.5763 13.0889 11.5763C12.9981 11.5763 12.8798 11.5419 12.732 11.4921C12.5839 11.4422 12.4063 11.3755 12.1889 11.3755ZM12.1889 11.9682C12.2762 11.9682 12.3923 12.002 12.5412 12.0521C12.6781 12.0985 12.8503 12.1495 13.0465 12.1589C13.0531 12.169 13.0709 12.2019 13.0889 12.2606C13.1207 12.3638 13.1531 12.5213 13.1978 12.6746C13.1835 12.694 13.1663 12.7184 13.1287 12.7562C13.0308 12.8547 12.8709 12.9828 12.7068 13.1031C12.4547 13.2884 12.2972 13.385 12.1889 13.4529C12.0814 13.3853 11.9207 13.2896 11.6678 13.1034C11.5037 12.9831 11.347 12.8547 11.249 12.7562C11.2112 12.7184 11.1946 12.694 11.1802 12.6746C11.2244 12.5213 11.257 12.364 11.2885 12.2606C11.2985 12.2251 11.3128 12.1909 11.3312 12.1589C11.5272 12.1498 11.697 12.0985 11.8342 12.0521C11.9826 12.002 12.1018 11.9682 12.1889 11.9682Z" stroke-width="0.67"/>
<path d="M7.30906 8.88614C7.07464 8.88624 6.84264 8.93362 6.62695 9.02547C6.41127 9.11732 6.21633 9.25173 6.0538 9.42067C5.84978 9.62964 5.69897 9.88459 5.61409 10.164C5.55185 10.3678 5.52567 10.5809 5.53674 10.7937C5.55062 11.0726 5.62806 11.3446 5.76315 11.5891C5.89824 11.8335 6.08741 12.0438 6.3162 12.2039C6.43504 12.2861 6.56346 12.3536 6.69862 12.4047C7.12065 12.5641 7.58817 12.5528 8.00201 12.3732C8.16094 12.3044 8.30875 12.2123 8.44057 12.0999C8.59535 11.9694 8.72706 11.8138 8.83016 11.6396C9.04723 11.2747 9.13087 10.8458 9.06677 10.4261C9.03125 10.1917 8.95081 9.96635 8.82987 9.76242C8.69661 9.53878 8.51722 9.3461 8.30365 9.19723C8.1847 9.11509 8.05618 9.04776 7.92094 8.99671C7.7255 8.92332 7.51783 8.88586 7.30906 8.88614ZM16.8968 8.88614C16.6882 8.88658 16.4814 8.92432 16.2861 8.99757C16.1238 9.05845 15.9716 9.14322 15.8344 9.24908C15.674 9.37189 15.5356 9.52084 15.4247 9.68966C15.1571 10.0945 15.0611 10.5891 15.1577 11.0647C15.2274 11.4104 15.3953 11.7286 15.6413 11.9813C15.762 12.1049 15.8997 12.2107 16.0503 12.2953C16.2031 12.3803 16.3677 12.4421 16.5388 12.4786C16.7162 12.5163 16.8984 12.5256 17.0787 12.5064C17.4288 12.4699 17.7594 12.3278 18.0269 12.0991C18.2037 11.9495 18.3501 11.7675 18.4583 11.5628C18.629 11.2422 18.7003 10.878 18.6631 10.5167C18.6363 10.2505 18.5518 9.99346 18.4156 9.76328C18.2676 9.51479 18.0629 9.3049 17.8181 9.15083C17.6701 9.05879 17.5094 8.9891 17.3411 8.944C17.1963 8.90551 17.0467 8.88605 16.8968 8.88614ZM7.30906 9.47624C7.36947 9.47641 7.43036 9.48101 7.4901 9.48999C7.6252 9.51084 7.75549 9.55566 7.87482 9.62234C7.97591 9.6784 8.06818 9.74906 8.14867 9.83203C8.258 9.944 8.34458 10.0761 8.40362 10.221C8.45809 10.3534 8.4892 10.4942 8.49557 10.6373C8.50241 10.7828 8.48479 10.9284 8.44344 11.0681C8.39789 11.2192 8.32389 11.3601 8.22544 11.4835C8.12855 11.6038 8.00954 11.7044 7.87482 11.7799C7.64786 11.9056 7.38545 11.952 7.12917 11.9117C6.83915 11.8666 6.57695 11.7134 6.39526 11.4829C6.29647 11.3598 6.22253 11.2188 6.17755 11.0675C6.14172 10.9486 6.12376 10.825 6.12427 10.7009C6.12453 10.5767 6.14257 10.4532 6.17784 10.3342C6.22337 10.1832 6.29726 10.0424 6.39555 9.91911C6.49208 9.79899 6.61071 9.69845 6.74503 9.62291C6.8996 9.53697 7.07173 9.4874 7.24833 9.47796C7.26838 9.47682 7.28872 9.47624 7.30906 9.47624ZM16.8968 9.47624C17.0164 9.47639 17.1352 9.49484 17.2492 9.53096C17.3964 9.5778 17.5328 9.65367 17.6502 9.75411C17.7689 9.85441 17.8669 9.97679 17.939 10.1145C18.0145 10.2574 18.0611 10.4138 18.0762 10.5748C18.1001 10.8208 18.0523 11.0684 17.9387 11.2878C17.8662 11.4251 17.7683 11.5474 17.6502 11.6482C17.5622 11.7228 17.4638 11.7841 17.358 11.8304C17.2126 11.8936 17.0557 11.9263 16.8971 11.9263C16.7385 11.9263 16.5816 11.8936 16.4362 11.8304C16.2946 11.7692 16.1666 11.6805 16.0595 11.5694C15.9504 11.4574 15.864 11.3253 15.8051 11.1804C15.7508 11.0481 15.7198 10.9073 15.7135 10.7644C15.7084 10.6598 15.716 10.555 15.7361 10.4522C15.7671 10.2959 15.8277 10.147 15.9146 10.0134C15.9994 9.88421 16.1081 9.77242 16.2348 9.68393C16.3463 9.6068 16.4702 9.54953 16.6012 9.51463C16.6977 9.48913 16.797 9.47623 16.8968 9.47624Z" stroke-width="0.67"/>
<path d="M6.81606 9.51578C6.62295 9.51562 6.43768 9.59519 6.30098 9.737C6.16427 9.87881 6.08731 10.0712 6.08702 10.272C6.08681 10.3717 6.10551 10.4704 6.14205 10.5625C6.1786 10.6547 6.23228 10.7384 6.3 10.8089C6.36773 10.8794 6.44818 10.9354 6.53673 10.9735C6.62529 11.0116 6.72021 11.0312 6.81606 11.0311C6.91191 11.0312 7.00683 11.0116 7.09539 10.9735C7.18394 10.9354 7.26439 10.8794 7.33212 10.8089C7.39984 10.7384 7.45352 10.6547 7.49007 10.5625C7.52661 10.4704 7.54531 10.3717 7.5451 10.272C7.5448 10.0712 7.46785 9.87881 7.33114 9.737C7.19444 9.59519 7.00917 9.51562 6.81606 9.51578ZM16.399 9.51578C16.2058 9.51562 16.0206 9.59519 15.8839 9.737C15.7472 9.87881 15.6702 10.0712 15.6699 10.272C15.6697 10.3717 15.6884 10.4704 15.7249 10.5625C15.7615 10.6547 15.8152 10.7384 15.8829 10.8089C15.9506 10.8794 16.0311 10.9354 16.1196 10.9735C16.2082 11.0116 16.3031 11.0312 16.399 11.0311C16.4948 11.0311 16.5896 11.0115 16.6781 10.9733C16.7666 10.9352 16.847 10.8792 16.9146 10.8087C16.9823 10.7382 17.0359 10.6545 17.0724 10.5624C17.109 10.4703 17.1276 10.3716 17.1274 10.272C17.1271 10.0714 17.0502 9.879 16.9137 9.73721C16.7771 9.59542 16.592 9.51578 16.399 9.51578Z" stroke-width="0.67"/>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

@ -47,6 +47,14 @@ export class ContainerStatsViewModel {
this.NumProcs = data.num_procs || 0;
this.isWindows = true;
}
// Podman has memory limit and usage but not stats
else if (
data?.memory_stats?.usage !== undefined &&
data?.memory_stats?.stats === undefined
) {
this.MemoryUsage = data.memory_stats.usage || 0;
this.MemoryCache = 0;
}
// Linux
else if (
data?.memory_stats?.stats === undefined ||

@ -118,7 +118,7 @@ const ngModule = angular
)
.component(
'dockerContainerProcessesDatatable',
r2a(ProcessesDatatable, ['dataset', 'headers'])
r2a(withUIRouter(withReactQuery(withCurrentUser(ProcessesDatatable))), [])
)
.component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset']))
.component(

@ -17,7 +17,7 @@ import { resizeTTY } from '@/react/docker/containers/queries/useContainerResizeT
import { updateContainer } from '@/react/docker/containers/queries/useUpdateContainer';
import { createExec } from '@/react/docker/containers/queries/useCreateExecMutation';
import { containerStats } from '@/react/docker/containers/queries/useContainerStats';
import { containerTop } from '@/react/docker/containers/queries/useContainerTop';
import { getContainerTop } from '@/react/docker/containers/queries/useContainerTop';
import { ContainerDetailsViewModel } from '../models/containerDetails';
import { ContainerStatsViewModel } from '../models/containerStats';
@ -45,7 +45,7 @@ function ContainerServiceFactory(AngularToReact) {
updateRestartPolicy: useAxios(updateRestartPolicyAngularJS), // container edit
createExec: useAxios(createExec), // container console
containerStats: useAxios(containerStatsAngularJS), // container stats
containerTop: useAxios(containerTop), // container stats
containerTop: useAxios(getContainerTop), // container stats
inspect: useAxios(getContainer), // container inspect
logs: useAxios(containerLogsAngularJS), // container logs
};

@ -1,6 +1,6 @@
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import { getInfo } from '@/react/docker/proxy/queries/useInfo';
import { aggregateData, getPlugins } from '@/react/docker/proxy/queries/useServicePlugins';
import { aggregateData, getPlugins } from '@/react/docker/proxy/queries/usePlugins';
angular.module('portainer.docker').factory('PluginService', PluginServiceFactory);

@ -43,11 +43,12 @@
Remove</button
>
</div>
<div class="btn-group" role="group" aria-label="..." ng-if="displayRecreateButton" authorization="DockerContainerCreate">
<div class="btn-group" role="group" aria-label="..." ng-if="displayRecreateButton || displayDuplicateEditButton" authorization="DockerContainerCreate">
<button
type="button"
class="btn btn-light btn-sm"
ng-disabled="state.recreateContainerInProgress || container.IsPortainer"
ng-if="displayRecreateButton"
ng-click="recreate()"
button-spinner="state.recreateContainerInProgress"
>
@ -57,7 +58,13 @@
>
<span ng-show="state.recreateContainerInProgress">Recreation in progress...</span>
</button>
<a class="btn btn-light btn-sm" type="button" ui-sref="docker.containers.new({ from: container.Id, nodeName: nodeName })" ng-disabled="container.IsPortainer">
<a
class="btn btn-light btn-sm"
type="button"
ui-sref="docker.containers.new({ from: container.Id, nodeName: nodeName })"
ng-disabled="container.IsPortainer"
ng-if="displayDuplicateEditButton"
>
<pr-icon icon="'copy'"></pr-icon>
Duplicate/Edit</a
>
@ -218,7 +225,7 @@
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!state.pullImageValidity || !config.RegistryModel.Image || config.commitInProgress"
ng-click="commit()"
>

@ -6,6 +6,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { ResourceControlType } from '@/react/portainer/access-control/types';
import { confirmContainerRecreation } from '@/react/docker/containers/ItemView/ConfirmRecreationModal';
import { commitContainer } from '@/react/docker/proxy/queries/useCommitContainerMutation';
import { ContainerEngine } from '@/react/portainer/environments/types';
angular.module('portainer.docker').controller('ContainerController', [
'$q',
@ -123,7 +124,11 @@ angular.module('portainer.docker').controller('ContainerController', [
!allowHostNamespaceForRegularUsers ||
!allowPrivilegedModeForRegularUsers;
$scope.displayRecreateButton = !inSwarm && !autoRemove && (admin || !settingRestrictsRegularUsers);
// displayRecreateButton should false for podman because recreating podman containers give and error: cannot set memory swappiness with cgroupv2
// https://github.com/containrrr/watchtower/issues/1060#issuecomment-2319076222
const isPodman = endpoint.ContainerEngine === ContainerEngine.Podman;
$scope.displayDuplicateEditButton = !inSwarm && !autoRemove && (admin || !settingRestrictsRegularUsers);
$scope.displayRecreateButton = !inSwarm && !autoRemove && (admin || !settingRestrictsRegularUsers) && !isPodman;
$scope.displayCreateWebhookButton = $scope.displayRecreateButton;
})
.catch(function error(err) {

@ -97,11 +97,9 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
function startChartUpdate(networkChart, cpuChart, memoryChart, ioChart) {
$q.all({
stats: ContainerService.containerStats(endpoint.Id, $transition$.params().id),
top: ContainerService.containerTop(endpoint.Id, $transition$.params().id),
})
.then(function success(data) {
var stats = data.stats;
$scope.processInfo = data.top;
if (stats.Networks.length === 0) {
$scope.state.networkStatsUnavailable = true;
}
@ -125,11 +123,9 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
$scope.repeater = $interval(function () {
$q.all({
stats: ContainerService.containerStats(endpoint.Id, $transition$.params().id),
top: ContainerService.containerTop(endpoint.Id, $transition$.params().id),
})
.then(function success(data) {
var stats = data.stats;
$scope.processInfo = data.top;
updateNetworkChart(stats, networkChart);
updateMemoryChart(stats, memoryChart);
updateCPUChart(stats, cpuChart);

@ -108,4 +108,4 @@
</div>
</div>
<docker-container-processes-datatable dataset="processInfo.Processes" headers="processInfo.Titles"></docker-container-processes-datatable>
<docker-container-processes-datatable></docker-container-processes-datatable>

@ -30,7 +30,7 @@
<div class="form-group">
<span class="col-sm-12 text-muted small">
A name must be specified in one of the following formats: <code>name:tag</code>, <code>repository/name:tag</code> or
<code>registryfqdn:port/repository/name:tag</code> format. If you omit the tag the default <b>latest</b> value is assumed.
<code>registry:port/repository/name:tag</code> format. If you omit the tag the default <b>latest</b> value is assumed.
</span>
</div>
<div class="form-group">

@ -86,7 +86,7 @@
<!-- !tag-note -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.RegistryModel.Image" ng-click="tagImage()">Tag</button>
<button type="button" class="btn btn-primary btn-sm !ml-0" ng-disabled="!formValues.RegistryModel.Image" ng-click="tagImage()">Tag</button>
</div>
</div>
</form>
@ -103,7 +103,7 @@
<table class="table">
<tbody>
<tr>
<td>ID</td>
<td class="min-w-[80px]">ID</td>
<td>
{{ image.Id }}
<button authorization="DockerImageDelete" class="btn btn-xs btn-danger" ng-click="removeImage(image.Id)">
@ -145,7 +145,7 @@
<td>
<table class="table-bordered table-condensed table">
<tr ng-repeat="(k, v) in image.Labels">
<td>{{ k }}</td>
<td class="min-w-[80px]">{{ k }}</td>
<td>{{ v }}</td>
</tr>
</table>

@ -2,6 +2,7 @@ import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImageController', [
'$async',
@ -71,8 +72,9 @@ angular.module('portainer.docker').controller('ImageController', [
const registryModel = $scope.formValues.RegistryModel;
const image = ImageHelper.createImageConfigForContainer(registryModel);
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
ImageService.tagImage($transition$.params().id, image.fromImage)
ImageService.tagImage($transition$.params().id, repo, tag)
.then(function success() {
Notifications.success('Success', 'Image successfully tagged');
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });

@ -1,15 +1,17 @@
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImportImageController', [
'$scope',
'$state',
'$async',
'ImageService',
'Notifications',
'HttpRequestHelper',
'Authentication',
'ImageHelper',
'endpoint',
function ($scope, $state, ImageService, Notifications, HttpRequestHelper, Authentication, ImageHelper, endpoint) {
function ($scope, $state, $async, ImageService, Notifications, HttpRequestHelper, Authentication, ImageHelper, endpoint) {
$scope.state = {
actionInProgress: false,
};
@ -33,15 +35,20 @@ angular.module('portainer.docker').controller('ImportImageController', [
const registryModel = $scope.formValues.RegistryModel;
if (registryModel.Image) {
const image = ImageHelper.createImageConfigForContainer(registryModel);
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
try {
await ImageService.tagImage(id, image.fromImage);
await ImageService.tagImage(id, repo, tag);
} catch (err) {
Notifications.error('Failure', err, 'Unable to tag image');
}
}
}
$scope.uploadImage = async function () {
$scope.uploadImage = function () {
return $async(uploadImageAsync);
};
async function uploadImageAsync() {
$scope.state.actionInProgress = true;
var nodeName = $scope.formValues.NodeName;
@ -52,7 +59,8 @@ angular.module('portainer.docker').controller('ImportImageController', [
if (data.error) {
Notifications.error('Failure', data.error, 'Unable to upload image');
} else if (data.stream) {
var regex = /Loaded.*?: (.*?)\n$/g;
// docker has /n at the end of the stream, podman doesn't
var regex = /Loaded.*?: (.*?)(?:\n|$)/g;
var imageIds = regex.exec(data.stream);
if (imageIds && imageIds.length == 2) {
await tagImage(imageIds[1]);
@ -67,6 +75,6 @@ angular.module('portainer.docker').controller('ImportImageController', [
} finally {
$scope.state.actionInProgress = false;
}
};
}
},
]);

@ -12,7 +12,7 @@
</div>
<div class="form-group">
<div class="col-sm-12 vertical-center">
<button type="button" class="btn btn-sm btn-primary" ngf-select ngf-min-size="10" ng-model="formValues.UploadFile">Select file</button>
<button type="button" class="btn btn-sm btn-primary !ml-0" ngf-select ngf-min-size="10" ng-model="formValues.UploadFile">Select file</button>
<span class="ml-1">
{{ formValues.UploadFile.name }}
<pr-icon icon="'x'" mode="'danger'" ng-if="!formValues.UploadFile"></pr-icon>
@ -27,7 +27,7 @@
<!-- !node-selection -->
</div>
<div class="row" authorization="DockerImageCreate">
<div class="col-lg-12 col-md-12 col-xs-12">
<div class="col-lg-12 col-md-12 col-xs-12 p-0">
<rd-widget>
<rd-widget-header icon="tag" title-text="Tag the image"></rd-widget-header>
<rd-widget-body>
@ -51,7 +51,7 @@
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !formValues.UploadFile"
ng-click="uploadImage()"
button-spinner="state.actionInProgress"

@ -11,6 +11,15 @@
<td>ID</td>
<td>
{{ volume.Id }}
<button
ng-if="showBrowseAction"
class="btn btn-xs btn-primary"
ui-sref="docker.volumes.volume.browse({ id: volume.Id, nodeName: volume.NodeName })"
authorization="DockerAgentBrowseList"
>
<pr-icon icon="'search'" class="leading-none"></pr-icon>
Browse
</button>
<button authorization="DockerVolumeDelete" class="btn btn-xs btn-danger" ng-click="removeVolume()"
><pr-icon icon="'trash-2'" class="leading-none"></pr-icon> Remove this volume</button
>

@ -9,10 +9,12 @@ angular.module('portainer.docker').controller('VolumeController', [
'ContainerService',
'Notifications',
'HttpRequestHelper',
'Authentication',
'endpoint',
function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper, endpoint) {
function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper, Authentication, endpoint) {
$scope.resourceType = ResourceControlType.Volume;
$scope.endpoint = endpoint;
$scope.showBrowseAction = false;
$scope.onUpdateResourceControlSuccess = function () {
$state.reload();
@ -41,6 +43,7 @@ angular.module('portainer.docker').controller('VolumeController', [
function initView() {
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
$scope.showBrowseAction = $scope.applicationState.endpoint.mode.agentProxy && (Authentication.isAdmin() || endpoint.SecuritySettings.allowVolumeBrowserForRegularUsers);
VolumeService.volume($transition$.params().id)
.then(function success(data) {

@ -1,12 +1,6 @@
import moment from 'moment';
import _ from 'lodash-es';
import filesize from 'filesize';
import { Cloud } from 'lucide-react';
import Kube from '@/assets/ico/kube.svg?c';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
import { EnvironmentType } from '@/react/portainer/environments/types';
export function truncateLeftRight(text, max, left, right) {
max = isNaN(max) ? 50 : max;
@ -105,24 +99,6 @@ export function endpointTypeName(type) {
return '';
}
export function environmentTypeIcon(type) {
switch (type) {
case EnvironmentType.Azure:
return MicrosoftIcon;
case EnvironmentType.EdgeAgentOnDocker:
return Cloud;
case EnvironmentType.AgentOnKubernetes:
case EnvironmentType.EdgeAgentOnKubernetes:
case EnvironmentType.KubernetesLocal:
return Kube;
case EnvironmentType.AgentOnDocker:
case EnvironmentType.Docker:
return DockerIcon;
default:
throw new Error(`type ${type}-${EnvironmentType[type]} is not supported`);
}
}
export function truncate(text, length, end) {
if (isNaN(length)) {
length = 10;

@ -4,7 +4,6 @@ import _ from 'lodash-es';
import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn';
import {
arrayToStr,
environmentTypeIcon,
endpointTypeName,
getPairKey,
getPairValue,
@ -34,5 +33,4 @@ angular
.filter('arraytostr', () => arrayToStr)
.filter('labelsToStr', () => labelsToStr)
.filter('endpointtypename', () => endpointTypeName)
.filter('endpointtypeicon', () => environmentTypeIcon)
.filter('ownershipicon', () => ownershipIcon);

@ -53,7 +53,7 @@ function EndpointController(
showAMTInfo: false,
showTLSConfig: false,
edgeScriptCommands: {
linux: _.compact([commandsTabs.k8sLinux, commandsTabs.swarmLinux, commandsTabs.standaloneLinux]),
linux: _.compact([commandsTabs.k8sLinux, commandsTabs.swarmLinux, commandsTabs.standaloneLinux, commandsTabs.podmanLinux]),
win: [commandsTabs.swarmWindows, commandsTabs.standaloneWindow],
},
};
@ -297,7 +297,6 @@ function EndpointController(
return $async(async () => {
try {
const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]);
if (isDockerAPIEnvironment(endpoint)) {
$scope.state.showTLSConfig = true;
}

@ -162,7 +162,7 @@
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress
|| !createStackForm.$valid
|| ((state.Method === 'editor' || state.Method === 'template') && (!formValues.StackFileContent || state.editorYamlValidationError))

@ -2,7 +2,10 @@ import _ from 'lodash';
import { Team } from '@/react/portainer/users/teams/types';
import { Role, User, UserId } from '@/portainer/users/types';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
export function createMockUsers(
count: number,
@ -69,6 +72,7 @@ export function createMockEnvironment(): Environment {
TagIds: [],
GroupId: 1,
Type: 1,
ContainerEngine: ContainerEngine.Docker,
Name: 'environment',
Status: 1,
URL: 'url',

@ -46,7 +46,7 @@ export function Tooltip({
position={position}
className={className}
>
<HelpCircle className="lucide" aria-hidden="true" />
<HelpCircle className="lucide" />
</TooltipWithChildren>
</span>
);

@ -25,7 +25,7 @@ export function CopyButton({
fadeDelay = 1000,
displayText = 'copied',
className,
color,
color = 'default',
indicatorPosition = 'right',
children,
'data-cy': dataCy,
@ -52,7 +52,7 @@ export function CopyButton({
<div className={styles.container}>
{indicatorPosition === 'left' && copiedIndicator()}
<Button
className={className}
className={clsx(className, '!ml-0')}
color={color}
size="small"
onClick={handleCopy}

@ -0,0 +1,302 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import {
createColumnHelper,
createTable,
getCoreRowModel,
} from '@tanstack/react-table';
import { Datatable, defaultGlobalFilterFn, Props } from './Datatable';
import {
BasicTableSettings,
createPersistedStore,
refreshableSettings,
RefreshableTableSettings,
} from './types';
import { useTableState } from './useTableState';
// Mock data and dependencies
type MockData = { id: string; name: string; age: number };
const mockData = [
{ id: '1', name: 'John Doe', age: 30 },
{ id: '2', name: 'Jane Smith', age: 25 },
{ id: '3', name: 'Bob Johnson', age: 35 },
];
const mockColumns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'age', header: 'Age' },
];
// mock table settings / state
export interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings {}
function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
...refreshableSettings(set),
}));
}
const storageKey = 'test-table';
const settingsStore = createStore(storageKey);
const mockSettingsManager = {
pageSize: 10,
search: '',
sortBy: undefined,
setSearch: vitest.fn(),
setSortBy: vitest.fn(),
setPageSize: vitest.fn(),
};
function DatatableWithStore(props: Omit<Props<MockData>, 'settingsManager'>) {
const tableState = useTableState(settingsStore, storageKey);
return (
<Datatable {...props} settingsManager={tableState} data-cy="test-table" />
);
}
describe('Datatable', () => {
it('renders the table with correct data', () => {
render(
<DatatableWithStore
dataset={mockData}
columns={mockColumns}
data-cy="test-table"
/>
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
it('renders the table with a title', () => {
render(
<DatatableWithStore
dataset={mockData}
columns={mockColumns}
title="Test Table"
data-cy="test-table"
/>
);
expect(screen.getByText('Test Table')).toBeInTheDocument();
});
it('handles row selection when not disabled', () => {
render(
<DatatableWithStore
dataset={mockData}
columns={mockColumns}
data-cy="test-table"
/>
);
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[1]); // Select the first row
// Check if the row is selected (you might need to adapt this based on your implementation)
expect(checkboxes[1]).toBeChecked();
});
it('disables row selection when disableSelect is true', () => {
render(
<DatatableWithStore
dataset={mockData}
columns={mockColumns}
disableSelect
data-cy="test-table"
/>
);
const checkboxes = screen.queryAllByRole('checkbox');
expect(checkboxes.length).toBe(0);
});
it('handles sorting', () => {
render(
<Datatable
dataset={mockData}
columns={mockColumns}
settingsManager={mockSettingsManager}
data-cy="test-table"
/>
);
const nameHeader = screen.getByText('Name');
fireEvent.click(nameHeader);
// Check if setSortBy was called with the correct arguments
expect(mockSettingsManager.setSortBy).toHaveBeenCalledWith('name', true);
});
it('renders loading state', () => {
render(
<DatatableWithStore
dataset={mockData}
columns={mockColumns}
isLoading
data-cy="test-table"
/>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('renders empty state', () => {
render(
<DatatableWithStore
dataset={[]}
columns={mockColumns}
emptyContentLabel="No data available"
data-cy="test-table"
/>
);
expect(screen.getByText('No data available')).toBeInTheDocument();
});
});
// Test the defaultGlobalFilterFn used in searches
type Person = {
id: string;
name: string;
age: number;
isEmployed: boolean;
tags?: string[];
city?: string;
family?: { sister: string; uncles?: string[] };
};
const data: Person[] = [
{
// searching primitives should be supported
id: '1',
name: 'Alice',
age: 30,
isEmployed: true,
// supporting arrays of primitives should be supported
tags: ['music', 'likes-pixar'],
// supporting objects of primitives should be supported (values only).
// but shouldn't be support nested objects / arrays
family: { sister: 'sophie', uncles: ['john', 'david'] },
},
];
const columnHelper = createColumnHelper<Person>();
const columns = [
columnHelper.accessor('name', {
id: 'name',
}),
columnHelper.accessor('isEmployed', {
id: 'isEmployed',
}),
columnHelper.accessor('age', {
id: 'age',
}),
columnHelper.accessor('tags', {
id: 'tags',
}),
columnHelper.accessor('family', {
id: 'family',
}),
];
const mockTable = createTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
state: {},
onStateChange() {},
renderFallbackValue: undefined,
getRowId: (row) => row.id,
});
const mockRow = mockTable.getRow('1');
describe('defaultGlobalFilterFn', () => {
it('should return true when filterValue is null', () => {
const result = defaultGlobalFilterFn(mockRow, 'Name', null);
expect(result).toBe(true);
});
it('should return true when filterValue.search is empty', () => {
const result = defaultGlobalFilterFn(mockRow, 'Name', {
search: '',
});
expect(result).toBe(true);
});
it('should filter string values correctly', () => {
expect(
defaultGlobalFilterFn(mockRow, 'name', {
search: 'hello',
})
).toBe(false);
expect(
defaultGlobalFilterFn(mockRow, 'name', {
search: 'ALICE',
})
).toBe(true);
expect(
defaultGlobalFilterFn(mockRow, 'name', {
search: 'Alice',
})
).toBe(true);
});
it('should filter number values correctly', () => {
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '123' })).toBe(
false
);
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '30' })).toBe(true);
expect(defaultGlobalFilterFn(mockRow, 'age', { search: '67' })).toBe(false);
});
it('should filter boolean values correctly', () => {
expect(
defaultGlobalFilterFn(mockRow, 'isEmployed', { search: 'true' })
).toBe(true);
expect(
defaultGlobalFilterFn(mockRow, 'isEmployed', { search: 'false' })
).toBe(false);
});
it('should filter object values correctly', () => {
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'sophie' })).toBe(
true
);
expect(defaultGlobalFilterFn(mockRow, 'family', { search: '30' })).toBe(
false
);
});
it('should filter array values correctly', () => {
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'music' })).toBe(
true
);
expect(
defaultGlobalFilterFn(mockRow, 'tags', { search: 'Likes-Pixar' })
).toBe(true);
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'grape' })).toBe(
false
);
expect(defaultGlobalFilterFn(mockRow, 'tags', { search: 'likes' })).toBe(
true
);
});
it('should handle complex nested structures', () => {
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'sophie' })).toBe(
true
);
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'mason' })).toBe(
false
);
});
it('should not filter non-primitive values within objects and arrays', () => {
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'john' })).toBe(
false
);
expect(defaultGlobalFilterFn(mockRow, 'family', { search: 'david' })).toBe(
false
);
});
});

@ -272,6 +272,21 @@ export function defaultGlobalFilterFn<D, TFilter extends { search: string }>(
const filterValueLower = filterValue.search.toLowerCase();
if (typeof value === 'object') {
return Object.values(value).some((item) =>
filterPrimitive(item, filterValueLower)
);
}
if (Array.isArray(value)) {
return value.some((item) => filterPrimitive(item, filterValueLower));
}
return filterPrimitive(value, filterValueLower);
}
// only filter primitive values within objects and arrays, to avoid searching nested objects
function filterPrimitive(value: unknown, filterValueLower: string) {
if (
typeof value === 'string' ||
typeof value === 'number' ||
@ -279,13 +294,6 @@ export function defaultGlobalFilterFn<D, TFilter extends { search: string }>(
) {
return value.toString().toLowerCase().includes(filterValueLower);
}
if (Array.isArray(value)) {
return value.some((item) =>
item.toString().toLowerCase().includes(filterValueLower)
);
}
return false;
}

@ -1,6 +1,8 @@
import { ZapIcon } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { getDockerEnvironmentType } from '@/react/portainer/environments/utils/getDockerEnvironmentType';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { Icon } from '@@/Icon';
@ -9,6 +11,7 @@ import { useInfo } from '../proxy/queries/useInfo';
export function DockerInfo({ isAgent }: { isAgent: boolean }) {
const envId = useEnvironmentId();
const infoQuery = useInfo(envId);
const isPodman = useIsPodman(envId);
if (!infoQuery.data) {
return null;
@ -16,16 +19,22 @@ export function DockerInfo({ isAgent }: { isAgent: boolean }) {
const info = infoQuery.data;
const isSwarm = info.Swarm && info.Swarm.NodeID !== '';
const isSwarm = info.Swarm !== undefined && info.Swarm?.NodeID !== '';
const type = getDockerEnvironmentType(isSwarm, isPodman);
return (
<span className="small text-muted">
{isSwarm ? 'Swarm' : 'Standalone'} {info.ServerVersion}
<span className="inline-flex gap-x-2 small text-muted">
<span>
{type} {info.ServerVersion}
</span>
{isAgent && (
<span className="flex gap-1 items-center">
<Icon icon={ZapIcon} />
Agent
</span>
<>
<span>-</span>
<span className="inline-flex items-center">
<Icon icon={ZapIcon} />
Agent
</span>
</>
)}
</span>
);

@ -44,7 +44,8 @@ export function EnvironmentInfo() {
<DetailsTable.Row label="Environment">
<div className="flex items-center gap-2">
{environment.Name}
<SnapshotStats snapshot={environment.Snapshots[0]} />-
<SnapshotStats snapshot={environment.Snapshots[0]} />
<span className="text-muted">-</span>
<DockerInfo isAgent={isAgent} />
</div>
</DetailsTable.Row>

@ -39,7 +39,7 @@ export function PortsMappingField({
label="Port mapping"
value={value}
onChange={onChange}
addLabel="map additional port"
addLabel="Map additional port"
itemBuilder={() => ({
hostPort: '',
containerPort: '',
@ -79,7 +79,7 @@ function Item({
readOnly={readOnly}
value={item.hostPort}
onChange={(e) => handleChange('hostPort', e.target.value)}
label="host"
label="Host"
placeholder="e.g. 80"
className="w-1/2"
id={`hostPort-${index}`}
@ -95,7 +95,7 @@ function Item({
readOnly={readOnly}
value={item.containerPort}
onChange={(e) => handleChange('containerPort', e.target.value)}
label="container"
label="Container"
placeholder="e.g. 80"
className="w-1/2"
id={`containerPort-${index}`}
@ -105,7 +105,10 @@ function Item({
<ButtonSelector<Protocol>
onChange={(value) => handleChange('protocol', value)}
value={item.protocol}
options={[{ value: 'tcp' }, { value: 'udp' }]}
options={[
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
]}
disabled={disabled}
readOnly={readOnly}
/>

@ -2,8 +2,9 @@ import { FormikErrors } from 'formik';
import { array, object, SchemaOf, string } from 'yup';
import _ from 'lodash';
import { useLoggingPlugins } from '@/react/docker/proxy/queries/useServicePlugins';
import { useLoggingPlugins } from '@/react/docker/proxy/queries/usePlugins';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
@ -30,8 +31,9 @@ export function LoggerConfig({
errors?: FormikErrors<LogConfig>;
}) {
const envId = useEnvironmentId();
const pluginsQuery = useLoggingPlugins(envId, apiVersion < 1.25);
const isPodman = useIsPodman(envId);
const isSystem = apiVersion < 1.25;
const pluginsQuery = useLoggingPlugins(envId, isSystem, isPodman);
if (!pluginsQuery.data) {
return null;

@ -1,5 +1,8 @@
import { FormikErrors } from 'formik';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
@ -19,12 +22,15 @@ export function NetworkTab({
setFieldValue: (field: string, value: unknown) => void;
errors?: FormikErrors<Values>;
}) {
const envId = useEnvironmentId();
const isPodman = useIsPodman(envId);
const additionalOptions = getAdditionalOptions(isPodman);
return (
<div className="mt-3">
<FormControl label="Network" errors={errors?.networkMode}>
<NetworkSelector
value={values.networkMode}
additionalOptions={[{ label: 'Container', value: CONTAINER_MODE }]}
additionalOptions={additionalOptions}
onChange={(networkMode) => setFieldValue('networkMode', networkMode)}
/>
</FormControl>
@ -105,3 +111,10 @@ export function NetworkTab({
</div>
);
}
function getAdditionalOptions(isPodman?: boolean) {
if (isPodman) {
return [];
}
return [{ label: 'Container', value: CONTAINER_MODE }];
}

@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { DockerNetwork } from '@/react/docker/networks/types';
import { ContainerListViewModel } from '../../types';
import { ContainerDetailsJSON } from '../../queries/useContainer';
import { getDefaultViewModel, getNetworkMode } from './toViewModel';
describe('getDefaultViewModel', () => {
it('should return the correct default view model for Windows', () => {
const result = getDefaultViewModel(true);
expect(result).toEqual({
networkMode: 'nat',
hostname: '',
domain: '',
macAddress: '',
ipv4Address: '',
ipv6Address: '',
primaryDns: '',
secondaryDns: '',
hostsFileEntries: [],
container: '',
});
});
it('should return the correct default view model for Podman', () => {
const result = getDefaultViewModel(false, true);
expect(result).toEqual({
networkMode: 'podman',
hostname: '',
domain: '',
macAddress: '',
ipv4Address: '',
ipv6Address: '',
primaryDns: '',
secondaryDns: '',
hostsFileEntries: [],
container: '',
});
});
it('should return the correct default view model for Linux Docker', () => {
const result = getDefaultViewModel(false);
expect(result).toEqual({
networkMode: 'bridge',
hostname: '',
domain: '',
macAddress: '',
ipv4Address: '',
ipv6Address: '',
primaryDns: '',
secondaryDns: '',
hostsFileEntries: [],
container: '',
});
});
});
describe('getNetworkMode', () => {
const mockNetworks: Array<DockerNetwork> = [
{
Name: 'bridge',
Id: 'bridge-id',
Driver: 'bridge',
Scope: 'local',
Attachable: false,
Internal: false,
IPAM: { Config: [], Driver: '', Options: {} },
Options: {},
Containers: {},
},
{
Name: 'host',
Id: 'host-id',
Driver: 'host',
Scope: 'local',
Attachable: false,
Internal: false,
IPAM: { Config: [], Driver: '', Options: {} },
Options: {},
Containers: {},
},
{
Name: 'custom',
Id: 'custom-id',
Driver: 'bridge',
Scope: 'local',
Attachable: true,
Internal: false,
IPAM: { Config: [], Driver: '', Options: {} },
Options: {},
Containers: {},
},
];
const mockRunningContainers: Array<ContainerListViewModel> = [
{
Id: 'container-1',
Names: ['container-1-name'],
} as ContainerListViewModel, // gaslight the type to avoid over-specifying
];
it('should return the network mode from HostConfig', () => {
const config: ContainerDetailsJSON = {
HostConfig: { NetworkMode: 'host' },
};
expect(getNetworkMode(config, mockNetworks)).toEqual(['host']);
});
it('should return the network mode from NetworkSettings if HostConfig is empty', () => {
const config: ContainerDetailsJSON = {
NetworkSettings: { Networks: { custom: {} } },
};
expect(getNetworkMode(config, mockNetworks)).toEqual(['custom']);
});
it('should return container mode when NetworkMode starts with "container:"', () => {
const config: ContainerDetailsJSON = {
HostConfig: { NetworkMode: 'container:container-1' },
};
expect(getNetworkMode(config, mockNetworks, mockRunningContainers)).toEqual(
['container', 'container-1-name']
);
});
it('should return "podman" for bridge network when isPodman is true', () => {
const config: ContainerDetailsJSON = {
HostConfig: { NetworkMode: 'bridge' },
};
expect(getNetworkMode(config, mockNetworks, [], true)).toEqual(['podman']);
});
it('should return "bridge" for default network mode on Docker', () => {
const config: ContainerDetailsJSON = {
HostConfig: { NetworkMode: 'default' },
};
expect(getNetworkMode(config, mockNetworks)).toEqual(['bridge']);
});
it('should return the first available network if no matching network is found', () => {
const config: ContainerDetailsJSON = {
HostConfig: { NetworkMode: 'non-existent' },
};
expect(getNetworkMode(config, mockNetworks)).toEqual(['bridge']);
});
});

@ -5,8 +5,8 @@ import { ContainerListViewModel } from '../../types';
import { CONTAINER_MODE, Values } from './types';
export function getDefaultViewModel(isWindows: boolean) {
const networkMode = isWindows ? 'nat' : 'bridge';
export function getDefaultViewModel(isWindows: boolean, isPodman?: boolean) {
const networkMode = getDefaultNetworkMode(isWindows, isPodman);
return {
networkMode,
hostname: '',
@ -21,10 +21,17 @@ export function getDefaultViewModel(isWindows: boolean) {
};
}
export function getDefaultNetworkMode(isWindows: boolean, isPodman?: boolean) {
if (isWindows) return 'nat';
if (isPodman) return 'podman';
return 'bridge';
}
export function toViewModel(
config: ContainerDetailsJSON,
networks: Array<DockerNetwork>,
runningContainers: Array<ContainerListViewModel> = []
runningContainers: Array<ContainerListViewModel> = [],
isPodman?: boolean
): Values {
const dns = config.HostConfig?.Dns;
const [primaryDns = '', secondaryDns = ''] = dns || [];
@ -34,7 +41,8 @@ export function toViewModel(
const [networkMode, container = ''] = getNetworkMode(
config,
networks,
runningContainers
runningContainers,
isPodman
);
const networkSettings = config.NetworkSettings?.Networks?.[networkMode];
@ -61,10 +69,11 @@ export function toViewModel(
};
}
function getNetworkMode(
export function getNetworkMode(
config: ContainerDetailsJSON,
networks: Array<DockerNetwork>,
runningContainers: Array<ContainerListViewModel> = []
runningContainers: Array<ContainerListViewModel> = [],
isPodman?: boolean
) {
let networkMode = config.HostConfig?.NetworkMode || '';
if (!networkMode) {
@ -85,6 +94,9 @@ function getNetworkMode(
const networkNames = networks.map((n) => n.Name);
if (networkNames.includes(networkMode)) {
if (isPodman && networkMode === 'bridge') {
return ['podman'] as const;
}
return [networkMode] as const;
}
@ -92,6 +104,9 @@ function getNetworkMode(
networkNames.includes('bridge') &&
(!networkMode || networkMode === 'default' || networkMode === 'bridge')
) {
if (isPodman) {
return ['podman'] as const;
}
return ['bridge'] as const;
}

@ -1,5 +1,6 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import {
BaseFormValues,
baseFormUtils,
@ -46,6 +47,8 @@ import { useNetworksForSelector } from '../components/NetworkSelector';
import { useContainers } from '../queries/useContainers';
import { useContainer } from '../queries/useContainer';
import { getDefaultNetworkMode } from './NetworkTab/toViewModel';
export interface Values extends BaseFormValues {
commands: CommandsTabValues;
volumes: VolumesTabValues;
@ -80,6 +83,7 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
const registriesQuery = useEnvironmentRegistries(environmentId, {
enabled: !!from,
});
const isPodman = useIsPodman(environmentId);
if (!networksQuery.data) {
return null;
@ -87,7 +91,13 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
if (!from) {
return {
initialValues: defaultValues(isPureAdmin, user.Id, nodeName, isWindows),
initialValues: defaultValues(
isPureAdmin,
user.Id,
nodeName,
isWindows,
isPodman
),
};
}
@ -110,7 +120,11 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
const extraNetworks = Object.entries(
fromContainer.NetworkSettings?.Networks || {}
)
.filter(([n]) => n !== network.networkMode)
.filter(
([n]) =>
n !== network.networkMode &&
n !== getDefaultNetworkMode(isWindows, isPodman)
)
.map(([networkName, network]) => ({
networkName,
aliases: (network.Aliases || []).filter(
@ -129,7 +143,8 @@ export function useInitialValues(submitting: boolean, isWindows: boolean) {
network: networkTabUtils.toViewModel(
fromContainer,
networksQuery.data,
runningContainersQuery.data
runningContainersQuery.data,
isPodman
),
labels: labelsTabUtils.toViewModel(fromContainer),
restartPolicy: restartPolicyTabUtils.toViewModel(fromContainer),
@ -153,12 +168,13 @@ function defaultValues(
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string,
isWindows: boolean
isWindows: boolean,
isPodman?: boolean
): Values {
return {
commands: commandsTabUtils.getDefaultViewModel(),
volumes: volumesTabUtils.getDefaultViewModel(),
network: networkTabUtils.getDefaultViewModel(isWindows), // windows containers should default to the nat network, not the bridge
network: networkTabUtils.getDefaultViewModel(isWindows, isPodman), // windows containers should default to the nat network, not the bridge
labels: labelsTabUtils.getDefaultViewModel(),
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
resources: resourcesTabUtils.getDefaultViewModel(),

@ -1,58 +1,83 @@
import { ColumnDef } from '@tanstack/react-table';
import { List } from 'lucide-react';
import { useMemo } from 'react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { useContainerTop } from '../queries/useContainerTop';
import { ContainerProcesses } from '../queries/types';
const tableKey = 'container-processes';
const store = createPersistedStore(tableKey);
export function ProcessesDatatable({
dataset,
headers,
}: {
dataset?: Array<Array<string | number>>;
headers?: Array<string>;
}) {
const tableState = useTableState(store, tableKey);
const rows = useMemo(() => {
if (!dataset || !headers) {
return [];
}
type ProcessRow = {
id: number;
};
return dataset.map((row, index) => ({
id: index,
...Object.fromEntries(
headers.map((header, index) => [header, row[index]])
),
}));
}, [dataset, headers]);
const columns = useMemo(
() =>
headers
? headers.map(
(header) =>
({ header, accessorKey: header }) satisfies ColumnDef<{
[k: string]: string;
}>
)
: [],
[headers]
type ProcessesDatatableProps = {
rows: Array<ProcessRow>;
columns: Array<ColumnDef<ProcessRow>>;
};
export function ProcessesDatatable() {
const {
params: { id: containerId },
} = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const topQuery = useContainerTop(
environmentId,
containerId,
(containerProcesses: ContainerProcesses) =>
parseContainerProcesses(containerProcesses)
);
const tableState = useTableState(store, tableKey);
return (
<Datatable
title="Processes"
titleIcon={List}
dataset={rows}
columns={columns}
dataset={topQuery.data?.rows ?? []}
columns={topQuery.data?.columns ?? []}
settingsManager={tableState}
disableSelect
isLoading={!dataset}
isLoading={topQuery.isLoading}
data-cy="docker-container-stats-processes-datatable"
/>
);
}
// transform the data from the API into the format expected by the datatable
function parseContainerProcesses(
containerProcesses: ContainerProcesses
): ProcessesDatatableProps {
const { Processes: processes, Titles: titles } = containerProcesses;
const rows = processes?.map((row, index) => {
// docker has the row data as an array of many strings
// podman has the row data as an array with a single string separated by one or many spaces
const processArray = row.length === 1 ? row[0].split(/\s+/) : row;
return {
id: index,
...Object.fromEntries(
titles.map((header, index) => [header, processArray[index]])
),
};
});
const columns = titles
? titles.map(
(header) =>
({ header, accessorKey: header }) satisfies ColumnDef<{
[k: string]: string;
}>
)
: [];
return {
rows,
columns,
};
}

@ -5,6 +5,7 @@ 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 { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
@ -19,9 +20,17 @@ export function NetworkSelector({
onChange: (value: string) => void;
hiddenNetworks?: string[];
}) {
const envId = useEnvironmentId();
const isPodman = useIsPodman(envId);
const networksQuery = useNetworksForSelector({
select(networks) {
return networks.map((n) => ({ label: n.Name, value: n.Name }));
return networks.map((n) => {
// The name of the 'bridge' network is 'podman' in Podman
if (n.Name === 'bridge' && isPodman) {
return { label: 'podman', value: 'podman' };
}
return { label: n.Name, value: n.Name };
});
},
});

@ -18,4 +18,7 @@ export const queryKeys = {
gpus: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.container(environmentId, id), 'gpus'] as const,
top: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.container(environmentId, id), 'top'] as const,
};

@ -7,3 +7,8 @@ export interface Filters {
network?: NetworkId[];
status?: ContainerStatus[];
}
export type ContainerProcesses = {
Processes: Array<Array<string>>;
Titles: Array<string>;
};

@ -1,21 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { ContainerId } from '../types';
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
import { queryKeys } from './query-keys';
import { ContainerProcesses } from './types';
export function useContainerTop<T = ContainerProcesses>(
environmentId: EnvironmentId,
id: ContainerId,
select?: (environment: ContainerProcesses) => T
) {
// many containers don't allow this call, so fail early, and omit withError to silently fail
return useQuery({
queryKey: queryKeys.top(environmentId, id),
queryFn: () => getContainerTop(environmentId, id),
retry: false,
select,
});
}
/**
* Raw docker API proxy
* @param environmentId
* @param id
* @returns
*/
export async function containerTop(
export async function getContainerTop(
environmentId: EnvironmentId,
id: ContainerId
) {
try {
const { data } = await axios.get(
const { data } = await axios.get<ContainerProcesses>(
buildDockerProxyUrl(environmentId, 'containers', id, 'top')
);
return data;

@ -73,11 +73,11 @@ function Cell({
});
return (
<>
<div className="flex gap-1">
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
{truncate(name, 40)}
</a>
{!image.used && <UnusedBadge />}
</>
</div>
);
}

@ -2,6 +2,8 @@ import { CellContext } from '@tanstack/react-table';
import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
import { Badge } from '@@/Badge';
import { columnHelper } from './helper';
export const tags = columnHelper.accessor((item) => item.tags?.join(','), {
@ -16,12 +18,12 @@ function Cell({
const repoTags = item.tags;
return (
<>
<div className="flex flex-wrap gap-1">
{repoTags?.map((tag, idx) => (
<span key={idx} className="label label-primary image-tag" title={tag}>
<Badge key={idx} type="info">
{tag}
</span>
</Badge>
))}
</>
</div>
);
}

@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { fullURIIntoRepoAndTag } from './utils';
describe('fullURIIntoRepoAndTag', () => {
it('splits registry/image-repo:tag correctly', () => {
const result = fullURIIntoRepoAndTag('registry.example.com/my-image:v1.0');
expect(result).toEqual({
repo: 'registry.example.com/my-image',
tag: 'v1.0',
});
});
it('splits image-repo:tag correctly', () => {
const result = fullURIIntoRepoAndTag('nginx:latest');
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
});
it('splits registry:port/image-repo:tag correctly', () => {
const result = fullURIIntoRepoAndTag(
'registry.example.com:5000/my-image:v2.1'
);
expect(result).toEqual({
repo: 'registry.example.com:5000/my-image',
tag: 'v2.1',
});
});
it('handles empty string input', () => {
const result = fullURIIntoRepoAndTag('');
expect(result).toEqual({ repo: '', tag: 'latest' });
});
it('handles input with multiple colons', () => {
const result = fullURIIntoRepoAndTag('registry:5000/namespace/image:v1.0');
expect(result).toEqual({
repo: 'registry:5000/namespace/image',
tag: 'v1.0',
});
});
it('handles input with @ symbol (digest)', () => {
const result = fullURIIntoRepoAndTag(
'myregistry.azurecr.io/image@sha256:123456'
);
expect(result).toEqual({
repo: 'myregistry.azurecr.io/image@sha256',
tag: '123456',
});
});
});

@ -9,6 +9,12 @@ import {
import { DockerImage } from './types';
import { DockerImageResponse } from './types/response';
type ImageModel = {
UseRegistry: boolean;
Registry?: Registry;
Image: string;
};
export function parseViewModel(response: DockerImageResponse): DockerImage {
return {
...response,
@ -40,11 +46,7 @@ export function imageContainsURL(image: string) {
return false;
}
export function buildImageFullURIFromModel(imageModel: {
UseRegistry: boolean;
Registry?: Registry;
Image: string;
}) {
export function buildImageFullURIFromModel(imageModel: ImageModel) {
const registry = imageModel.UseRegistry ? imageModel.Registry : undefined;
return buildImageFullURI(imageModel.Image, registry);
}
@ -107,3 +109,24 @@ function buildImageFullURIWithRegistry(image: string, registry: Registry) {
return url + image;
}
}
/**
* Splits a full URI into repository and tag.
*
* @param fullURI - The full URI to be split.
* @returns An object containing the repository and tag.
*/
export function fullURIIntoRepoAndTag(fullURI: string) {
// possible fullURI values (all should contain a tag):
// - registry/image-repo:tag
// - image-repo:tag
// - registry:port/image-repo:tag
// buildImageFullURIFromModel always gives a tag (defaulting to 'latest'), so the tag is always present after the last ':'
const parts = fullURI.split(':');
const tag = parts.pop() || 'latest';
const repo = parts.join(':');
return {
repo,
tag,
};
}

@ -14,13 +14,14 @@ import { buildDockerProxyUrl } from '../buildDockerProxyUrl';
export async function tagImage(
environmentId: EnvironmentId,
id: ImageId | ImageName,
repo: string
repo: string,
tag?: string
) {
try {
const { data } = await axios.post(
buildDockerProxyUrl(environmentId, 'images', id, 'tag'),
{},
{ params: { repo } }
{ params: { repo, tag } }
);
return data;
} catch (e) {

@ -99,9 +99,18 @@ export function aggregateData(
export function useLoggingPlugins(
environmentId: EnvironmentId,
systemOnly: boolean
systemOnly: boolean,
isPodman?: boolean
) {
return useServicePlugins(environmentId, systemOnly, 'Log');
// systemOnly false + podman false|undefined -> both
// systemOnly true + podman false|undefined -> system
// systemOnly false + podman true -> system
// systemOnly true + podman true -> system
return useServicePlugins(
environmentId,
systemOnly || isPodman === true,
'Log'
);
}
export function useVolumePlugins(

@ -84,7 +84,6 @@ function Cell({
id: item.Id,
nodeName: item.NodeName,
}}
className="monospaced"
data-cy={`volume-link-${name}`}
>
{truncate(name, 40)}
@ -106,7 +105,7 @@ function Cell({
}}
data-cy={`volume-browse-button-${name}`}
>
browse
Browse
</Button>
</Authorized>
)}

@ -57,6 +57,7 @@ export function EdgeScriptSettingsFieldset({
type="text"
value={values.edgeIdGenerator}
name="edgeIdGenerator"
placeholder="e.g. uuidgen"
id="edge-id-generator-input"
onChange={(e) => setFieldValue(e.target.name, e.target.value)}
data-cy="edge-id-generator-input"
@ -81,7 +82,7 @@ export function EdgeScriptSettingsFieldset({
<Field
name="envVars"
as={Input}
placeholder="foo=bar,myvar"
placeholder="e.g. foo=bar"
id="env-variables-input"
/>
</FormControl>

@ -35,6 +35,11 @@ export const commandsTabs: Record<string, CommandTab> = {
label: 'Docker Standalone',
command: buildLinuxStandaloneCommand,
},
podmanLinux: {
id: 'podman',
label: 'Podman',
command: buildLinuxPodmanCommand,
},
swarmWindows: {
id: 'swarm',
label: 'Docker Swarm',
@ -83,6 +88,45 @@ docker run -d \\
`;
}
function buildLinuxPodmanCommand(
agentVersion: string,
edgeKey: string,
properties: ScriptFormValues,
useAsyncMode: boolean,
edgeId?: string,
agentSecret?: string
) {
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(envVars, [
...buildDefaultDockerEnvVars(
edgeKey,
allowSelfSignedCertificates,
!edgeIdGenerator ? edgeId : undefined,
agentSecret,
useAsyncMode
),
...metaEnvVars(properties),
]);
return `${
edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : ''
}\
sudo systemctl enable --now podman.socket
sudo podman volume create portainer
sudo podman run -d \\
-v /run/podman/podman.sock:/var/run/docker.sock \\
-v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \\
-v /:/host \\
-v portainer_agent_data:/data \\
--restart always \\
--privileged \\
${env} \\
--name portainer_edge_agent \\
portainer/agent:${agentVersion}
`;
}
export function buildWindowsStandaloneCommand(
agentVersion: string,
edgeKey: string,

@ -3,7 +3,7 @@ import { EnvironmentGroupId } from '@/react/portainer/environments/environment-g
import { EdgeGroup } from '../../edge-groups/types';
export type Platform = 'standalone' | 'swarm' | 'k8s';
export type Platform = 'standalone' | 'swarm' | 'podman' | 'k8s';
export type OS = 'win' | 'linux';
export interface ScriptFormValues {

@ -26,9 +26,15 @@ function WaitingRoomView() {
<div className="col-sm-12">
<InformationPanel>
<TextTip color="blue">
Only environments generated from the AEEC script will appear here,
manually added environments and edge devices will bypass the
waiting room.
Only environments generated from the{' '}
<Link
to="portainer.endpoints.edgeAutoCreateScript"
data-cy="waitingRoom-edgeAutoCreateScriptLink"
>
auto onboarding
</Link>{' '}
script will appear here, manually added environments and edge
devices will bypass the waiting room.
</TextTip>
</InformationPanel>
</div>

@ -2,6 +2,12 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
/**
* useEnvironmentId is a hook that returns the environmentId from the url params.
* use only when endpointId is set in the path.
* for example: /kubernetes/clusters/:endpointId
* for `:id` paths, use a different hook
*/
export function useEnvironmentId(force = true): EnvironmentId {
const {
params: { endpointId: environmentId },

@ -1,17 +1,25 @@
import { DockerSnapshot } from '@/react/docker/snapshots/types';
import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import {
Environment,
PlatformType,
KubernetesSnapshot,
} from '@/react/portainer/environments/types';
import { getPlatformType } from '@/react/portainer/environments/utils';
import { getDockerEnvironmentType } from '@/react/portainer/environments/utils/getDockerEnvironmentType';
export function EngineVersion({ environment }: { environment: Environment }) {
const platform = getPlatformType(environment.Type);
const isPodman = useIsPodman(environment.Id);
switch (platform) {
case PlatformType.Docker:
return <DockerEngineVersion snapshot={environment.Snapshots[0]} />;
return (
<DockerEngineVersion
snapshot={environment.Snapshots[0]}
isPodman={isPodman}
/>
);
case PlatformType.Kubernetes:
return (
<KubernetesEngineVersion
@ -23,14 +31,21 @@ export function EngineVersion({ environment }: { environment: Environment }) {
}
}
function DockerEngineVersion({ snapshot }: { snapshot?: DockerSnapshot }) {
function DockerEngineVersion({
snapshot,
isPodman,
}: {
snapshot?: DockerSnapshot;
isPodman?: boolean;
}) {
if (!snapshot) {
return null;
}
const type = getDockerEnvironmentType(snapshot.Swarm, isPodman);
return (
<span className="small text-muted vertical-center">
{snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion}
{type} {snapshot.DockerVersion}
</span>
);
}

@ -1,43 +1,86 @@
import { environmentTypeIcon } from '@/portainer/filters/filters';
import dockerEdge from '@/assets/images/edge_endpoint.png';
import { getEnvironmentTypeIcon } from '@/react/portainer/environments/utils';
import dockerEdge from '@/assets/ico/docker-edge-environment.svg';
import podmanEdge from '@/assets/ico/podman-edge-environment.svg';
import kube from '@/assets/images/kubernetes_endpoint.png';
import kubeEdge from '@/assets/images/kubernetes_edge_endpoint.png';
import { EnvironmentType } from '@/react/portainer/environments/types';
import kubeEdge from '@/assets/ico/kubernetes-edge-environment.svg';
import {
ContainerEngine,
EnvironmentType,
} from '@/react/portainer/environments/types';
import azure from '@/assets/ico/vendor/azure.svg';
import docker from '@/assets/ico/vendor/docker.svg';
import podman from '@/assets/ico/vendor/podman.svg';
import { Icon } from '@@/Icon';
interface Props {
type: EnvironmentType;
containerEngine?: ContainerEngine;
}
export function EnvironmentIcon({ type }: Props) {
export function EnvironmentIcon({ type, containerEngine }: Props) {
switch (type) {
case EnvironmentType.AgentOnDocker:
case EnvironmentType.Docker:
if (containerEngine === ContainerEngine.Podman) {
return (
<img
src={podman}
width="60"
alt="podman environment"
aria-hidden="true"
/>
);
}
return (
<img src={docker} width="60" alt="docker endpoint" aria-hidden="true" />
<img
src={docker}
width="60"
alt="docker environment"
aria-hidden="true"
/>
);
case EnvironmentType.Azure:
return (
<img src={azure} width="60" alt="azure endpoint" aria-hidden="true" />
<img
src={azure}
width="60"
alt="azure environment"
aria-hidden="true"
/>
);
case EnvironmentType.EdgeAgentOnDocker:
if (containerEngine === ContainerEngine.Podman) {
return (
<img
src={podmanEdge}
alt="podman edge environment"
aria-hidden="true"
/>
);
}
return (
<img src={dockerEdge} alt="docker edge endpoint" aria-hidden="true" />
<img
src={dockerEdge}
alt="docker edge environment"
aria-hidden="true"
/>
);
case EnvironmentType.KubernetesLocal:
case EnvironmentType.AgentOnKubernetes:
return <img src={kube} alt="kubernetes endpoint" aria-hidden="true" />;
return <img src={kube} alt="kubernetes environment" aria-hidden="true" />;
case EnvironmentType.EdgeAgentOnKubernetes:
return (
<img src={kubeEdge} alt="kubernetes edge endpoint" aria-hidden="true" />
<img
src={kubeEdge}
alt="kubernetes edge environment"
aria-hidden="true"
/>
);
default:
return (
<Icon
icon={environmentTypeIcon(type)}
icon={getEnvironmentTypeIcon(type, containerEngine)}
className="blue-icon !h-16 !w-16"
/>
);

@ -66,7 +66,10 @@ export function EnvironmentItem({
params={dashboardRoute.params}
>
<div className="ml-2 flex justify-center self-center">
<EnvironmentIcon type={environment.Type} />
<EnvironmentIcon
type={environment.Type}
containerEngine={environment.ContainerEngine}
/>
</div>
<div className="ml-3 mr-auto flex flex-col items-start justify-center gap-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">

@ -265,6 +265,12 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
EnvironmentType.AgentOnDocker,
EnvironmentType.EdgeAgentOnDocker,
],
// for podman keep the env type as docker (the containerEngine distinguishes podman from docker)
[PlatformType.Podman]: [
EnvironmentType.Docker,
EnvironmentType.AgentOnDocker,
EnvironmentType.EdgeAgentOnDocker,
],
[PlatformType.Azure]: [EnvironmentType.Azure],
[PlatformType.Kubernetes]: [
EnvironmentType.KubernetesLocal,

@ -171,6 +171,13 @@ function getConnectionTypeOptions(platformTypes: PlatformType[]) {
ConnectionType.EdgeAgentStandard,
ConnectionType.EdgeAgentAsync,
],
[PlatformType.Podman]: [
// api includes a socket connection, so keep this for podman
ConnectionType.API,
ConnectionType.Agent,
ConnectionType.EdgeAgentStandard,
ConnectionType.EdgeAgentAsync,
],
[PlatformType.Azure]: [ConnectionType.API],
[PlatformType.Kubernetes]: [
ConnectionType.Agent,

@ -5,7 +5,7 @@ import { Select } from '@@/form-components/Input';
const typeOptions = [
{ label: 'Swarm', value: StackType.DockerSwarm },
{ label: 'Standalone', value: StackType.DockerCompose },
{ label: 'Standalone / Podman', value: StackType.DockerCompose },
];
export function TemplateTypeSelector({

@ -1,28 +1,41 @@
import { CellContext } from '@tanstack/react-table';
import { environmentTypeIcon } from '@/portainer/filters/filters';
import {
Environment,
EnvironmentType,
} from '@/react/portainer/environments/types';
import { getPlatformTypeName } from '@/react/portainer/environments/utils';
getEnvironmentTypeIcon,
getPlatformTypeName,
} from '@/react/portainer/environments/utils';
import { Icon } from '@@/Icon';
import { EnvironmentListItem } from '../types';
import { EnvironmentType, ContainerEngine } from '../../types';
import { columnHelper } from './helper';
export const type = columnHelper.accessor('Type', {
header: 'Type',
cell: Cell,
});
type TypeCellContext = {
type: EnvironmentType;
containerEngine?: ContainerEngine;
};
export const type = columnHelper.accessor(
(rowItem): TypeCellContext => ({
type: rowItem.Type,
containerEngine: rowItem.ContainerEngine,
}),
{
header: 'Type',
cell: Cell,
id: 'Type',
}
);
function Cell({ getValue }: CellContext<Environment, EnvironmentType>) {
const type = getValue();
function Cell({ getValue }: CellContext<EnvironmentListItem, TypeCellContext>) {
const { type, containerEngine } = getValue();
return (
<span className="flex items-center gap-1">
<Icon icon={environmentTypeIcon(type)} />
{getPlatformTypeName(type)}
<Icon icon={getEnvironmentTypeIcon(type, containerEngine)} />
{getPlatformTypeName(type, containerEngine)}
</span>
);
}

@ -7,7 +7,11 @@ import { type EnvironmentGroupId } from '@/react/portainer/environments/environm
import { type TagId } from '@/portainer/tags/types';
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { type Environment, EnvironmentCreationTypes } from '../types';
import {
type Environment,
ContainerEngine,
EnvironmentCreationTypes,
} from '../types';
import { buildUrl } from './utils';
@ -21,6 +25,7 @@ interface CreateLocalDockerEnvironment {
socketPath?: string;
publicUrl?: string;
meta?: EnvironmentMetadata;
containerEngine?: ContainerEngine;
}
export async function createLocalDockerEnvironment({
@ -28,6 +33,7 @@ export async function createLocalDockerEnvironment({
socketPath = '',
publicUrl = '',
meta = { tagIds: [] },
containerEngine,
}: CreateLocalDockerEnvironment) {
const url = prefixPath(socketPath);
@ -38,6 +44,7 @@ export async function createLocalDockerEnvironment({
url,
publicUrl,
meta,
containerEngine,
}
);
@ -115,6 +122,7 @@ export interface EnvironmentOptions {
pollFrequency?: number;
edge?: EdgeSettings;
tunnelServerAddr?: string;
containerEngine?: ContainerEngine;
}
interface CreateRemoteEnvironment {
@ -125,6 +133,7 @@ interface CreateRemoteEnvironment {
>;
url: string;
options?: Omit<EnvironmentOptions, 'url'>;
containerEngine?: ContainerEngine;
}
export async function createRemoteEnvironment({
@ -143,11 +152,13 @@ export interface CreateAgentEnvironmentValues {
name: string;
environmentUrl: string;
meta: EnvironmentMetadata;
containerEngine?: ContainerEngine;
}
export function createAgentEnvironment({
name,
environmentUrl,
containerEngine = ContainerEngine.Docker,
meta = { tagIds: [] },
}: CreateAgentEnvironmentValues) {
return createRemoteEnvironment({
@ -160,6 +171,7 @@ export function createAgentEnvironment({
skipVerify: true,
skipClientVerify: true,
},
containerEngine,
},
});
}
@ -171,6 +183,7 @@ interface CreateEdgeAgentEnvironment {
meta?: EnvironmentMetadata;
pollFrequency: number;
edge: EdgeSettings;
containerEngine: ContainerEngine;
}
export function createEdgeAgentEnvironment({
@ -179,6 +192,7 @@ export function createEdgeAgentEnvironment({
meta = { tagIds: [] },
pollFrequency,
edge,
containerEngine,
}: CreateEdgeAgentEnvironment) {
return createEnvironment(
name,
@ -192,6 +206,7 @@ export function createEdgeAgentEnvironment({
pollFrequency,
edge,
meta,
containerEngine,
}
);
}
@ -207,7 +222,8 @@ async function createEnvironment(
};
if (options) {
const { groupId, tagIds = [] } = options.meta || {};
const { tls, azure, meta, containerEngine } = options;
const { groupId, tagIds = [] } = meta || {};
payload = {
...payload,
@ -216,10 +232,9 @@ async function createEnvironment(
GroupID: groupId,
TagIds: arrayToJson(tagIds),
EdgeCheckinInterval: options.pollFrequency,
ContainerEngine: containerEngine,
};
const { tls, azure } = options;
if (tls) {
payload = {
...payload,

@ -0,0 +1,15 @@
import { ContainerEngine, EnvironmentId } from '../types';
import { useEnvironment } from './useEnvironment';
/**
* useIsPodman returns true if the current environment is using podman as container engine.
* @returns isPodman boolean, can also be undefined if the environment hasn't loaded yet.
*/
export function useIsPodman(envId: EnvironmentId) {
const { data: isPodman } = useEnvironment(
envId,
(env) => env.ContainerEngine === ContainerEngine.Podman
);
return isPodman;
}

@ -4,6 +4,9 @@ import { DockerSnapshot } from '@/react/docker/snapshots/types';
export type EnvironmentId = number;
/**
* matches portainer.EndpointType in app/portainer.go
*/
export enum EnvironmentType {
// Docker represents an environment(endpoint) connected to a Docker environment(endpoint)
Docker = 1,
@ -124,6 +127,7 @@ export type Environment = {
Agent: { Version: string };
Id: EnvironmentId;
Type: EnvironmentType;
ContainerEngine?: ContainerEngine;
TagIds: TagId[];
GroupId: EnvironmentGroupId;
DeploymentOptions: DeploymentOptions | null;
@ -168,8 +172,14 @@ export enum EnvironmentCreationTypes {
KubeConfigEnvironment,
}
export enum ContainerEngine {
Docker = 'docker',
Podman = 'podman',
}
export enum PlatformType {
Docker,
Kubernetes,
Azure,
Podman,
}

@ -1,8 +1,10 @@
import { getPlatformType } from '@/react/portainer/environments/utils';
import {
ContainerEngine,
EnvironmentType,
PlatformType,
} from '@/react/portainer/environments/types';
import Podman from '@/assets/ico/vendor/podman.svg?c';
import Docker from './docker.svg?c';
import Azure from './azure.svg?c';
@ -12,12 +14,16 @@ const icons: {
[key in PlatformType]: SvgrComponent;
} = {
[PlatformType.Docker]: Docker,
[PlatformType.Podman]: Podman,
[PlatformType.Kubernetes]: Kubernetes,
[PlatformType.Azure]: Azure,
};
export function getPlatformIcon(type: EnvironmentType) {
const platform = getPlatformType(type);
export function getPlatformIcon(
type: EnvironmentType,
containerEngine?: ContainerEngine
) {
const platform = getPlatformType(type, containerEngine);
return icons[platform];
}

@ -0,0 +1,6 @@
export function getDockerEnvironmentType(isSwarm: boolean, isPodman?: boolean) {
if (isPodman) {
return 'Podman';
}
return isSwarm ? 'Swarm' : 'Standalone';
}

@ -1,6 +1,21 @@
import { Environment, EnvironmentType, PlatformType } from '../types';
import { Cloud } from 'lucide-react';
export function getPlatformType(envType: EnvironmentType) {
import Kube from '@/assets/ico/kube.svg?c';
import PodmanIcon from '@/assets/ico/vendor/podman-icon.svg?c';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
import {
Environment,
EnvironmentType,
ContainerEngine,
PlatformType,
} from '../types';
export function getPlatformType(
envType: EnvironmentType,
containerEngine?: ContainerEngine
) {
switch (envType) {
case EnvironmentType.KubernetesLocal:
case EnvironmentType.AgentOnKubernetes:
@ -9,6 +24,9 @@ export function getPlatformType(envType: EnvironmentType) {
case EnvironmentType.Docker:
case EnvironmentType.AgentOnDocker:
case EnvironmentType.EdgeAgentOnDocker:
if (containerEngine === ContainerEngine.Podman) {
return PlatformType.Podman;
}
return PlatformType.Docker;
case EnvironmentType.Azure:
return PlatformType.Azure;
@ -25,8 +43,11 @@ export function isKubernetesEnvironment(envType: EnvironmentType) {
return getPlatformType(envType) === PlatformType.Kubernetes;
}
export function getPlatformTypeName(envType: EnvironmentType): string {
return PlatformType[getPlatformType(envType)];
export function getPlatformTypeName(
envType: EnvironmentType,
containerEngine?: ContainerEngine
): string {
return PlatformType[getPlatformType(envType, containerEngine)];
}
export function isAgentEnvironment(envType: EnvironmentType) {
@ -104,3 +125,27 @@ export function getDashboardRoute(environment: Environment) {
}
}
}
export function getEnvironmentTypeIcon(
type: EnvironmentType,
containerEngine?: ContainerEngine
) {
switch (type) {
case EnvironmentType.Azure:
return MicrosoftIcon;
case EnvironmentType.EdgeAgentOnDocker:
return Cloud;
case EnvironmentType.AgentOnKubernetes:
case EnvironmentType.EdgeAgentOnKubernetes:
case EnvironmentType.KubernetesLocal:
return Kube;
case EnvironmentType.AgentOnDocker:
case EnvironmentType.Docker:
if (containerEngine === ContainerEngine.Podman) {
return PodmanIcon;
}
return DockerIcon;
default:
throw new Error(`type ${type}-${EnvironmentType[type]} is not supported`);
}
}

@ -15,6 +15,7 @@ import {
EnvironmentOptionValue,
existingEnvironmentTypes,
newEnvironmentTypes,
environmentTypes,
} from './environment-types';
export function EnvironmentTypeSelectView() {
@ -65,6 +66,7 @@ export function EnvironmentTypeSelectView() {
disabled={types.length === 0}
data-cy="start-wizard-button"
onClick={() => startWizard()}
className="!ml-0"
>
Start Wizard
</Button>
@ -80,11 +82,6 @@ export function EnvironmentTypeSelectView() {
return;
}
const environmentTypes = [
...existingEnvironmentTypes,
...newEnvironmentTypes,
];
const steps = _.compact(
types.map((id) => environmentTypes.find((eType) => eType.id === id))
);

@ -1,5 +1,6 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import Podman from '@/assets/ico/vendor/podman.svg?c';
import Kubernetes from '@/assets/ico/vendor/kubernetes.svg?c';
import Azure from '@/assets/ico/vendor/azure.svg?c';
import KaaS from '@/assets/ico/vendor/kaas-icon.svg?c';
@ -10,6 +11,7 @@ import { BoxSelectorOption } from '@@/BoxSelector';
export type EnvironmentOptionValue =
| 'dockerStandalone'
| 'dockerSwarm'
| 'podman'
| 'kubernetes'
| 'aci'
| 'kaas'
@ -20,7 +22,6 @@ export interface EnvironmentOption
id: EnvironmentOptionValue;
value: EnvironmentOptionValue;
}
export const existingEnvironmentTypes: EnvironmentOption[] = [
{
id: 'dockerStandalone',
@ -38,6 +39,14 @@ export const existingEnvironmentTypes: EnvironmentOption[] = [
iconType: 'logo',
description: 'Connect to Docker Swarm via URL/IP, API or Socket',
},
{
id: 'podman',
value: 'podman',
label: 'Podman',
icon: Podman,
iconType: 'logo',
description: 'Connect to Podman via URL/IP or Socket',
},
{
id: 'kubernetes',
value: 'kubernetes',
@ -80,7 +89,7 @@ export const newEnvironmentTypes: EnvironmentOption[] = [
},
];
export const environmentTypes = [
export const environmentTypes: EnvironmentOption[] = [
...existingEnvironmentTypes,
...newEnvironmentTypes,
];
@ -88,6 +97,7 @@ export const environmentTypes = [
export const formTitles: Record<EnvironmentOptionValue, string> = {
dockerStandalone: 'Connect to your Docker Standalone environment',
dockerSwarm: 'Connect to your Docker Swarm environment',
podman: 'Connect to your Podman environment',
kubernetes: 'Connect to your Kubernetes environment',
aci: 'Connect to your ACI environment',
kaas: 'Provision a KaaS environment',

@ -1 +1 @@
export { EnvironmentTypeSelectView } from './EndpointTypeView';
export { EnvironmentTypeSelectView } from './EnvironmentTypeSelectView';

@ -7,7 +7,7 @@
.wizard-wrapper {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-columns: 2fr minmax(300px, 1fr);
grid-template-areas:
'main sidebar'
'footer sidebar';

@ -22,6 +22,7 @@ import {
EnvironmentOptionValue,
environmentTypes,
formTitles,
EnvironmentOption,
} from '../EnvironmentTypeSelectView/environment-types';
import { WizardDocker } from './WizardDocker';
@ -30,6 +31,7 @@ import { WizardKubernetes } from './WizardKubernetes';
import { AnalyticsState, AnalyticsStateKey } from './types';
import styles from './EnvironmentsCreationView.module.css';
import { WizardEndpointsList } from './WizardEndpointsList';
import { WizardPodman } from './WizardPodman';
export function EnvironmentCreationView() {
const {
@ -161,7 +163,7 @@ function useParamEnvironmentTypes(): EnvironmentOptionValue[] {
}
function useStepper(
steps: (typeof environmentTypes)[number][],
steps: EnvironmentOption[][number][],
onFinish: () => void
) {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
@ -197,6 +199,8 @@ function useStepper(
case 'dockerStandalone':
case 'dockerSwarm':
return WizardDocker;
case 'podman':
return WizardPodman;
case 'aci':
return WizardAzure;
case 'kubernetes':
@ -211,14 +215,18 @@ function useAnalyticsState() {
const [analytics, setAnalyticsState] = useState<AnalyticsState>({
dockerAgent: 0,
dockerApi: 0,
dockerEdgeAgentAsync: 0,
dockerEdgeAgentStandard: 0,
podmanAgent: 0,
podmanEdgeAgentAsync: 0,
podmanEdgeAgentStandard: 0,
podmanLocalEnvironment: 0,
kubernetesAgent: 0,
kubernetesEdgeAgentAsync: 0,
kubernetesEdgeAgentStandard: 0,
kaasAgent: 0,
aciApi: 0,
localEndpoint: 0,
dockerEdgeAgentAsync: 0,
dockerEdgeAgentStandard: 0,
});
return { analytics, setAnalytics };

@ -4,6 +4,7 @@ import { CopyButton } from '@@/buttons/CopyButton';
import { Code } from '@@/Code';
import { NavTabs } from '@@/NavTabs';
import { NavContainer } from '@@/NavTabs/NavContainer';
import { TextTip } from '@@/Tip/TextTip';
const deployments = [
{
@ -45,10 +46,10 @@ interface DeployCodeProps {
function DeployCode({ code }: DeployCodeProps) {
return (
<>
<span className="text-muted small">
<TextTip color="blue" className="mb-1">
When using the socket, ensure that you have started the Portainer
container with the following Docker flag:
</span>
</TextTip>
<Code>{code}</Code>
<div className="mt-2">

@ -1,4 +1,7 @@
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { AgentForm } from '../../shared/AgentForm/AgentForm';
@ -15,7 +18,10 @@ export function AgentTab({ onCreate, isDockerStandalone }: Props) {
<DeploymentScripts isDockerStandalone={isDockerStandalone} />
<div className="mt-5">
<AgentForm onCreate={onCreate} />
<AgentForm
onCreate={onCreate}
containerEngine={ContainerEngine.Docker}
/>
</div>
</>
);

@ -4,7 +4,10 @@ import { Plug2 } from 'lucide-react';
import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl';
@ -19,9 +22,10 @@ import { FormValues } from './types';
interface Props {
onCreate(environment: Environment): void;
containerEngine: ContainerEngine;
}
export function SocketForm({ onCreate }: Props) {
export function SocketForm({ onCreate, containerEngine }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const initialValues: FormValues = {
name: '',
@ -74,6 +78,7 @@ export function SocketForm({ onCreate }: Props) {
name: values.name,
socketPath: values.overridePath ? values.socketPath : '',
meta: values.meta,
containerEngine,
},
{
onSuccess(environment) {

@ -1,4 +1,7 @@
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { DeploymentScripts } from '../APITab/DeploymentScripts';
@ -14,7 +17,10 @@ export function SocketTab({ onCreate }: Props) {
<DeploymentScripts />
<div className="mt-5">
<SocketForm onCreate={onCreate} />
<SocketForm
onCreate={onCreate}
containerEngine={ContainerEngine.Docker}
/>
</div>
</>
);

@ -2,7 +2,10 @@ import { useState } from 'react';
import { Zap, Network, Plug2 } from 'lucide-react';
import _ from 'lodash';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c';
@ -64,6 +67,8 @@ const options: BoxSelectorOption<
},
]);
const containerEngine = ContainerEngine.Docker;
export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
const [creationType, setCreationType] = useState(options[0].value);
@ -135,6 +140,7 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
? [commandsTabs.standaloneWindow]
: [commandsTabs.swarmWindows],
}}
containerEngine={containerEngine}
/>
);
case 'edgeAgentAsync':
@ -152,6 +158,7 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
? [commandsTabs.standaloneWindow]
: [commandsTabs.swarmWindows],
}}
containerEngine={containerEngine}
/>
);
default:

@ -22,8 +22,6 @@
.wizard-list-image {
grid-area: image;
font-size: 35px;
color: #337ab7;
}
.wizard-list-title {

@ -1,15 +1,13 @@
import { Plug2 } from 'lucide-react';
import clsx from 'clsx';
import { endpointTypeName, stripProtocol } from '@/portainer/filters/filters';
import {
environmentTypeIcon,
endpointTypeName,
stripProtocol,
} from '@/portainer/filters/filters';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
getEnvironmentTypeIcon,
isEdgeEnvironment,
isUnassociatedEdgeEnvironment,
} from '@/react/portainer/environments/utils';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
ENVIRONMENTS_POLLING_INTERVAL,
useEnvironmentList,
@ -51,9 +49,17 @@ export function WizardEndpointsList({ environmentIds }: Props) {
<WidgetBody>
{environments.map((environment) => (
<div className={styles.wizardListWrapper} key={environment.Id}>
<div className={styles.wizardListImage}>
<div
className={clsx(
styles.wizardListImage,
'text-blue-8 th-dark:text-blue-7 th-highcontrast:text-white text-5xl'
)}
>
<Icon
icon={environmentTypeIcon(environment.Type)}
icon={getEnvironmentTypeIcon(
environment.Type,
environment.ContainerEngine
)}
className="mr-1"
/>
</div>

@ -0,0 +1,27 @@
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { AgentForm } from '../../shared/AgentForm/AgentForm';
import { DeploymentScripts } from './DeploymentScripts';
interface Props {
onCreate(environment: Environment): void;
}
export function AgentTab({ onCreate }: Props) {
return (
<>
<DeploymentScripts />
<div className="mt-5">
<AgentForm
onCreate={onCreate}
containerEngine={ContainerEngine.Podman}
/>
</div>
</>
);
}

@ -0,0 +1,86 @@
import { useState } from 'react';
import { useAgentDetails } from '@/react/portainer/environments/queries/useAgentDetails';
import { CopyButton } from '@@/buttons/CopyButton';
import { Code } from '@@/Code';
import { NavTabs } from '@@/NavTabs';
import { NavContainer } from '@@/NavTabs/NavContainer';
const deploymentPodman = [
{
id: 'all',
label: 'Linux (CentOS)',
command: linuxPodmanCommandRootful,
},
];
export function DeploymentScripts() {
const deployments = deploymentPodman;
const [deployType, setDeployType] = useState(deployments[0].id);
const agentDetailsQuery = useAgentDetails();
if (!agentDetailsQuery) {
return null;
}
const { agentVersion, agentSecret } = agentDetailsQuery;
const options = deployments.map((c) => {
const code = c.command(agentVersion, agentSecret);
return {
id: c.id,
label: c.label,
children: <DeployCode code={code} />,
};
});
return (
<NavContainer>
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
</NavContainer>
);
}
interface DeployCodeProps {
code: string;
}
function DeployCode({ code }: DeployCodeProps) {
return (
<>
<div className="code-script">
<Code>{code}</Code>
</div>
<div className="mt-2">
<CopyButton copyText={code} data-cy="copy-deployment-script">
Copy command
</CopyButton>
</div>
</>
);
}
function linuxPodmanCommandRootful(agentVersion: string, agentSecret: string) {
const secret =
agentSecret === '' ? '' : `\\\n -e AGENT_SECRET=${agentSecret} `;
return `sudo systemctl enable --now podman.socket\n
sudo podman volume create portainer\n
sudo podman run -d \\
-p 9001:9001 ${secret}\\
--name portainer_agent \\
--restart=always \\
--privileged \\
-v /run/podman/podman.sock:/var/run/docker.sock \\
-v /var/lib/containers/storage/volumes:/var/lib/docker/volumes \\
-v /:/host \\
portainer/agent:${agentVersion}
`;
}

@ -0,0 +1,68 @@
import { useState } from 'react';
import { CopyButton } from '@@/buttons/CopyButton';
import { Code } from '@@/Code';
import { NavTabs } from '@@/NavTabs';
import { NavContainer } from '@@/NavTabs/NavContainer';
import { TextTip } from '@@/Tip/TextTip';
const deployments = [
{
id: 'linux',
label: 'Linux (CentOS)',
command: `sudo systemctl enable --now podman.socket`,
},
];
export function DeploymentScripts() {
const [deployType, setDeployType] = useState(deployments[0].id);
const options = deployments.map((c) => ({
id: c.id,
label: c.label,
children: <DeployCode code={c.command} />,
}));
return (
<NavContainer>
<NavTabs
options={options}
onSelect={(id: string) => setDeployType(id)}
selectedId={deployType}
/>
</NavContainer>
);
}
interface DeployCodeProps {
code: string;
}
function DeployCode({ code }: DeployCodeProps) {
const bindMountCode = `-v "/run/podman/podman.sock:/var/run/docker.sock"`;
return (
<>
<TextTip color="blue" className="mb-1">
When using the socket, ensure that you have started the Portainer
container with the following Podman flag:
</TextTip>
<Code>{bindMountCode}</Code>
<div className="mt-2 mb-4">
<CopyButton copyText={bindMountCode} data-cy="copy-deployment-command">
Copy command
</CopyButton>
</div>
<TextTip color="blue" className="mb-1">
To use the socket, ensure that you have started the Podman rootful
socket:
</TextTip>
<Code>{code}</Code>
<div className="mt-2">
<CopyButton copyText={code} data-cy="copy-deployment-command">
Copy command
</CopyButton>
</div>
</>
);
}

@ -0,0 +1,125 @@
import { Field, Form, Formik, useFormikContext } from 'formik';
import { useReducer } from 'react';
import { Plug2 } from 'lucide-react';
import { notifySuccess } from '@/portainer/services/notifications';
import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField';
import { NameField } from '../../shared/NameField';
import { MoreSettingsSection } from '../../shared/MoreSettingsSection';
import { useValidation } from './SocketForm.validation';
import { FormValues } from './types';
interface Props {
onCreate(environment: Environment): void;
containerEngine: ContainerEngine;
}
export function SocketForm({ onCreate, containerEngine }: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const initialValues: FormValues = {
name: '',
socketPath: '',
overridePath: false,
meta: { groupId: 1, tagIds: [] },
};
const mutation = useCreateLocalDockerEnvironmentMutation();
const validation = useValidation();
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
key={formKey}
>
{({ isValid, dirty }) => (
<Form>
<NameField />
<OverrideSocketFieldset />
<MoreSettingsSection />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
className="wizard-connect-button vertical-center"
data-cy="docker-socket-connect-button"
loadingText="Connecting environment..."
isLoading={mutation.isLoading}
disabled={!dirty || !isValid}
icon={Plug2}
>
Connect
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
mutation.mutate(
{
name: values.name,
socketPath: values.overridePath ? values.socketPath : '',
meta: values.meta,
containerEngine,
},
{
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment);
},
}
);
}
}
function OverrideSocketFieldset() {
const { values, setFieldValue, errors } = useFormikContext<FormValues>();
return (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
checked={values.overridePath}
data-cy="create-docker-env-socket-override-switch"
onChange={(checked) => setFieldValue('overridePath', checked)}
label="Override default socket path"
labelClass="col-sm-3 col-lg-2"
/>
</div>
</div>
{values.overridePath && (
<FormControl
label="Socket Path"
tooltip="Path to the Podman socket. Remember to bind-mount the socket, see the important notice above for more information."
errors={errors.socketPath}
>
<Field
name="socketPath"
as={Input}
placeholder="e.g. /run/podman/podman.sock (on Linux)"
/>
</FormControl>
)}
</>
);
}

@ -0,0 +1,23 @@
import { boolean, object, SchemaOf, string } from 'yup';
import { metadataValidation } from '../../shared/MetadataFieldset/validation';
import { useNameValidation } from '../../shared/NameField';
import { FormValues } from './types';
export function useValidation(): SchemaOf<FormValues> {
return object({
name: useNameValidation(),
meta: metadataValidation(),
overridePath: boolean().default(false),
socketPath: string()
.default('')
.when('overridePath', (overridePath, schema) =>
overridePath
? schema.required(
'Socket Path is required when override path is enabled'
)
: schema
),
});
}

@ -0,0 +1,33 @@
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { TextTip } from '@@/Tip/TextTip';
import { DeploymentScripts } from './DeploymentScripts';
import { SocketForm } from './SocketForm';
interface Props {
onCreate(environment: Environment): void;
}
export function SocketTab({ onCreate }: Props) {
return (
<>
<TextTip color="orange" className="mb-2" inline={false}>
To connect via socket, Portainer server must be running in a Podman
container.
</TextTip>
<DeploymentScripts />
<div className="mt-5">
<SocketForm
onCreate={onCreate}
containerEngine={ContainerEngine.Podman}
/>
</div>
</>
);
}

@ -0,0 +1,8 @@
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
export interface FormValues {
name: string;
socketPath: string;
overridePath: boolean;
meta: EnvironmentMetadata;
}

@ -0,0 +1,133 @@
import { useState } from 'react';
import { Zap, Plug2 } from 'lucide-react';
import _ from 'lodash';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c';
import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
import { BadgeIcon } from '@@/BadgeIcon';
import { TextTip } from '@@/Tip/TextTip';
import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
import { AgentTab } from './AgentTab';
import { SocketTab } from './SocketTab';
interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
}
const options: BoxSelectorOption<
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync'
>[] = _.compact([
{
id: 'agent',
icon: <BadgeIcon icon={Zap} size="3xl" />,
label: 'Agent',
description: '',
value: 'agent',
},
{
id: 'socket',
icon: <BadgeIcon icon={Plug2} size="3xl" />,
label: 'Socket',
description: '',
value: 'socket',
},
{
id: 'edgeAgentStandard',
icon: <BadgeIcon icon={EdgeAgentStandardIcon} size="3xl" />,
label: 'Edge Agent Standard',
description: '',
value: 'edgeAgentStandard',
},
isBE && {
id: 'edgeAgentAsync',
icon: <BadgeIcon icon={EdgeAgentAsyncIcon} size="3xl" />,
label: 'Edge Agent Async',
description: '',
value: 'edgeAgentAsync',
},
]);
const containerEngine = ContainerEngine.Podman;
export function WizardPodman({ onCreate }: Props) {
const [creationType, setCreationType] = useState(options[0].value);
const tab = getTab(creationType);
return (
<div className="form-horizontal">
<BoxSelector
onChange={(v) => setCreationType(v)}
options={options}
value={creationType}
radioName="creation-type"
/>
<TextTip color="orange" className="mb-2" inline={false}>
Currently, Portainer only supports <b>Podman 5</b> running in rootful
(privileged) mode on <b>CentOS 9</b> Linux environments. Rootless mode
and other Linux distros may work, but aren&apos;t officially supported.
</TextTip>
{tab}
</div>
);
function getTab(
creationType:
| 'agent'
| 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync'
) {
switch (creationType) {
case 'agent':
return (
<AgentTab
onCreate={(environment) => onCreate(environment, 'podmanAgent')}
/>
);
case 'socket':
return (
<SocketTab
onCreate={(environment) =>
onCreate(environment, 'podmanLocalEnvironment')
}
/>
);
case 'edgeAgentStandard':
return (
<EdgeAgentTab
onCreate={(environment) =>
onCreate(environment, 'podmanEdgeAgentStandard')
}
commands={[commandsTabs.podmanLinux]}
containerEngine={containerEngine}
/>
);
case 'edgeAgentAsync':
return (
<EdgeAgentTab
asyncMode
onCreate={(environment) =>
onCreate(environment, 'podmanEdgeAgentAsync')
}
commands={[commandsTabs.podmanLinux]}
containerEngine={containerEngine}
/>
);
default:
return null;
}
}
}

@ -4,7 +4,10 @@ import { Plug2 } from 'lucide-react';
import { useCreateAgentEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { Environment } from '@/react/portainer/environments/types';
import {
ContainerEngine,
Environment,
} from '@/react/portainer/environments/types';
import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create';
import { LoadingButton } from '@@/buttons/LoadingButton';
@ -18,6 +21,7 @@ import { useValidation } from './AgentForm.validation';
interface Props {
onCreate(environment: Environment): void;
envDefaultPort?: string;
containerEngine?: ContainerEngine;
}
const initialValues: CreateAgentEnvironmentValues = {
@ -29,7 +33,11 @@ const initialValues: CreateAgentEnvironmentValues = {
},
};
export function AgentForm({ onCreate, envDefaultPort }: Props) {
export function AgentForm({
onCreate,
envDefaultPort,
containerEngine = ContainerEngine.Docker,
}: Props) {
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
const mutation = useCreateAgentEnvironmentMutation();
@ -70,12 +78,15 @@ export function AgentForm({ onCreate, envDefaultPort }: Props) {
);
function handleSubmit(values: CreateAgentEnvironmentValues) {
mutation.mutate(values, {
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment);
},
});
mutation.mutate(
{ ...values, containerEngine },
{
onSuccess(environment) {
notifySuccess('Environment created', environment.Name);
clearForm();
onCreate(environment);
},
}
);
}
}

@ -1,4 +1,4 @@
import { object, SchemaOf, string } from 'yup';
import { mixed, object, SchemaOf, string } from 'yup';
import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create';
@ -10,6 +10,7 @@ export function useValidation(): SchemaOf<CreateAgentEnvironmentValues> {
name: useNameValidation(),
environmentUrl: environmentValidation(),
meta: metadataValidation(),
containerEngine: mixed().oneOf(['docker', 'podman']),
});
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save