Compare commits

...

12 Commits

Author SHA1 Message Date
Ali
74913e842d chore(version): bump version to 2.38.1 (#1842) 2026-02-11 11:20:29 +13:00
nickl-portainer
420488116b fix(environments): handle unix:// urls [BE-12610] (#1835)
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
2026-02-10 15:21:11 +02:00
Ali
bb3de163a6 feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1810) 2026-02-10 23:40:50 +13:00
Steven Kang
3cef57760b fix(policy): pod security constraints - release 2.38.1 [R8S-808] (#1759) 2026-02-10 08:46:08 +09:00
Josiah Clumont
7d49f61a05 fix(docker): Update the docker binary version that uses 1.25.6 to fix CVE-2025-61726 - for 2.38.1-STS Patch [R8S-818] (#1794) 2026-02-10 11:01:40 +13:00
Josiah Clumont
0b51ad7f01 fix(CVE): Updated Golang to 1.25.7 to resolve CVE-2025-61726 (#1831) 2026-02-10 08:46:23 +13:00
Chaim Lev-Ari
92527f1212 fix(environments): update associated group [BE-12559] (#1801) 2026-02-05 16:33:55 +02:00
nickl-portainer
1c903b35a6 feat(menu) move policies from observability to env settings [R8S-806] (#1778) 2026-02-05 10:09:37 +13:00
RHCowan
8a354ceceb fix(policy) Fetch new status after policy update [R8S-711] (#1775) (#1798) 2026-02-05 09:04:52 +13:00
nickl-portainer
7519e7cb89 fix(react): namespace selects sort alphabetically [R8S-765] (#1785) 2026-02-04 19:05:36 +13:00
RHCowan
a33a72923d feat(policy): Display last attempt timestamp for policy installations [R8S-667] (#1774) (#1782) 2026-02-04 10:40:14 +13:00
Ali
8ddd2ade8b chore(environment-groups): migrate environment groups to react [R8S-771] (#1779) 2026-02-04 08:39:17 +13:00
61 changed files with 4045 additions and 867 deletions

View File

@@ -613,7 +613,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.38.0",
"KubectlShellImage": "portainer/kubectl-shell:2.38.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -942,7 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.38.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.38.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -20,7 +20,9 @@ type endpointGroupUpdatePayload struct {
// Environment(Endpoint) group name
Name string `example:"my-environment-group"`
// Environment(Endpoint) group description
Description string `example:"description"`
Description *string `example:"description"`
// List of environment(endpoint) identifiers that will be part of this group
AssociatedEndpoints []portainer.EndpointID `example:"1,3"`
// List of tag identifiers associated to the environment(endpoint) group
TagIDs []portainer.TagID `example:"3,4"`
UserAccessPolicies portainer.UserAccessPolicies
@@ -80,8 +82,8 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
endpointGroup.Name = payload.Name
}
if payload.Description != "" {
endpointGroup.Description = payload.Description
if payload.Description != nil {
endpointGroup.Description = *payload.Description
}
tagsChanged := false
@@ -161,7 +163,51 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
return nil, httperror.InternalServerError("Unable to persist environment group changes inside the database", err)
}
if tagsChanged {
// Handle associated endpoints updates
endpointsChanged := false
if payload.AssociatedEndpoints != nil {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
// Build a set of the new endpoint IDs for quick lookup
newEndpointSet := make(map[portainer.EndpointID]bool)
for _, id := range payload.AssociatedEndpoints {
newEndpointSet[id] = true
}
for i := range endpoints {
endpoint := &endpoints[i]
wasInGroup := endpoint.GroupID == endpointGroup.ID
shouldBeInGroup := newEndpointSet[endpoint.ID]
if wasInGroup && !shouldBeInGroup {
// Remove from group (move to Unassigned)
endpoint.GroupID = portainer.EndpointGroupID(1)
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, nil); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
} else if !wasInGroup && shouldBeInGroup {
// Add to group
endpoint.GroupID = endpointGroup.ID
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to update environment", err)
}
if err := handler.updateEndpointRelations(tx, endpoint, endpointGroup); err != nil {
return nil, httperror.InternalServerError("Unable to persist environment relations changes inside the database", err)
}
endpointsChanged = true
}
}
}
// Reconcile endpoints in the group if tags changed (but endpoints weren't already reconciled)
if tagsChanged && !endpointsChanged {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve environments from the database", err)

View File

@@ -161,12 +161,6 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
log.Warn().Err(err).Msg("Unable to update user authorizations")
}
}
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
}

View File

@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.38.0
// @version 2.38.1
// @description.markdown api-description.md
// @termsOfService

View File

@@ -2,8 +2,10 @@ package kubernetes
import (
"net/http"
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
@@ -31,33 +33,23 @@ import (
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers"
// @router /kubernetes/{id}/ingresscontrollers [get]
func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid environment identifier route variable")
return httperror.BadRequest("Invalid environment identifier route variable", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to retrieve allowedOnly query parameter")
return httperror.BadRequest("Unable to retrieve allowedOnly query parameter", err)
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Invalid allowedOnly boolean query parameter")
return httperror.BadRequest("Invalid allowedOnly boolean query parameter", err)
}
// Get endpoint from context (may have policies applied in-memory)
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to fetch endpoint")
return httperror.InternalServerError(err.Error(), err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to get privileged kube client")
return httperror.InternalServerError("Unable to get privileged kube client", err)
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to create Kubernetes client")
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
controllers, err := cli.GetIngressControllers()
@@ -72,6 +64,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
}
// Add none controller if "AllowNone" is set for endpoint.
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
@@ -79,37 +72,46 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
Type: "custom",
})
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
if controllers[i].ClassName != "none" {
controllers[i].New = true
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
var updatedClasses []portainer.KubernetesIngressClassConfig
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses = []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
if controllers[i].ClassName != "none" {
controllers[i].New = true
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
@@ -126,6 +128,7 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
}
controllers = allowedControllers
}
return response.JSON(w, controllers)
}
@@ -146,21 +149,16 @@ func (handler *Handler) getAllKubernetesIngressControllers(w http.ResponseWriter
// @failure 500 "Server error occurred while attempting to retrieve ingress controllers by a namespace"
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [get]
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve environment identifier from request")
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace identifier from request")
return httperror.BadRequest("Unable to retrieve namespace identifier from request", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.InternalServerError(err.Error(), err)
}
cli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
@@ -169,12 +167,6 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
return httperror.InternalServerError("Unable to create Kubernetes client", err)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
return httperror.BadRequest("Unable to retrieve namespace from request", err)
}
currentControllers, err := cli.GetIngressControllers()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
@@ -185,7 +177,9 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to retrieve ingress controllers from the Kubernetes")
return httperror.InternalServerError("Unable to retrieve ingress controllers from the Kubernetes", err)
}
// Add none controller if "AllowNone" is set for endpoint.
// Use the policy-applied endpoint for this check since it affects what's shown to the user.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
currentControllers = append(currentControllers, models.K8sIngressController{
Name: "none",
@@ -194,55 +188,66 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
})
}
kubernetesConfig := endpoint.Kubernetes.Configuration
existingClasses := kubernetesConfig.IngressClasses
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
updatedClasses := []portainer.KubernetesIngressClassConfig{}
// Use policy-applied endpoint for ingressAvailabilityPerNamespace since it affects the response.
ingressAvailabilityPerNamespace := endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace
controllers := models.K8sIngressControllers{}
for i := range currentControllers {
globallyblocked := false
currentControllers[i].Availability = true
if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: currentControllers[i].ClassName,
Type: currentControllers[i].Type,
}
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
// Check if the controller is blocked globally or in the current
// namespace.
for _, existingClass := range existingClasses {
if currentControllers[i].ClassName != existingClass.Name {
continue
for i := range currentControllers {
globallyblocked := false
currentControllers[i].Availability = true
if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
}
currentControllers[i].New = false
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
globallyblocked = existingClass.GloballyBlocked
updatedClass := portainer.KubernetesIngressClassConfig{
Name: currentControllers[i].ClassName,
Type: currentControllers[i].Type,
}
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
if ingressAvailabilityPerNamespace {
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
// Check if the controller is blocked globally or in the current
// namespace.
for _, existingClass := range existingClasses {
if currentControllers[i].ClassName != existingClass.Name {
continue
}
currentControllers[i].New = false
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
globallyblocked = existingClass.GloballyBlocked
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
if ingressAvailabilityPerNamespace {
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
}
}
}
}
if !globallyblocked {
controllers = append(controllers, currentControllers[i])
}
updatedClasses = append(updatedClasses, updatedClass)
}
if !globallyblocked {
controllers = append(controllers, currentControllers[i])
}
updatedClasses = append(updatedClasses, updatedClass)
}
// Update the database to match the list of found controllers.
// This includes pruning out controllers which no longer exist.
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
// Update the database to match the list of found controllers.
// This includes pruning out controllers which no longer exist.
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesIngressControllersByNamespace").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
@@ -268,21 +273,10 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
// @failure 500 "Server error occurred while attempting to update ingress controllers."
// @router /kubernetes/{id}/ingresscontrollers [put]
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment identifier from request")
return httperror.BadRequest("Unable to retrieve environment identifier from request", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to find an environment with the specified identifier inside the database")
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to retrieve environment")
return httperror.BadRequest("Unable to retrieve environment", err)
}
payload := models.K8sIngressControllers{}
@@ -298,7 +292,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
return httperror.InternalServerError("Unable to get privileged kube client", err)
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
controllers, err := cli.GetIngressControllers()
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
@@ -316,6 +309,7 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
}
// Add none controller if "AllowNone" is set for endpoint.
// Use policy-applied endpoint for this check since it affects the response.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
@@ -324,48 +318,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
})
}
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
controllers[i].Availability = true
controllers[i].New = true
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
for _, p := range payload {
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for i := range controllers {
// Now set new payload data
if updatedClasses[i].Name == p.ClassName {
updatedClasses[i].GloballyBlocked = !p.Availability
controllers[i].Availability = true
controllers[i].New = true
updatedClass := portainer.KubernetesIngressClassConfig{
Name: controllers[i].ClassName,
Type: controllers[i].Type,
}
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
for _, p := range payload {
for i := range controllers {
// Now set new payload data
if updatedClasses[i].Name == p.ClassName {
updatedClasses[i].GloballyBlocked = !p.Availability
}
}
}
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
)
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllers").Msg("Unable to store found IngressClasses inside the database")
return httperror.InternalServerError("Unable to store found IngressClasses inside the database", err)
}
return response.Empty(w)
}
@@ -388,12 +389,6 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
// @failure 500 "Server error occurred while attempting to update ingress controllers by namespace."
// @router /kubernetes/{id}/namespaces/{namespace}/ingresscontrollers [put]
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.NotFound("Unable to fetch endpoint", err)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to retrieve namespace from request")
@@ -407,75 +402,88 @@ func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.Res
return httperror.BadRequest("Unable to decode and validate the request payload", err)
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
PayloadLoop:
for _, p := range payload {
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
continue
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: existingClass.Name,
Type: existingClass.Type,
GloballyBlocked: existingClass.GloballyBlocked,
}
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Msg("Unable to fetch endpoint")
return httperror.InternalServerError("Unable to fetch endpoint", err)
}
// Handle "allow"
if p.Availability {
// remove the namespace from the list of blocked namespaces
// in the existingClass.
for _, blockedNS := range existingClass.BlockedNamespaces {
if blockedNS != namespace {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
// Fetch raw endpoint and update IngressClasses within a transaction.
// This prevents policy-applied values from being persisted to the database.
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
rawEndpoint, err := tx.Endpoint().Endpoint(endpoint.ID)
if err != nil {
return err
}
// Use raw endpoint's IngressClasses for building updatedClasses to persist original DB values.
existingClasses := rawEndpoint.Kubernetes.Configuration.IngressClasses
updatedClasses := []portainer.KubernetesIngressClassConfig{}
for _, p := range payload {
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
continue
}
updatedClass := portainer.KubernetesIngressClassConfig{
Name: existingClass.Name,
Type: existingClass.Type,
GloballyBlocked: existingClass.GloballyBlocked,
}
// Handle "allow"
if p.Availability {
// remove the namespace from the list of blocked namespaces
// in the existingClass.
for _, blockedNS := range existingClass.BlockedNamespaces {
if blockedNS != namespace {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
}
}
}
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
}
// Handle "disallow"
// If it's meant to be blocked we need to add the current
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
for _, ns := range updatedClass.BlockedNamespaces {
if namespace == ns {
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
break
}
// Handle "disallow"
// If it's meant to be blocked we need to add the current
// namespace. First, check if it's already in the
// BlockedNamespaces and if not we append it.
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
if !slices.Contains(updatedClass.BlockedNamespaces, namespace) {
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
}
updatedClasses = append(updatedClasses, updatedClass)
break
}
}
// At this point it's possible we had an existing class which was globally
// blocked and thus not included in the payload. As a result it is not yet
// part of updatedClasses, but we MUST include it or we would remove the
// global block.
for _, existingClass := range existingClasses {
found := false
for _, updatedClass := range updatedClasses {
if existingClass.Name == updatedClass.Name {
found = true
break
}
}
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, namespace)
updatedClasses = append(updatedClasses, updatedClass)
}
}
// At this point it's possible we had an existing class which was globally
// blocked and thus not included in the payload. As a result it is not yet
// part of updatedClasses, but we MUST include it or we would remove the
// global block.
for _, existingClass := range existingClasses {
found := false
for _, updatedClass := range updatedClasses {
if existingClass.Name == updatedClass.Name {
found = true
if !found {
updatedClasses = append(updatedClasses, existingClass)
}
}
if !found {
updatedClasses = append(updatedClasses, existingClass)
}
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
rawEndpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
return tx.Endpoint().UpdateEndpoint(rawEndpoint.ID, rawEndpoint)
})
if err != nil {
log.Error().Err(err).Str("context", "updateKubernetesIngressControllersByNamespace").Str("namespace", namespace).Msg("Unable to store BlockedIngressClasses inside the database")
return httperror.InternalServerError("Unable to store BlockedIngressClasses inside the database", err)
}
return response.Empty(w)
}

View File

@@ -496,7 +496,7 @@ func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, te
}
}
return service.UpdateUsersAuthorizationsTx(tx)
return nil
}
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
@@ -569,198 +569,14 @@ func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, us
return nil
}
// UpdateUserAuthorizations will update the authorizations for the provided userid
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
err := service.updateUserAuthorizations(tx, userID)
if err != nil {
return err
}
return nil
}
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
// UpdateUsersAuthorizations is a no-op kept for backward compatibility with database migrations.
//
// Deprecated: This function previously populated the User.EndpointAuthorizations field which is
// no longer used. Authorization is now computed dynamically via ResolveUserEndpointAccess.
func (service *Service) UpdateUsersAuthorizations() error {
return service.UpdateUsersAuthorizationsTx(service.dataStore)
}
func (service *Service) UpdateUsersAuthorizationsTx(tx dataservices.DataStoreTx) error {
users, err := tx.User().ReadAll()
if err != nil {
return err
}
for _, user := range users {
err := service.updateUserAuthorizations(tx, user.ID)
if err != nil {
return err
}
}
return nil
}
func (service *Service) updateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
user, err := tx.User().Read(userID)
if err != nil {
return err
}
endpointAuthorizations, err := service.getAuthorizations(tx, user)
if err != nil {
return err
}
user.EndpointAuthorizations = endpointAuthorizations
return tx.User().Update(userID, user)
}
func (service *Service) getAuthorizations(tx dataservices.DataStoreTx, user *portainer.User) (portainer.EndpointAuthorizations, error) {
endpointAuthorizations := portainer.EndpointAuthorizations{}
if user.Role == portainer.AdministratorRole {
return endpointAuthorizations, nil
}
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return endpointAuthorizations, err
}
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return endpointAuthorizations, err
}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return endpointAuthorizations, err
}
roles, err := tx.Role().ReadAll()
if err != nil {
return endpointAuthorizations, err
}
endpointAuthorizations = getUserEndpointAuthorizations(user, endpoints, endpointGroups, roles, userMemberships)
return endpointAuthorizations, nil
}
func getUserEndpointAuthorizations(user *portainer.User, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, roles []portainer.Role, userMemberships []portainer.TeamMembership) portainer.EndpointAuthorizations {
endpointAuthorizations := make(portainer.EndpointAuthorizations)
groupUserAccessPolicies := map[portainer.EndpointGroupID]portainer.UserAccessPolicies{}
groupTeamAccessPolicies := map[portainer.EndpointGroupID]portainer.TeamAccessPolicies{}
for _, endpointGroup := range endpointGroups {
groupUserAccessPolicies[endpointGroup.ID] = endpointGroup.UserAccessPolicies
groupTeamAccessPolicies[endpointGroup.ID] = endpointGroup.TeamAccessPolicies
}
for _, endpoint := range endpoints {
authorizations := getAuthorizationsFromUserEndpointPolicy(user, &endpoint, roles)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
continue
}
authorizations = getAuthorizationsFromUserEndpointGroupPolicy(user, &endpoint, roles, groupUserAccessPolicies)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
continue
}
authorizations = getAuthorizationsFromTeamEndpointPolicies(userMemberships, &endpoint, roles)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
continue
}
authorizations = getAuthorizationsFromTeamEndpointGroupPolicies(userMemberships, &endpoint, roles, groupTeamAccessPolicies)
if len(authorizations) > 0 {
endpointAuthorizations[endpoint.ID] = authorizations
}
}
return endpointAuthorizations
}
func getAuthorizationsFromUserEndpointPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
policy, ok := endpoint.UserAccessPolicies[user.ID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromUserEndpointGroupPolicy(user *portainer.User, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.UserAccessPolicies) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
policy, ok := groupAccessPolicies[endpoint.GroupID][user.ID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromTeamEndpointPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
for _, membership := range memberships {
policy, ok := endpoint.TeamAccessPolicies[membership.TeamID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromTeamEndpointGroupPolicies(memberships []portainer.TeamMembership, endpoint *portainer.Endpoint, roles []portainer.Role, groupAccessPolicies map[portainer.EndpointGroupID]portainer.TeamAccessPolicies) portainer.Authorizations {
policyRoles := make([]portainer.RoleID, 0)
for _, membership := range memberships {
policy, ok := groupAccessPolicies[endpoint.GroupID][membership.TeamID]
if ok {
policyRoles = append(policyRoles, policy.RoleID)
}
}
return getAuthorizationsFromRoles(policyRoles, roles)
}
func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []portainer.Role) portainer.Authorizations {
var associatedRoles []portainer.Role
for _, id := range roleIdentifiers {
for _, role := range roles {
if role.ID == id {
associatedRoles = append(associatedRoles, role)
break
}
}
}
var authorizations portainer.Authorizations
highestPriority := 0
for _, role := range associatedRoles {
if role.Priority > highestPriority {
highestPriority = role.Priority
authorizations = role.Authorizations
}
}
return authorizations
}
func (service *Service) UserIsAdminOrAuthorized(tx dataservices.DataStoreTx, userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
user, err := tx.User().Read(userID)
if err != nil {

View File

@@ -549,6 +549,8 @@ type (
Status HelmInstallStatus `json:"status"`
Message string `json:"message"`
Namespace string `json:"namespace"`
// Unix timestamp
LastAttemptTime int64 `json:"lastAttemptTime"`
}
ImageBundle struct {
@@ -584,7 +586,7 @@ type (
// RestoreSettings contains instructions for restoring environment-level settings
RestoreSettings struct {
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
Manifest string `json:"manifest,omitempty"` // Base64-encoded Kubernetes YAML manifest
}
// RestoreSettingsBundle maps restore type to restoration instructions
@@ -1855,7 +1857,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.38.0"
APIVersion = "2.38.1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called

View File

@@ -256,8 +256,12 @@ angular
url: '/:id',
views: {
'content@': {
templateUrl: './views/groups/edit/group.html',
controller: 'GroupController',
component: 'environmentGroupEditView',
},
},
params: {
id: {
type: 'int',
},
},
};
@@ -267,8 +271,7 @@ angular
url: '/new',
views: {
'content@': {
templateUrl: './views/groups/create/creategroup.html',
controller: 'CreateGroupController',
component: 'environmentGroupCreateView',
},
},
};

View File

@@ -1,17 +0,0 @@
import angular from 'angular';
import GroupFormController from './groupFormController';
angular.module('portainer.app').component('groupForm', {
templateUrl: './groupForm.html',
controller: GroupFormController,
bindings: {
loaded: '<',
model: '=',
associatedEndpoints: '=',
formAction: '<',
formActionLabel: '@',
actionInProgress: '<',
onChangeEnvironments: '<',
},
});

View File

@@ -6,8 +6,6 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser';
import { withFormValidation } from '@/react-tools/withFormValidation';
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
import { withControlledInput } from '@/react-tools/withControlledInput';
import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
@@ -271,20 +269,7 @@ export const ngModule = angular
'inlineLoader',
r2a(InlineLoader, ['children', 'className', 'size'])
)
.component(
'groupAssociationTable',
r2a(withReactQuery(GroupAssociationTable), [
'onClickRow',
'query',
'title',
'data-cy',
])
)
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
.component(
'associatedEndpointsSelector',
r2a(withReactQuery(AssociatedEnvironmentsSelector), ['onChange', 'value'])
);
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []));
export const componentsModule = ngModule.name;

View File

@@ -5,10 +5,20 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/portainer/environments/environment-groups/ListView';
import { EditGroupView } from '@/react/portainer/environments/environment-groups/ItemView/EditGroupView';
import { CreateGroupView } from '@/react/portainer/environments/environment-groups/CreateView/CreateGroupView';
export const environmentGroupModule = angular
.module('portainer.app.react.views.environment-groups', [])
.component(
'environmentGroupsListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
)
.component(
'environmentGroupEditView',
r2a(withUIRouter(withReactQuery(withCurrentUser(EditGroupView))), [])
)
.component(
'environmentGroupCreateView',
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateGroupView))), [])
).name;

View File

@@ -292,7 +292,9 @@ function EndpointController(
$scope.endpointType = 'remote';
}
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
if ($scope.state.azureEndpoint || $scope.state.edgeEndpoint) {
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
}
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.state.edgeAssociated = !!endpoint.EdgeID;

View File

@@ -1,40 +0,0 @@
import { EndpointGroupDefaultModel } from '../../../models/group';
angular.module('portainer.app').controller('CreateGroupController', function CreateGroupController($async, $scope, $state, GroupService, Notifications) {
$scope.state = {
actionInProgress: false,
};
$scope.onChangeEnvironments = onChangeEnvironments;
$scope.create = function () {
var model = $scope.model;
$scope.state.actionInProgress = true;
GroupService.createGroup(model, $scope.associatedEndpoints)
.then(function success() {
Notifications.success('Success', 'Group successfully created');
$state.go('portainer.groups', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create group');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function initView() {
$scope.associatedEndpoints = [];
$scope.model = new EndpointGroupDefaultModel();
$scope.loaded = true;
}
function onChangeEnvironments(value) {
return $scope.$evalAsync(() => {
$scope.associatedEndpoints = value;
});
}
initView();
});

View File

@@ -1,19 +0,0 @@
<page-header title="'Create environment group'" breadcrumbs="[{label:'Environment groups', link:'portainer.groups'}, 'Add group']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
loaded="loaded"
model="model"
associated-endpoints="associatedEndpoints"
form-action="create"
form-action-label="Create the group"
action-in-progress="state.actionInProgress"
on-change-environments="(onChangeEnvironments)"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -1,19 +0,0 @@
<page-header title="'Environment group details'" breadcrumbs="[{label:'Groups', link:'portainer.groups'}, group.Name]" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
loaded="loaded"
model="group"
associated-endpoints="associatedEndpoints"
form-action="update"
form-action-label="Update the group"
action-in-progress="state.actionInProgress"
on-change-environments="(onChangeEnvironments)"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -1,83 +0,0 @@
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
angular.module('portainer.app').controller('GroupController', function GroupController($async, $q, $scope, $state, $transition$, GroupService, Notifications) {
$scope.state = {
actionInProgress: false,
};
$scope.onChangeEnvironments = onChangeEnvironments;
$scope.associatedEndpoints = [];
$scope.update = function () {
var model = $scope.group;
$scope.state.actionInProgress = true;
GroupService.updateGroup(model)
.then(function success() {
Notifications.success('Success', 'Group successfully updated');
$state.go('portainer.groups', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update group');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function onChangeEnvironments(value, meta) {
return $async(async () => {
let success = false;
if (meta.type === 'add') {
success = await onAssociate(meta.value);
} else if (meta.type === 'remove') {
success = await onDisassociate(meta.value);
}
if (success) {
$scope.associatedEndpoints = value;
}
});
}
async function onAssociate(endpointId) {
try {
await GroupService.addEndpoint($scope.group.Id, endpointId);
notifySuccess('Success', `Environment successfully added to group`);
return true;
} catch (err) {
notifyError('Failure', err, `Unable to add environment to group`);
}
}
async function onDisassociate(endpointId) {
try {
await GroupService.removeEndpoint($scope.group.Id, endpointId);
notifySuccess('Success', `Environment successfully removed to group`);
return true;
} catch (err) {
notifyError('Failure', err, `Unable to remove environment to group`);
}
}
function initView() {
var groupId = $transition$.params().id;
$q.all({
group: GroupService.group(groupId),
endpoints: getEnvironments({ query: { groupIds: [groupId] } }),
})
.then(function success(data) {
$scope.group = data.group;
$scope.associatedEndpoints = data.endpoints.value.map((endpoint) => endpoint.Id);
$scope.loaded = true;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load group details');
});
}
initView();
});

View File

@@ -37,10 +37,8 @@ function loadingButtonIcon(isLoading: boolean, defaultIcon: ReactNode) {
return defaultIcon;
}
return (
<Icon
icon={Loader2}
className="ml-1 animate-spin-slow"
aria-label="loading"
/>
<span className="flex items-center" role="status" aria-label="loading">
<Icon icon={Loader2} className="ml-1 animate-spin-slow" />
</span>
);
}

View File

@@ -44,6 +44,15 @@ beforeEach(() => {
);
});
beforeAll(() => {
// set timezone explicitly to avoid daylight savings drift
vi.stubEnv('TZ', 'UTC');
});
afterAll(() => {
vi.unstubAllEnvs();
});
it('should return null when data is loading', () => {
server.use(
http.get('/api/endpoints/:environmentId/docker/configs', async () => {

View File

@@ -11,11 +11,13 @@ function transformNamespaces(
namespaces: PortainerNamespace[],
showSystem?: boolean
) {
const transformedNamespaces = namespaces.map(({ Name, IsSystem }) => ({
label: IsSystem ? `${Name} - system` : Name,
value: Name,
isSystem: IsSystem,
}));
const transformedNamespaces = namespaces
.map(({ Name, IsSystem }) => ({
label: IsSystem ? `${Name} - system` : Name,
value: Name,
isSystem: IsSystem,
}))
.sort((a, b) => a.label.localeCompare(b.label));
if (showSystem === undefined) {
return transformedNamespaces;
}

View File

@@ -0,0 +1,36 @@
import { vi } from 'vitest';
import { filterNamespaces } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
const mockUseNamespacesQuery = vi.fn(() => ({
data: { 1: { Name: 'gamma' }, 2: { Name: 'alpha' }, 3: { Name: 'beta' } },
isLoading: false,
}));
const mockUseEnvironmentId = vi.fn(() => 1);
vi.mock('@/react/kubernetes/namespaces/queries/useNamespacesQuery', () => ({
useNamespacesQuery: () => mockUseNamespacesQuery(),
}));
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
describe('CustomResourceDetailsView', () => {
it('renders the correct summary widget details for a custom resource', async () => {
const mockNamespaces = [
{ Name: 'gamma', IsSystem: false },
{ Name: 'alpha', IsSystem: false },
{ Name: 'beta', IsSystem: false },
];
const namespaceSelect = filterNamespaces(mockNamespaces);
expect(namespaceSelect).toStrictEqual([
{ label: 'alpha', value: 'alpha' },
{ label: 'beta', value: 'beta' },
{ label: 'gamma', value: 'gamma' },
]);
});
});

View File

@@ -6,6 +6,18 @@ import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNam
import { FormControl } from '@@/form-components/FormControl';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
export function filterNamespaces(
namespaces: { IsSystem: boolean; Name: string }[] | undefined
) {
return Object.values(namespaces ?? {})
.filter((ns) => !ns.IsSystem)
.map((ns) => ({
label: ns.Name,
value: ns.Name,
}))
.sort((a, b) => a.label.localeCompare(b.label));
}
type Props = {
onChange: (value: string) => void;
values: string;
@@ -22,12 +34,7 @@ export function NamespaceSelector({
const environmentId = useEnvironmentId();
const { data: namespaces, ...namespacesQuery } =
useNamespacesQuery(environmentId);
const namespaceNames = Object.entries(namespaces ?? {})
.filter(([, ns]) => !ns.IsSystem)
.map(([, ns]) => ({
label: ns.Name,
value: ns.Name,
}));
const namespaceNames = filterNamespaces(namespaces);
return (
<FormControl

View File

@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DefaultBodyType, http, HttpResponse } from 'msw';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, test } from 'vitest';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
@@ -201,6 +201,214 @@ describe('GeneralEnvironmentForm', () => {
expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
});
});
describe('URL handling', () => {
test.each([
{
description: 'unix:// socket URL',
inputUrl: 'unix:///var/run/docker.sock',
expectedDisplay: 'unix:///var/run/docker.sock',
environmentType: EnvironmentType.Docker,
},
{
description: 'tcp:// Docker URL',
inputUrl: 'tcp://10.0.0.1:2375',
expectedDisplay: '10.0.0.1:2375',
environmentType: EnvironmentType.Docker,
},
{
description: 'https:// URL',
inputUrl: 'https://docker.example.com:2376',
expectedDisplay: 'docker.example.com:2376',
environmentType: EnvironmentType.Docker,
},
{
description: 'http:// URL',
inputUrl: 'http://docker.example.com:2375',
expectedDisplay: 'docker.example.com:2375',
environmentType: EnvironmentType.Docker,
},
{
description: 'URL without protocol',
inputUrl: '192.168.1.100:2375',
expectedDisplay: '192.168.1.100:2375',
environmentType: EnvironmentType.Docker,
},
{
description: 'Kubernetes local URL',
inputUrl: 'https://k8s.example.com:6443',
expectedDisplay: 'k8s.example.com:6443',
environmentType: EnvironmentType.KubernetesLocal,
},
])(
'should handle $description correctly on load',
async ({ inputUrl, expectedDisplay, environmentType }) => {
const env = createMockEnvironment({
URL: inputUrl,
Type: environmentType,
});
renderComponent(env);
await waitFor(() => {
const urlInput = screen.getByLabelText('Environment URL');
expect(urlInput).toHaveValue(expectedDisplay);
});
}
);
});
describe('TLS visibility', () => {
test.each([
{
description: 'Docker tcp:// API',
url: 'tcp://10.0.0.1:2375',
environmentType: EnvironmentType.Docker,
shouldShowTLS: true,
},
{
description: 'Docker tcp:// API with TLS port',
url: 'tcp://docker.example.com:2376',
environmentType: EnvironmentType.Docker,
shouldShowTLS: true,
},
{
description: 'Docker unix:// socket',
url: 'unix:///var/run/docker.sock',
environmentType: EnvironmentType.Docker,
shouldShowTLS: false,
},
{
description: 'Agent on Docker',
url: 'tcp://agent:9001',
environmentType: EnvironmentType.AgentOnDocker,
shouldShowTLS: false,
},
{
description: 'Agent on Kubernetes',
url: 'agent-k8s:9001',
environmentType: EnvironmentType.AgentOnKubernetes,
shouldShowTLS: false,
},
{
description: 'Kubernetes Local',
url: 'https://k8s.local:6443',
environmentType: EnvironmentType.KubernetesLocal,
shouldShowTLS: false,
},
{
description: 'Edge Agent on Docker',
url: 'edge-agent:8000',
environmentType: EnvironmentType.EdgeAgentOnDocker,
shouldShowTLS: false,
},
])(
'should $description - TLS visible: $shouldShowTLS',
async ({ url, environmentType, shouldShowTLS }) => {
const env = createMockEnvironment({
Type: environmentType,
URL: url,
});
renderComponent(env);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
});
if (shouldShowTLS) {
expect(screen.getByText(/TLS/i)).toBeVisible();
} else {
expect(screen.queryByText(/TLS/i)).not.toBeInTheDocument();
}
}
);
});
describe('Form submission', () => {
test.each([
{
description: 'Docker tcp:// URL',
environmentType: EnvironmentType.Docker,
inputUrl: '10.0.0.1:2375',
expectedPayloadUrl: 'tcp://10.0.0.1:2375',
},
{
description: 'Docker plain URL becomes tcp://',
environmentType: EnvironmentType.Docker,
inputUrl: 'docker.example.com:2376',
expectedPayloadUrl: 'tcp://docker.example.com:2376',
},
{
description: 'Kubernetes Local adds https://',
environmentType: EnvironmentType.KubernetesLocal,
inputUrl: 'k8s.local:6443',
expectedPayloadUrl: 'https://k8s.local:6443',
},
])(
'should submit $description correctly',
async ({ environmentType, inputUrl, expectedPayloadUrl }) => {
let requestPayload: DefaultBodyType;
server.use(
http.put('/api/endpoints/:id', async ({ request }) => {
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
requestPayload = await request.json();
return HttpResponse.json({});
})
);
const env = createMockEnvironment({
Id: 1,
Name: 'test-env',
Type: environmentType,
});
const onSuccess = vi.fn();
renderComponent(env, { onSuccess });
const nameInput = screen.getByRole('textbox', { name: /Name/ });
await waitFor(() => {
expect(nameInput).toBeVisible();
});
// Fill form fields
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'my-environment');
const urlInput = screen.getByLabelText('Environment URL');
await userEvent.clear(urlInput);
await userEvent.type(urlInput, inputUrl);
// Wait for debounce to complete (NameField uses useDebounce)
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
const submitButton = screen.getByRole('button', {
name: /update environment/i,
});
// Wait for Formik to process all changes and enable submit button
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await userEvent.click(submitButton);
expect(await screen.findByText(/updating environment/i)).toBeVisible();
// Verify payload
await waitFor(() => {
expect(requestPayload).toMatchObject({
Name: 'my-environment',
URL: expectedPayloadUrl,
});
});
expect(onSuccess).toHaveBeenCalled();
}
);
});
});
function renderComponent(

View File

@@ -1,4 +1,4 @@
import { Formik, Form } from 'formik';
import { Formik, Form, FormikErrors } from 'formik';
import { useUpdateEnvironmentMutation } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
import { NameField } from '@/react/portainer/environments/common/NameField/NameField';
@@ -8,8 +8,7 @@ import { TLSFieldset } from '@/react/components/TLSFieldset';
import { MetadataFieldset } from '@/react/portainer/environments/common/MetadataFieldset';
import {
isAgentEnvironment,
isDockerAPIEnvironment,
isLocalEnvironment,
isLocalDockerEnvironment,
} from '@/react/portainer/environments/utils';
import {
Environment,
@@ -19,6 +18,7 @@ import {
import { FormSection } from '@@/form-components/FormSection';
import { Widget } from '@@/Widget/Widget';
import { WidgetBody } from '@@/Widget';
import { TLSConfig } from '@@/TLSFieldset/types';
import { EnvironmentFormActions } from '../EnvironmentFormActions/EnvironmentFormActions';
@@ -33,9 +33,8 @@ interface Props {
export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
const updateMutation = useUpdateEnvironmentMutation();
const isDockerAPI = isDockerAPIEnvironment(environment);
const isAgent = isAgentEnvironment(environment.Type);
const isLocal = isLocalEnvironment(environment);
const isLocalDocker = isLocalDockerEnvironment(environment.URL);
const hasError = environment.Status === EnvironmentStatus.Error;
const validationSchema = useGeneralValidation({
status: environment.Status,
@@ -49,7 +48,10 @@ export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
initialValues={buildInitialValues(environment)}
validationSchema={validationSchema}
onSubmit={(values) => {
const payload = buildUpdatePayload(values, environment.Type);
const payload = buildUpdatePayload({
values,
environmentType: environment.Type,
});
updateMutation.mutate(
{ id: environment.Id, payload },
{ onSuccess }
@@ -57,28 +59,31 @@ export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
}}
validateOnMount
>
{(formik) => (
{({ values, setFieldValue, errors, isValid, dirty }) => (
<Form className="form-horizontal">
<FormSection title="Configuration">
<NameField />
{!hasError && (
<>
<EnvironmentUrlField isAgent={isAgent} disabled={isLocal} />
<EnvironmentUrlField
isAgent={isAgent}
disabled={isLocalDocker}
/>
<PublicUrlField />
</>
)}
{!hasError && isDockerAPI && (
{!hasError && values.tls && (
<TLSFieldset
values={formik.values.tls}
values={values.tls}
onChange={(partialValues) => {
formik.setFieldValue('tls', {
...formik.values.tls,
setFieldValue('tls', {
...values.tls,
...partialValues,
});
}}
errors={formik.errors.tls}
errors={errors.tls as FormikErrors<TLSConfig> | undefined}
/>
)}
</FormSection>
@@ -87,8 +92,8 @@ export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
<EnvironmentFormActions
isLoading={updateMutation.isLoading}
isValid={formik.isValid}
isDirty={formik.dirty}
isValid={isValid}
isDirty={dirty}
/>
</Form>
)}

View File

@@ -1,52 +1,210 @@
import { describe, it, expect } from 'vitest';
import { describe, test, expect } from 'vitest';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import { formatURL } from './helpers';
import { formatURL, buildInitialValues, buildUpdatePayload } from './helpers';
describe('helpers', () => {
describe('formatURL', () => {
it('should add tcp:// prefix for Docker environments', () => {
const result = formatURL('10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
it('should add tcp:// prefix for Docker standalone', () => {
const result = formatURL('10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
it('should add tcp:// prefix for Agent environment', () => {
const result = formatURL('agent-host', EnvironmentType.AgentOnDocker);
expect(result).toBe('tcp://agent-host');
});
it('should add https:// prefix for Kubernetes Local', () => {
const result = formatURL(
'k8s.example.com',
EnvironmentType.KubernetesLocal
);
expect(result).toBe('https://k8s.example.com');
});
it('should not add prefix for Agent on Kubernetes', () => {
const result = formatURL('k8s-agent', EnvironmentType.AgentOnKubernetes);
expect(result).toBe('k8s-agent');
});
it('should strip existing protocol before formatting', () => {
const result = formatURL('tcp://10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
it('should handle empty URL', () => {
const result = formatURL('', EnvironmentType.Docker);
expect(result).toBe('');
});
it('should strip https:// and add tcp:// for Docker', () => {
const result = formatURL('https://10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
describe('formatURL', () => {
test.each([
{
description: 'Docker environment with plain URL',
url: '10.0.0.1:2375',
environmentType: EnvironmentType.Docker,
expected: 'tcp://10.0.0.1:2375',
},
{
description: 'Docker environment with tcp:// protocol',
url: 'tcp://10.0.0.1:2375',
environmentType: EnvironmentType.Docker,
expected: 'tcp://10.0.0.1:2375',
},
{
description: 'Docker environment with https:// protocol',
url: 'https://docker.example.com:2376',
environmentType: EnvironmentType.Docker,
expected: 'tcp://docker.example.com:2376',
},
{
description: 'Docker environment with http:// protocol',
url: 'http://docker.example.com:2375',
environmentType: EnvironmentType.Docker,
expected: 'tcp://docker.example.com:2375',
},
{
description: 'Docker environment with unix:// socket',
url: 'unix:///var/run/docker.sock',
environmentType: EnvironmentType.Docker,
expected: 'unix:///var/run/docker.sock',
},
{
description: 'Agent on Docker',
url: 'agent-host:9001',
environmentType: EnvironmentType.AgentOnDocker,
expected: 'tcp://agent-host:9001',
},
{
description: 'Kubernetes Local with https://',
url: 'https://k8s.example.com:6443',
environmentType: EnvironmentType.KubernetesLocal,
expected: 'https://k8s.example.com:6443',
},
{
description: 'Kubernetes Local without protocol',
url: 'k8s.example.com:6443',
environmentType: EnvironmentType.KubernetesLocal,
expected: 'https://k8s.example.com:6443',
},
{
description: 'Agent on Kubernetes no protocol added',
url: 'k8s-agent:9001',
environmentType: EnvironmentType.AgentOnKubernetes,
expected: 'k8s-agent:9001',
},
{
description: 'Edge Agent on Docker',
url: 'edge-agent:8000',
environmentType: EnvironmentType.EdgeAgentOnDocker,
expected: 'tcp://edge-agent:8000',
},
{
description: 'Edge Agent on Kubernetes',
url: 'edge-k8s:9001',
environmentType: EnvironmentType.EdgeAgentOnKubernetes,
expected: 'tcp://edge-k8s:9001',
},
{
description: 'Empty URL',
url: '',
environmentType: EnvironmentType.Docker,
expected: '',
},
])('$description', ({ url, environmentType, expected }) => {
const result = formatURL({ url, environmentType });
expect(result).toBe(expected);
});
});
describe('buildInitialValues', () => {
test.each([
{
description: 'Docker with tcp:// URL',
environment: {
URL: 'tcp://10.0.0.1:2375',
Type: EnvironmentType.Docker,
TLSConfig: { TLS: true, TLSSkipVerify: false },
},
expectedUrl: '10.0.0.1:2375',
expectedTls: {
tls: true,
skipVerify: false,
caCertFile: undefined,
certFile: undefined,
keyFile: undefined,
},
},
{
description: 'Docker with unix:// socket',
environment: {
URL: 'unix:///var/run/docker.sock',
Type: EnvironmentType.Docker,
},
expectedUrl: 'unix:///var/run/docker.sock',
expectedTls: undefined,
},
{
description: 'Kubernetes without TLS config',
environment: {
URL: 'https://k8s.example.com:6443',
Type: EnvironmentType.AgentOnKubernetes,
},
expectedUrl: 'k8s.example.com:6443',
expectedTls: undefined,
},
{
description: 'Agent on Docker without TLS',
environment: {
URL: 'tcp://agent:9001',
Type: EnvironmentType.AgentOnDocker,
},
expectedUrl: 'agent:9001',
expectedTls: undefined,
},
])('$description', ({ environment, expectedUrl, expectedTls }) => {
const mockEnv = createMockEnvironment(environment);
const result = buildInitialValues(mockEnv);
expect(result.environmentUrl).toBe(expectedUrl);
expect(result.tls).toEqual(expectedTls);
});
});
describe('buildUpdatePayload', () => {
test.each([
{
description: 'Docker environment with plain URL',
values: {
name: 'my-docker',
environmentUrl: '10.0.0.1:2375',
publicUrl: '1.2.3.4',
meta: { groupId: 1, tagIds: [1, 2] },
tls: {
tls: true,
skipVerify: false,
caCertFile: undefined,
certFile: undefined,
keyFile: undefined,
},
},
environmentType: EnvironmentType.Docker,
expectedUrl: 'tcp://10.0.0.1:2375',
},
{
description: 'Kubernetes Local environment',
values: {
name: 'my-k8s',
environmentUrl: 'k8s.local:6443',
publicUrl: '',
meta: { groupId: 1, tagIds: [] },
tls: undefined,
},
environmentType: EnvironmentType.KubernetesLocal,
expectedUrl: 'https://k8s.local:6443',
},
{
description: 'Agent on Kubernetes',
values: {
name: 'k8s-agent',
environmentUrl: 'agent:9001',
publicUrl: '',
meta: { groupId: 2, tagIds: [] },
tls: undefined,
},
environmentType: EnvironmentType.AgentOnKubernetes,
expectedUrl: 'agent:9001',
},
{
description: 'Local Docker (should return the same URL)',
values: {
name: 'local-docker',
environmentUrl: 'unix:///var/run/docker.sock',
publicUrl: '',
meta: { groupId: 1, tagIds: [] },
tls: undefined,
},
environmentType: EnvironmentType.Docker,
expectedUrl: 'unix:///var/run/docker.sock',
},
])('$description', ({ values, environmentType, expectedUrl }) => {
const payload = buildUpdatePayload({
values,
environmentType,
});
expect(payload.URL).toBe(expectedUrl);
expect(payload.Name).toBe(values.name);
expect(payload.PublicURL).toBe(values.publicUrl);
expect(payload.GroupID).toBe(values.meta.groupId);
expect(payload.TagIds).toEqual(values.meta.tagIds);
});
});

View File

@@ -5,14 +5,20 @@ import {
import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
import { stripProtocol } from '@/react/common/string-utils';
import { isDockerAPIEnvironment, isLocalDockerEnvironment } from '../../utils';
import { GeneralEnvironmentFormValues } from './types';
export function buildInitialValues(
environment: Environment
): GeneralEnvironmentFormValues {
const isDockerAPI = isDockerAPIEnvironment(environment);
const isLocalDocker = isLocalDockerEnvironment(environment.URL);
return {
name: environment.Name,
environmentUrl: stripProtocol(environment.URL),
environmentUrl: isLocalDocker
? environment.URL
: stripProtocol(environment.URL),
publicUrl: environment.PublicURL || '',
meta: {
@@ -20,54 +26,65 @@ export function buildInitialValues(
tagIds: environment.TagIds || [],
},
tls: {
tls: environment.TLSConfig?.TLS || false,
skipVerify: environment.TLSConfig?.TLSSkipVerify || false,
caCertFile: undefined,
certFile: undefined,
keyFile: undefined,
},
tls: isDockerAPI
? {
tls: environment.TLSConfig?.TLS || false,
skipVerify: environment.TLSConfig?.TLSSkipVerify || false,
caCertFile: undefined,
certFile: undefined,
keyFile: undefined,
}
: undefined,
};
}
export function buildUpdatePayload(
values: GeneralEnvironmentFormValues,
environmentType: EnvironmentType
): Partial<UpdateEnvironmentPayload> {
export function buildUpdatePayload({
environmentType,
values,
}: {
values: GeneralEnvironmentFormValues;
environmentType: EnvironmentType;
}): Partial<UpdateEnvironmentPayload> {
return {
Name: values.name,
PublicURL: values.publicUrl,
GroupID: values.meta.groupId,
TagIds: values.meta.tagIds,
URL: formatURL(values.environmentUrl, environmentType),
URL: formatURL({
url: values.environmentUrl,
environmentType,
}),
TLS: values.tls.tls,
TLSSkipVerify: values.tls.skipVerify,
TLSSkipClientVerify: values.tls.skipVerify,
TLSCACert: values.tls.caCertFile,
TLSCert: values.tls.certFile,
TLSKey: values.tls.keyFile,
TLS: values.tls?.tls,
TLSSkipVerify: values.tls?.skipVerify,
TLSSkipClientVerify: values.tls?.skipVerify,
TLSCACert: values.tls?.caCertFile,
TLSCert: values.tls?.certFile,
TLSKey: values.tls?.keyFile,
};
}
// URL Formatting Logic (from Angular controller lines 195-242)
export function formatURL(url: string, type: EnvironmentType): string {
if (!url) return '';
export function formatURL({
environmentType,
url,
}: {
url: string;
environmentType: EnvironmentType;
}) {
if (!url || isLocalDockerEnvironment(url)) {
return url;
}
// Strip any existing protocol
const stripped = stripProtocol(url);
// Kubernetes Local - prefix https://
if (type === EnvironmentType.KubernetesLocal) {
if (environmentType === EnvironmentType.KubernetesLocal) {
return `https://${stripped}`;
}
// Agent on Kubernetes - use as-is
if (type === EnvironmentType.AgentOnKubernetes) {
if (environmentType === EnvironmentType.AgentOnKubernetes) {
return stripped;
}
// Default (Docker) - prefix tcp://
return `tcp://${stripped}`;
}

View File

@@ -6,7 +6,7 @@ export interface GeneralEnvironmentFormValues {
environmentUrl: string;
publicUrl: string;
tls: TLSConfig;
tls?: TLSConfig;
meta: EnvironmentMetadata;
}

View File

@@ -27,7 +27,7 @@ export function useGeneralValidation({
? string().required('Environment address is required')
: string().default(''),
publicUrl: string().default(''),
tls: tlsConfigValidation({ optionalCert: true }),
tls: tlsConfigValidation({ optionalCert: true }).optional(),
meta: metadataValidation(),
});
}

View File

@@ -14,8 +14,8 @@ import { Link } from '@/react/components/Link';
import { Icon } from '@/react/components/Icon';
interface Props {
environmentId: EnvironmentId;
environmentType: EnvironmentType;
environmentId?: EnvironmentId;
environmentType?: EnvironmentType;
edgeId?: string;
status: EnvironmentStatus;
}
@@ -26,6 +26,10 @@ export function KubeConfigInfo({
edgeId,
status,
}: Props) {
if (!environmentType) {
return null;
}
const isVisible =
isKubernetesEnvironment(environmentType) &&
(!isEdgeEnvironment(environmentType) || !!edgeId) &&

View File

@@ -0,0 +1,266 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { http, HttpResponse, DefaultBodyType } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import { CreateGroupView } from './CreateGroupView';
vi.mock('@/react/hooks/useCanExit', () => ({
useCanExit: vi.fn(),
}));
vi.mock('@/portainer/services/notifications', () => ({
notifyError: vi.fn(),
notifySuccess: vi.fn(),
}));
function renderCreateGroupView({
onMutationError,
}: { onMutationError?(): void } = {}) {
// Set up default mocks
server.use(
http.get('/api/tags', () =>
HttpResponse.json([
{ ID: 1, Name: 'production' },
{ ID: 2, Name: 'staging' },
])
),
http.get('/api/endpoints', () =>
HttpResponse.json([], {
headers: {
'x-total-count': '0',
'x-total-available': '0',
},
})
)
);
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(CreateGroupView)),
{ onMutationError }
);
return render(<Wrapped />);
}
describe('CreateGroupView', () => {
describe('Page rendering', () => {
it('should render the page header with correct title', async () => {
renderCreateGroupView();
// Use heading role to get the h1 title specifically (not the breadcrumb)
expect(
await screen.findByRole('heading', { name: /Create group/i })
).toBeVisible();
});
it('should render breadcrumbs with link to Groups', async () => {
renderCreateGroupView();
// Use the data-cy attribute to find the breadcrumb link
expect(await screen.findByTestId('breadcrumb-Groups')).toBeVisible();
});
it('should render the form', async () => {
renderCreateGroupView();
expect(await screen.findByLabelText(/Name/i)).toBeVisible();
expect(screen.getByLabelText(/Description/i)).toBeVisible();
});
it('should render the Create button', async () => {
renderCreateGroupView();
expect(
await screen.findByRole('button', { name: /Create/i })
).toBeVisible();
});
});
describe('Form submission and payload validation', () => {
it('should submit the correct API payload with all fields', async () => {
let requestBody: DefaultBodyType;
server.use(
http.post('/api/endpoint_groups', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
Id: 1,
Name: 'test-group',
Description: 'Test description',
TagIds: [],
Policies: [],
});
})
);
const user = userEvent.setup();
renderCreateGroupView();
// Fill in the name
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'test-group');
// Fill in the description
const descriptionInput = screen.getByLabelText(/Description/i);
await user.type(descriptionInput, 'Test description');
// Submit the form
const submitButton = screen.getByRole('button', { name: /Create/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Verify the request body matches expected API payload
await waitFor(() => {
expect(requestBody).toEqual({
Name: 'test-group',
Description: 'Test description',
TagIDs: [],
AssociatedEndpoints: [],
});
});
});
it('should submit the correct API payload with minimal fields (name only)', async () => {
let requestBody: DefaultBodyType;
server.use(
http.post('/api/endpoint_groups', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
Id: 1,
Name: 'minimal-group',
Description: '',
TagIds: [],
Policies: [],
});
})
);
const user = userEvent.setup();
renderCreateGroupView();
// Fill in only the name (required)
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'minimal-group');
// Submit the form
const submitButton = screen.getByRole('button', { name: /Create/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Verify the request body
await waitFor(() => {
expect(requestBody).toEqual({
Name: 'minimal-group',
Description: '',
TagIDs: [],
AssociatedEndpoints: [],
});
});
});
it('should show Creating... loading state during submission', async () => {
server.use(
http.post('/api/endpoint_groups', async () => {
// Delay response to test loading state
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
return HttpResponse.json({
Id: 1,
Name: 'test-group',
Description: '',
TagIds: [],
Policies: [],
});
})
);
const user = userEvent.setup();
renderCreateGroupView();
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'test-group');
const submitButton = screen.getByRole('button', { name: /Create/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Should show loading state
expect(
await screen.findByRole('button', { name: /Creating.../i })
).toBeVisible();
});
});
describe('Error handling', () => {
it('should handle API error gracefully', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mutationError = vi.fn();
const errorMessage = 'Failed to create group';
server.use(
http.post('/api/endpoint_groups', () =>
HttpResponse.json({ message: errorMessage }, { status: 500 })
)
);
const user = userEvent.setup();
renderCreateGroupView({ onMutationError: mutationError });
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'test-group');
const submitButton = screen.getByRole('button', { name: /Create/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
await waitFor(() => {
expect(mutationError).toHaveBeenCalled();
});
consoleErrorSpy.mockRestore();
});
});
describe('Validation', () => {
it('should show validation error for empty name', async () => {
renderCreateGroupView();
await waitFor(() => {
expect(screen.getAllByText(/Name is required/i)[0]).toBeVisible();
});
});
it('should disable submit button when name is empty', async () => {
renderCreateGroupView();
const submitButton = await screen.findByRole('button', {
name: /Create/i,
});
expect(submitButton).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,70 @@
import { useRouter } from '@uirouter/react';
import { FormikHelpers } from 'formik';
import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { PageHeader } from '@@/PageHeader';
import { useCreateGroupMutation } from '../queries/useCreateGroupMutation';
import { GroupForm, GroupFormValues } from '../components/GroupForm';
export function CreateGroupView() {
const router = useRouter();
const createMutation = useCreateGroupMutation();
const initialValues: GroupFormValues = {
name: '',
description: '',
tagIds: [],
associatedEnvironments: [],
};
return (
<>
<PageHeader
title="Create group"
breadcrumbs={[
{ label: 'Groups', link: 'portainer.groups' },
{ label: 'Create group' },
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<GroupForm
initialValues={initialValues}
onSubmit={handleSubmit}
submitLabel="Create"
submitLoadingLabel="Creating..."
/>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
async function handleSubmit(
values: GroupFormValues,
{ resetForm }: FormikHelpers<GroupFormValues>
) {
await createMutation.mutateAsync(
{
name: values.name,
description: values.description,
tagIds: values.tagIds,
associatedEnvironments: values.associatedEnvironments,
},
{
onSuccess: () => {
resetForm();
notifySuccess('Success', 'Group successfully created');
router.stateService.go('portainer.groups');
},
}
);
}
}

View File

@@ -0,0 +1,506 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { http, HttpResponse, DefaultBodyType } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import {
Environment,
EnvironmentType,
EnvironmentStatus,
} from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '../types';
import { EditGroupView } from './EditGroupView';
vi.mock('@/react/hooks/useCanExit', () => ({
useCanExit: vi.fn(),
}));
vi.mock('@/react/hooks/useIdParam', () => ({
useIdParam: () => 2, // Default to group ID 2 for most tests
}));
vi.mock('@/portainer/services/notifications', () => ({
notifyError: vi.fn(),
notifySuccess: vi.fn(),
}));
const mockGroup: EnvironmentGroup = {
Id: 2,
Name: 'Test Group',
Description: 'Test description',
TagIds: [1],
};
const mockEnvironment: Partial<Environment> = {
Id: 1,
Name: 'Test Environment',
Type: EnvironmentType.Docker,
Status: EnvironmentStatus.Up,
GroupId: 2,
TagIds: [],
};
function buildMockEnvironment(
id: number,
name: string,
groupId: number = 1
): Partial<Environment> {
return {
Id: id,
Name: name,
Type: EnvironmentType.Docker,
Status: EnvironmentStatus.Up,
GroupId: groupId,
TagIds: [],
};
}
function renderEditGroupView({
onMutationError,
groupData = mockGroup,
associatedEnvironments = [mockEnvironment],
}: {
onMutationError?(): void;
groupData?: EnvironmentGroup | null;
associatedEnvironments?: Array<Partial<Environment>>;
} = {}) {
// Set up default mocks
server.use(
http.get('/api/tags', () =>
HttpResponse.json([
{ ID: 1, Name: 'production' },
{ ID: 2, Name: 'staging' },
])
),
http.get<object, never, EnvironmentGroup | { message: string }>(
'/api/endpoint_groups/2',
() => {
if (groupData === null) {
return HttpResponse.json(
{ message: 'Group not found' },
{ status: 404 }
);
}
return HttpResponse.json(groupData);
}
),
// Mock for environments query (associated environments)
http.get('/api/endpoints', ({ request }) => {
const url = new URL(request.url);
// Get all groupIds values (handles both groupIds=2 and groupIds[]=2 formats)
const groupIdsParam = url.searchParams.getAll('groupIds');
const groupIds =
groupIdsParam.length > 0
? groupIdsParam
: url.searchParams.getAll('groupIds[]');
const endpointIdsParam = url.searchParams.getAll('endpointIds');
const endpointIds =
endpointIdsParam.length > 0
? endpointIdsParam
: url.searchParams.getAll('endpointIds[]');
// Helper to create response with required headers
function createResponse(envs: Array<Partial<Environment>>) {
return HttpResponse.json(envs, {
headers: {
'x-total-count': String(envs.length),
'x-total-available': String(envs.length),
},
});
}
// If querying by endpointIds (AssociatedEnvironmentsSelector initial query)
if (endpointIds.length > 0) {
const ids = endpointIds.map(Number);
const envs = associatedEnvironments.filter((e) =>
ids.includes(e.Id as number)
);
return createResponse(envs);
}
// If querying for group's associated environments
if (groupIds.includes('2')) {
return createResponse(associatedEnvironments);
}
// For available environments (unassigned group = 1)
if (groupIds.includes('1')) {
return createResponse([
buildMockEnvironment(10, 'Available Env 1'),
buildMockEnvironment(11, 'Available Env 2'),
]);
}
return createResponse([]);
})
);
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(EditGroupView)),
{ onMutationError }
);
return render(<Wrapped />);
}
describe('EditGroupView', () => {
describe('Page rendering', () => {
it('should render the page header with correct title', async () => {
renderEditGroupView();
expect(
await screen.findByText('Environment group details')
).toBeVisible();
});
it('should render breadcrumbs with link to Groups', async () => {
renderEditGroupView();
expect(await screen.findByText('Groups')).toBeVisible();
});
it('should render group name in breadcrumbs after loading', async () => {
renderEditGroupView();
expect(await screen.findByText('Test Group')).toBeVisible();
});
it('should render the Update button', async () => {
renderEditGroupView();
expect(
await screen.findByRole('button', { name: /Update/i })
).toBeVisible();
});
});
describe('Loading state', () => {
it('should not show form while fetching group data', async () => {
server.use(
http.get('/api/endpoint_groups/2', async () => {
// Delay response to test loading state
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
return HttpResponse.json(mockGroup);
})
);
renderEditGroupView();
// Form should not be visible initially while loading
expect(screen.queryByLabelText(/Name/i)).not.toBeInTheDocument();
// After loading completes, form should appear
await waitFor(() => {
expect(screen.getByLabelText(/Name/i)).toBeVisible();
});
});
it('should populate form with fetched group data', async () => {
renderEditGroupView();
// Wait for data to load and populate the form
const nameInput = await screen.findByLabelText(/Name/i);
await waitFor(() => {
expect(nameInput).toHaveValue('Test Group');
});
const descriptionInput = screen.getByLabelText(/Description/i);
expect(descriptionInput).toHaveValue('Test description');
});
it('should show Associated environments section for non-unassigned groups', async () => {
renderEditGroupView();
// Wait for form to load
await screen.findByLabelText(/Name/i);
// Check that at least one "Associated environments" text exists (section + table title)
const elements = screen.getAllByText(/Associated environments/i);
expect(elements.length).toBeGreaterThan(0);
expect(elements[0]).toBeVisible();
});
});
describe('Error state', () => {
it('should show error Alert when group fetch fails', async () => {
renderEditGroupView({ groupData: null });
// Should show the error message
expect(
await screen.findByText(/Failed to load group details/i)
).toBeVisible();
});
it('should show error alert with Error title', async () => {
renderEditGroupView({ groupData: null });
// Wait for the error alert to appear by finding the error message
await screen.findByText(/Failed to load group details/i);
// Check that the Error title is shown
expect(screen.getByText('Error')).toBeVisible();
});
it('should NOT show the form when group fetch fails', async () => {
renderEditGroupView({ groupData: null });
// Wait for the error message to appear
await screen.findByText(/Failed to load group details/i);
// Form fields should not be visible
expect(screen.queryByLabelText(/Name/i)).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Update/i })
).not.toBeInTheDocument();
});
});
describe('Form submission and payload validation', () => {
it('should submit the correct API payload when updating', async () => {
let requestBody: DefaultBodyType;
let requestUrl: string;
server.use(
http.put('/api/endpoint_groups/:id', async ({ request, params }) => {
requestBody = await request.json();
requestUrl = `/api/endpoint_groups/${params.id}`;
return HttpResponse.json({
...mockGroup,
Name: 'Updated Group',
});
})
);
const user = userEvent.setup();
renderEditGroupView();
// Wait for form to populate
const nameInput = await screen.findByLabelText(/Name/i);
await waitFor(() => {
expect(nameInput).toHaveValue('Test Group');
});
// Clear and update the name
await user.clear(nameInput);
await user.type(nameInput, 'Updated Group');
// Submit the form
const submitButton = screen.getByRole('button', { name: /Update/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Verify the request URL and body
await waitFor(() => {
expect(requestUrl).toBe('/api/endpoint_groups/2');
expect(requestBody).toEqual({
Name: 'Updated Group',
Description: 'Test description',
TagIDs: [1],
AssociatedEndpoints: [1], // The associated environment ID
});
});
});
it('should submit updated description correctly', async () => {
let requestBody: DefaultBodyType;
server.use(
http.put('/api/endpoint_groups/:id', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({
...mockGroup,
Description: 'New description',
});
})
);
const user = userEvent.setup();
renderEditGroupView();
// Wait for form to populate
const descriptionInput = await screen.findByLabelText(/Description/i);
await waitFor(() => {
expect(descriptionInput).toHaveValue('Test description');
});
// Clear and update the description
await user.clear(descriptionInput);
await user.type(descriptionInput, 'New description');
// Submit the form
const submitButton = screen.getByRole('button', { name: /Update/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Verify the request body includes new description
await waitFor(() => {
expect(requestBody).toMatchObject({
Description: 'New description',
});
});
});
it('should show Updating... loading state during submission', async () => {
server.use(
http.put('/api/endpoint_groups/:id', async () => {
// Delay response to test loading state
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
return HttpResponse.json(mockGroup);
})
);
const user = userEvent.setup();
renderEditGroupView();
// Wait for form to populate
const nameInput = await screen.findByLabelText(/Name/i);
await waitFor(() => {
expect(nameInput).toHaveValue('Test Group');
});
// Make a change to enable the submit button
await user.clear(nameInput);
await user.type(nameInput, 'Changed Name');
const submitButton = screen.getByRole('button', { name: /Update/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Should show loading state
expect(
await screen.findByRole('button', { name: /Updating.../i })
).toBeVisible();
});
});
describe('Error handling on update', () => {
it('should handle API error gracefully on update', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mutationError = vi.fn();
const errorMessage = 'Failed to update group';
server.use(
http.put('/api/endpoint_groups/:id', () =>
HttpResponse.json({ message: errorMessage }, { status: 500 })
)
);
const user = userEvent.setup();
renderEditGroupView({ onMutationError: mutationError });
// Wait for form to populate
const nameInput = await screen.findByLabelText(/Name/i);
await waitFor(() => {
expect(nameInput).toHaveValue('Test Group');
});
// Make a change
await user.clear(nameInput);
await user.type(nameInput, 'Changed Name');
const submitButton = screen.getByRole('button', { name: /Update/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
await waitFor(() => {
expect(mutationError).toHaveBeenCalled();
});
consoleErrorSpy.mockRestore();
});
});
describe('Associated environments', () => {
it('should display initially associated environments', async () => {
renderEditGroupView({
associatedEnvironments: [
{ ...mockEnvironment, Id: 1, Name: 'Env 1' } as Partial<Environment>,
{ ...mockEnvironment, Id: 2, Name: 'Env 2' } as Partial<Environment>,
],
});
// Wait for the form to load
await screen.findByLabelText(/Name/i);
// Check that at least one "Associated environments" text exists (section + table title)
const elements = screen.getAllByText(/Associated environments/i);
expect(elements.length).toBeGreaterThan(0);
expect(elements[0]).toBeVisible();
});
it('should include associated environment IDs in update payload', async () => {
let requestBody: DefaultBodyType;
const associatedEnvs = [
{
...mockEnvironment,
Id: 100,
Name: 'Env 100',
} as Partial<Environment>,
{
...mockEnvironment,
Id: 200,
Name: 'Env 200',
} as Partial<Environment>,
];
server.use(
http.put('/api/endpoint_groups/:id', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json(mockGroup);
})
);
const user = userEvent.setup();
renderEditGroupView({
associatedEnvironments: associatedEnvs,
});
// Wait for form to populate
const nameInput = await screen.findByLabelText(/Name/i);
await waitFor(() => {
expect(nameInput).toHaveValue('Test Group');
});
// Make a small change to enable submit
await user.type(nameInput, ' edited');
const submitButton = screen.getByRole('button', { name: /Update/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Verify the associated environments are included in payload
await waitFor(() => {
expect(requestBody).toMatchObject({
AssociatedEndpoints: [100, 200],
});
});
});
});
});

View File

@@ -0,0 +1,100 @@
import { useRouter } from '@uirouter/react';
import { useMemo } from 'react';
import { FormikHelpers } from 'formik';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { notifySuccess } from '@/portainer/services/notifications';
import { useIdParam } from '@/react/hooks/useIdParam';
import { Widget } from '@@/Widget';
import { PageHeader } from '@@/PageHeader';
import { Alert } from '@@/Alert';
import { useGroup } from '../queries/useGroup';
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
import { GroupForm, GroupFormValues } from '../components/GroupForm';
export function EditGroupView() {
const groupId = useIdParam();
const router = useRouter();
const groupQuery = useGroup(groupId);
const updateMutation = useUpdateGroupMutation();
// Fetch associated environments for this group (not for unassigned group)
const isUnassignedGroup = groupId === 1;
const environmentsQuery = useEnvironmentList(
{ groupIds: [groupId] },
{ enabled: !!groupId && !isUnassignedGroup }
);
const isLoading =
groupQuery.isLoading || (!isUnassignedGroup && environmentsQuery.isLoading);
const initialValues: GroupFormValues = useMemo(
() => ({
name: groupQuery.data?.Name ?? '',
description: groupQuery.data?.Description ?? '',
tagIds: groupQuery.data?.TagIds ?? [],
associatedEnvironments:
environmentsQuery.environments?.map((e) => e.Id) ?? [],
}),
[groupQuery.data, environmentsQuery.environments]
);
return (
<>
<PageHeader
title="Environment group details"
breadcrumbs={[
{ label: 'Groups', link: 'portainer.groups' },
{ label: groupQuery.data?.Name ?? 'Edit group' },
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body loading={isLoading}>
{groupQuery.isError && (
<Alert color="error" title="Error">
Failed to load group details
</Alert>
)}
{!groupQuery.isError && groupQuery.data && (
<GroupForm
initialValues={initialValues}
onSubmit={handleSubmit}
submitLabel="Update"
submitLoadingLabel="Updating..."
groupId={groupId}
/>
)}
</Widget.Body>
</Widget>
</div>
</div>
</>
);
async function handleSubmit(
values: GroupFormValues,
{ resetForm }: FormikHelpers<GroupFormValues>
) {
await updateMutation.mutateAsync(
{
id: groupId,
name: values.name,
description: values.description,
tagIds: values.tagIds,
associatedEnvironments: values.associatedEnvironments,
},
{
onSuccess() {
resetForm();
notifySuccess('Success', 'Group successfully updated');
router.stateService.go('portainer.groups');
},
}
);
}
}

View File

@@ -1,61 +0,0 @@
import { EnvironmentId } from '../../types';
import { GroupAssociationTable } from './GroupAssociationTable';
export function AssociatedEnvironmentsSelector({
onChange,
value,
}: {
onChange: (
value: EnvironmentId[],
meta: { type: 'add' | 'remove'; value: EnvironmentId }
) => void;
value: EnvironmentId[];
}) {
return (
<>
<div className="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">
<GroupAssociationTable
title="Available environments"
query={{
groupIds: [1],
excludeIds: value,
}}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
onChange([...value, env.Id], { type: 'add', value: env.Id });
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
/>
</div>
<div className="w-1/2">
<GroupAssociationTable
title="Associated environments"
query={{
endpointIds: value,
}}
onClickRow={(env) => {
if (value.includes(env.Id)) {
onChange(
value.filter((id) => id !== env.Id),
{ type: 'remove', value: env.Id }
);
}
}}
data-cy="edgeGroupCreate-associatedEndpoints"
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,237 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { server } from '@/setup-tests/server';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector';
function createEnv(id: EnvironmentId, name: string): Environment {
return createMockEnvironment({ Id: id, Name: name, GroupId: 1 });
}
function setupMockServer(environments: Array<Environment> = []) {
server.use(
http.get('/api/endpoints', () =>
HttpResponse.json(environments, {
headers: {
'x-total-count': String(environments.length),
'x-total-available': String(environments.length),
},
})
)
);
}
function renderComponent({
associatedEnvironmentIds = [] as Array<EnvironmentId>,
initialAssociatedEnvironmentIds = [] as Array<EnvironmentId>,
onChange = vi.fn(),
}: {
associatedEnvironmentIds?: Array<EnvironmentId>;
initialAssociatedEnvironmentIds?: Array<EnvironmentId>;
onChange?: (ids: Array<EnvironmentId>) => void;
} = {}) {
const Wrapped = withTestQueryProvider(() => (
<AssociatedEnvironmentsSelector
associatedEnvironmentIds={associatedEnvironmentIds}
initialAssociatedEnvironmentIds={initialAssociatedEnvironmentIds}
onChange={onChange}
/>
));
return {
...render(<Wrapped />),
onChange,
};
}
describe('AssociatedEnvironmentsSelector', () => {
describe('Rendering', () => {
it('should render both Available and Associated environments tables', async () => {
setupMockServer();
renderComponent();
expect(
screen.getByRole('heading', { name: 'Available environments' })
).toBeVisible();
expect(
screen.getByRole('heading', { name: 'Associated environments' })
).toBeVisible();
});
it('should render instruction text', async () => {
setupMockServer();
renderComponent();
expect(
await screen.findByText(/click on any environment entry to move it/i)
).toBeInTheDocument();
});
it('should render Associated environments table with data-cy attribute', async () => {
setupMockServer();
renderComponent();
await waitFor(() => {
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
});
});
it('should display initially associated environments in Associated table', async () => {
const envs = [createEnv(10, 'associated-env-1')];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
});
await waitFor(() => {
expect(screen.getByText('associated-env-1')).toBeInTheDocument();
});
});
});
describe('Adding environments', () => {
it('should call onChange with new environment ID when clicking an available environment', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env')];
setupMockServer(envs);
renderComponent({ onChange });
const envRow = await screen.findByText('available-env');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([1]);
});
it('should append new environment to existing associated IDs', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(1, 'available-env'), createEnv(10, 'existing')];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
onChange,
});
// Wait for the available table to be ready and find the row
const availableTable = await screen.findByTestId(
'group-availableEndpoints'
);
await within(availableTable).findByText('available-env');
// Find the row element that contains the text and click it
const rows = within(availableTable).getAllByRole('row');
const envRow = rows.find(
(row) => row.textContent?.includes('available-env')
);
expect(envRow).toBeDefined();
await user.click(envRow!);
// Wait for onChange to be called with the new environment ID appended
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith([10, 1]);
});
});
});
describe('Removing environments', () => {
it('should call onChange without the removed environment ID', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [
createEnv(10, 'associated-env-1'),
createEnv(11, 'associated-env-2'),
];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10, 11],
initialAssociatedEnvironmentIds: [10, 11],
onChange,
});
// Wait for initial query to load and row to appear in Associated table, then click
const associatedTable = await screen.findByTestId(
'group-associatedEndpoints'
);
const envRow =
await within(associatedTable).findByText('associated-env-1');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([11]);
});
it('should call onChange with empty array when removing last environment', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const envs = [createEnv(10, 'only-env')];
setupMockServer(envs);
renderComponent({
associatedEnvironmentIds: [10],
initialAssociatedEnvironmentIds: [10],
onChange,
});
const associatedTable = await screen.findByTestId(
'group-associatedEndpoints'
);
const envRow = await within(associatedTable).findByText('only-env');
await user.click(envRow);
expect(onChange).toHaveBeenCalledWith([]);
});
});
describe('Computed values', () => {
it('should identify added IDs (current but not initial)', () => {
// addedIds = associatedEnvironmentIds.filter(id => !initialAssociatedEnvironmentIds.includes(id))
// When current=[1,2,3] and initial=[2,3], added=[1]
setupMockServer();
// This test validates the component's internal logic by checking the highlightIds
// passed to AssociatedEnvironmentsTable (newly added envs get "Unsaved" badge)
renderComponent({
associatedEnvironmentIds: [1, 2, 3],
initialAssociatedEnvironmentIds: [2, 3],
});
// The component will compute addedIds=[1] internally
// We can't directly test internal state, but we verify it renders
expect(screen.getByTestId('group-associatedEndpoints')).toBeVisible();
});
it('should identify removed IDs (initial but not current)', () => {
// removedIds = initialAssociatedEnvironmentIds.filter(id => !associatedEnvironmentIds.includes(id))
// When current=[2,3] and initial=[1,2,3], removed=[1]
setupMockServer();
renderComponent({
associatedEnvironmentIds: [2, 3],
initialAssociatedEnvironmentIds: [1, 2, 3],
});
// The component will compute removedIds=[1] internally
// and pass it as includeIds to AvailableEnvironmentsTable
expect(screen.getByTestId('group-availableEndpoints')).toBeVisible();
});
});
});

View File

@@ -0,0 +1,109 @@
import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { FormSection } from '@@/form-components/FormSection';
import { Environment, EnvironmentId } from '../../../types';
import { EnvironmentTableData } from './types';
import { AssociatedEnvironmentsTable } from './AssociatedEnvironmentsTable';
import { AvailableEnvironmentsTable } from './AvailableEnvironmentsTable';
interface Props {
/** IDs of currently associated environments */
associatedEnvironmentIds: Array<EnvironmentId>;
/** IDs of initially associated environments for tracking unsaved changes */
initialAssociatedEnvironmentIds: Array<EnvironmentId>;
/** Called when environment IDs change */
onChange: (ids: Array<EnvironmentId>) => void;
}
export function AssociatedEnvironmentsSelector({
associatedEnvironmentIds,
initialAssociatedEnvironmentIds,
onChange,
}: Props) {
// Track full environment objects for display (populated when clicking rows)
const [environmentCache, setEnvironmentCache] = useState<
Map<EnvironmentId, EnvironmentTableData>
>(new Map());
// Fetch initially associated environments to populate the cache
const initialEnvsQuery = useEnvironmentList(
{
endpointIds: initialAssociatedEnvironmentIds,
},
{
enabled: initialAssociatedEnvironmentIds.length > 0,
}
);
const environmentMap = buildEnvironmentMap(
environmentCache,
initialEnvsQuery.environments
);
const addedIds = associatedEnvironmentIds.filter(
(id) => !initialAssociatedEnvironmentIds.includes(id)
);
const removedIds = initialAssociatedEnvironmentIds.filter(
(id) => !associatedEnvironmentIds.includes(id)
);
const associatedEnvironments = associatedEnvironmentIds
.map((id) => environmentMap.get(id))
.filter((env): env is Environment => env !== undefined);
return (
<FormSection title="Associated environments">
<div className="small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
<div className="flex mt-4 gap-5 items-stretch">
<div className="w-1/2 flex flex-col">
<AvailableEnvironmentsTable
title="Available environments"
excludeIds={associatedEnvironmentIds}
includeIds={removedIds}
onClickRow={handleAddEnvironment}
data-cy="group-availableEndpoints"
/>
</div>
<div className="w-1/2 flex flex-col">
<AssociatedEnvironmentsTable
title="Associated environments"
environments={associatedEnvironments}
highlightIds={addedIds}
onClickRow={handleRemoveEnvironment}
data-cy="group-associatedEndpoints"
/>
</div>
</div>
</FormSection>
);
function handleAddEnvironment(env: EnvironmentTableData) {
if (!associatedEnvironmentIds.includes(env.Id)) {
setEnvironmentCache((prev) => new Map(prev).set(env.Id, env));
onChange([...associatedEnvironmentIds, env.Id]);
}
}
function handleRemoveEnvironment(env: EnvironmentTableData) {
onChange(associatedEnvironmentIds.filter((id) => id !== env.Id));
}
}
function buildEnvironmentMap(
cache: Map<EnvironmentId, EnvironmentTableData>,
envs: Array<Environment> | undefined
): Map<EnvironmentId, EnvironmentTableData> {
return new Map([
...cache.entries(),
...(envs ?? []).map(
(env) => [env.Id, { Name: env.Name, Id: env.Id }] as const
),
]);
}

View File

@@ -0,0 +1,82 @@
import { createColumnHelper } from '@tanstack/react-table';
import clsx from 'clsx';
import { truncate } from 'lodash';
import { useMemo } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { Badge } from '@@/Badge';
import { Widget } from '@@/Widget';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
interface Props extends AutomationTestingProps {
title: string;
environments: Array<EnvironmentTableData>;
onClickRow?: (env: EnvironmentTableData) => void;
highlightIds?: Array<EnvironmentId>;
}
export function AssociatedEnvironmentsTable({
title,
environments,
onClickRow,
highlightIds = [],
'data-cy': dataCy,
}: Props) {
const tableState = useTableStateWithoutStorage('Name');
const columns = useMemo(() => buildColumns(highlightIds), [highlightIds]);
return (
<Widget className="flex-1 flex flex-col">
<div
className={clsx(
'h-full flex flex-col',
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
'[&_.footer]:!mt-auto'
)}
>
<Datatable<EnvironmentTableData>
// noWidget to avoid padding issues with TableContainer
noWidget
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
disableSelect
data-cy={dataCy || 'environment-table'}
/>
</div>
</Widget>
);
}
function buildColumns(highlightIds: Array<EnvironmentId>) {
return [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue, row }) => (
<span className="flex items-center gap-2">
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.includes(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
),
}),
];
}

View File

@@ -0,0 +1,167 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import clsx from 'clsx';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isSortType } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
import { semverCompare } from '@/react/common/semver-utils';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { Badge } from '@@/Badge';
import { Widget } from '@@/Widget';
import { EnvironmentTableData } from './types';
const columnHelper = createColumnHelper<EnvironmentTableData>();
const tableKey = 'available-environments';
const settingsStore = createPersistedStore(tableKey, 'Name');
interface Props extends AutomationTestingProps {
title: string;
/** IDs to exclude from the query (environments already associated) */
excludeIds: Array<EnvironmentId>;
/** IDs to include in the query (e.g., recently removed from associated - will be highlighted) */
includeIds?: Array<EnvironmentId>;
onClickRow?: (env: EnvironmentTableData) => void;
}
export function AvailableEnvironmentsTable({
title,
excludeIds,
includeIds = [],
onClickRow,
'data-cy': dataCy,
}: Props) {
const tableState = useTableState(settingsStore, tableKey);
const [page, setPage] = useState(0);
const columns = useMemo(() => buildColumns(includeIds), [includeIds]);
// Query unassigned environments (group 1)
const unassignedQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: isSortType(tableState.sortBy?.id) ? tableState.sortBy.id : 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
groupIds: [1],
excludeIds,
});
// Query removed environments by ID (these are still in their original group until saved)
const removedQuery = useEnvironmentList(
{
endpointIds: includeIds,
search: tableState.search,
},
{ enabled: includeIds.length > 0 }
);
// Merge results: removed environments + unassigned environments (deduped)
// Only use removedQuery data when includeIds is non-empty to avoid stale cache
const environments = useMemo(() => {
const unassigned = unassignedQuery.environments || [];
const removed =
includeIds.length > 0 ? removedQuery.environments || [] : [];
if (removed.length === 0) {
return unassigned;
}
const unassignedIds = new Set(unassigned.map((e) => e.Id));
const uniqueRemoved = removed.filter((e) => !unassignedIds.has(e.Id));
// Sort combined results by name to maintain order
const combined = [...uniqueRemoved, ...unassigned];
const isDesc = tableState.sortBy?.desc ?? false;
// useTypeGuard on tableState.sortBy.id to use as a key for sorting
const sortKey = getSortKey(tableState.sortBy?.id);
if (sortKey) {
return combined.sort((a, b) => {
const cmp = semverCompare(a[sortKey].toString(), b[sortKey].toString());
return isDesc ? -cmp : cmp;
});
}
return combined;
}, [
unassignedQuery.environments,
removedQuery.environments,
includeIds.length,
tableState.sortBy?.desc,
tableState.sortBy?.id,
]);
const totalCount =
unassignedQuery.totalCount +
(includeIds.length > 0 ? removedQuery.environments?.length || 0 : 0);
return (
<Widget className="flex-1 flex flex-col">
<div
className={clsx(
'h-full flex flex-col',
'[&_section.datatable]:flex-1 [&_section.datatable]:flex [&_section.datatable]:flex-col',
'[&_.footer]:!mt-auto'
)}
>
<Datatable<EnvironmentTableData>
// noWidget to avoid padding issues with TableContainer
noWidget
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
renderRow={(row) => (
<TableRow<EnvironmentTableData>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
disableSelect
data-cy={dataCy || 'available-environments-table'}
/>
</div>
</Widget>
);
}
function buildColumns(highlightIds: Array<EnvironmentId>) {
return [
columnHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue, row }) => (
<span className="flex items-center gap-2">
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
{highlightIds.includes(row.original.Id) && (
<Badge type="muted" data-cy="unsaved-badge">
Unsaved
</Badge>
)}
</span>
),
}),
];
}
function getSortKey(sortId?: string): keyof EnvironmentTableData | undefined {
if (!sortId) {
return undefined;
}
switch (sortId) {
case 'Name':
return 'Name';
default:
return 'Name';
}
// extend to other keys as needed
}

View File

@@ -0,0 +1,3 @@
import { Environment } from '@/react/portainer/environments/types';
export type EnvironmentTableData = Pick<Environment, 'Name' | 'Id'>;

View File

@@ -1,68 +0,0 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
const columHelper = createColumnHelper<Environment>();
const columns = [
columHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => (
<span title={getValue()}>{truncate(getValue(), { length: 64 })}</span>
),
}),
];
export function GroupAssociationTable({
title,
query,
onClickRow,
'data-cy': dataCy,
}: {
title: string;
query: EnvironmentsQueryParams;
onClickRow?: (env: Environment) => void;
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(0);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: tableState.sortBy?.id as 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
...query,
});
const { environments } = environmentsQuery;
return (
<Datatable<Environment>
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={environmentsQuery.totalCount}
renderRow={(row) => (
<TableRow<Environment>
cells={row.getVisibleCells()}
onClick={onClickRow ? () => onClickRow(row.original) : undefined}
/>
)}
data-cy={dataCy}
disableSelect
/>
);
}

View File

@@ -0,0 +1,320 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import { GroupForm, GroupFormValues } from './GroupForm';
vi.mock('@/react/hooks/useCanExit', () => ({
useCanExit: vi.fn(),
}));
function renderGroupForm({
initialValues = {
name: '',
description: '',
tagIds: [],
associatedEnvironments: [],
},
onSubmit = vi.fn(),
submitLabel = 'Create',
submitLoadingLabel = 'Creating...',
groupId,
}: {
initialValues?: GroupFormValues;
onSubmit?: (values: GroupFormValues) => Promise<void>;
submitLabel?: string;
submitLoadingLabel?: string;
groupId?: number;
} = {}) {
// Mock tag endpoints
server.use(
http.get('/api/tags', () =>
HttpResponse.json([
{ ID: 1, Name: 'production' },
{ ID: 2, Name: 'staging' },
])
),
// Mock environments list for AssociatedEnvironmentsSelector
http.get('/api/endpoints', () =>
HttpResponse.json([], {
headers: {
'x-total-count': '0',
'x-total-available': '0',
},
})
)
);
const Wrapped = withTestQueryProvider(
withTestRouter(
withUserProvider(() => (
<GroupForm
initialValues={initialValues}
onSubmit={onSubmit}
submitLabel={submitLabel}
submitLoadingLabel={submitLoadingLabel}
groupId={groupId}
/>
))
)
);
return {
...render(<Wrapped />),
onSubmit,
};
}
describe('GroupForm', () => {
describe('Form rendering', () => {
it('should render name and description fields', async () => {
renderGroupForm();
expect(await screen.findByLabelText(/Name/i)).toBeVisible();
expect(screen.getByLabelText(/Description/i)).toBeVisible();
});
it('should render the submit button with correct label', async () => {
renderGroupForm({ submitLabel: 'Create' });
expect(
await screen.findByRole('button', { name: /Create/i })
).toBeVisible();
});
it('should show Associated environments section when groupId is provided (not unassigned group)', async () => {
renderGroupForm({ groupId: 2 });
// Wait for form to render
await screen.findByLabelText(/Name/i);
// Check for section title using findByRole
expect(
await screen.findByRole('heading', { name: /Associated environments/i })
).toBeVisible();
});
it('should show Unassociated environments section when groupId is 1 (unassigned group)', async () => {
renderGroupForm({ groupId: 1 });
// Wait for form to render
await screen.findByLabelText(/Name/i);
// Check for section title using findByRole
expect(
await screen.findByRole('heading', {
name: /Unassociated environments/i,
})
).toBeVisible();
// Should NOT show "Associated environments" section (exact match to exclude "Unassociated")
const associatedElements = screen.queryAllByText(
/^Associated environments$/i
);
expect(associatedElements).toHaveLength(0);
});
it('should show Associated environments section in create mode (no groupId)', async () => {
renderGroupForm();
// Wait for form to render
await screen.findByLabelText(/Name/i);
// Check for section title using findByRole
expect(
await screen.findByRole('heading', { name: /Associated environments/i })
).toBeVisible();
});
});
describe('Form validation', () => {
it('should show validation error when name is empty', async () => {
renderGroupForm();
// The form validates on mount
await waitFor(() => {
expect(
screen.getByRole('alert', { name: /Name is required/i })
).toBeVisible();
});
});
it('should disable submit button when form is invalid', async () => {
renderGroupForm();
const submitButton = await screen.findByRole('button', {
name: /Create/i,
});
expect(submitButton).toBeDisabled();
});
it('should disable submit button when form is not dirty', async () => {
renderGroupForm({
initialValues: {
name: 'existing-group',
description: '',
tagIds: [],
associatedEnvironments: [],
},
});
const submitButton = await screen.findByRole('button', {
name: /Create/i,
});
// Form is valid but not dirty, so should be disabled
expect(submitButton).toBeDisabled();
});
it('should enable submit button when form is valid and dirty', async () => {
const user = userEvent.setup();
renderGroupForm();
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'my-new-group');
const submitButton = screen.getByRole('button', { name: /Create/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
});
it('should clear validation error when name is provided', async () => {
const user = userEvent.setup();
renderGroupForm();
// Wait for initial validation error
await waitFor(() => {
expect(
screen.getByRole('alert', { name: /Name is required/i })
).toBeVisible();
});
// Use data-cy attribute to find the specific name input
const nameInput = screen.getByTestId('group-name-input');
await user.type(nameInput, 'my-group');
await waitFor(() => {
expect(
screen.queryByRole('alert', { name: /Name is required/i })
).not.toBeInTheDocument();
});
});
});
describe('Form submission', () => {
it('should call onSubmit with form values when submitted', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderGroupForm({ onSubmit });
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'test-group');
const descriptionInput = screen.getByLabelText(/Description/i);
await user.type(descriptionInput, 'Test description');
const submitButton = screen.getByRole('button', { name: /Create/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test-group',
description: 'Test description',
tagIds: [],
associatedEnvironments: [],
}),
expect.anything()
);
});
});
it('should show loading state during submission', async () => {
const user = userEvent.setup();
// Create a promise that we can control
let resolveSubmit: () => void;
const onSubmit = vi.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveSubmit = resolve;
})
);
renderGroupForm({
onSubmit,
submitLabel: 'Create',
submitLoadingLabel: 'Creating...',
});
const nameInput = await screen.findByLabelText(/Name/i);
await user.type(nameInput, 'test-group');
const submitButton = screen.getByRole('button', { name: /Create/i });
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await user.click(submitButton);
// Should show loading state
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Creating.../i })
).toBeVisible();
});
// Resolve the submission
resolveSubmit!();
});
});
describe('Form field placeholders', () => {
it('should show correct placeholder for name field', async () => {
renderGroupForm();
const nameInput = await screen.findByPlaceholderText(/e\.g\. my-group/i);
expect(nameInput).toBeVisible();
});
it('should show correct placeholder for description field', async () => {
renderGroupForm();
const descriptionInput = await screen.findByPlaceholderText(
/e\.g\. production environments/i
);
expect(descriptionInput).toBeVisible();
});
});
describe('Initial values', () => {
it('should populate form with initial values', async () => {
renderGroupForm({
initialValues: {
name: 'pre-filled-name',
description: 'pre-filled-description',
tagIds: [],
associatedEnvironments: [],
},
});
const nameInput = await screen.findByLabelText(/Name/i);
expect(nameInput).toHaveValue('pre-filled-name');
const descriptionInput = screen.getByLabelText(/Description/i);
expect(descriptionInput).toHaveValue('pre-filled-description');
});
});
});

View File

@@ -0,0 +1,169 @@
import {
Form,
Formik,
FormikHelpers,
FormikProps,
useFormikContext,
} from 'formik';
import { object, string, array, number } from 'yup';
import { useRef } from 'react';
import { TagId } from '@/portainer/tags/types';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { useCanExit } from '@/react/hooks/useCanExit';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { FormSection } from '@@/form-components/FormSection';
import { TagSelector } from '@@/TagSelector';
import { confirmGenericDiscard } from '@@/modals/confirm';
import { FormActions } from '@@/form-components/FormActions';
import { EnvironmentGroupId, EnvironmentId } from '../../types';
import { AssociatedEnvironmentsSelector } from './AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
import { AvailableEnvironmentsTable } from './AssociatedEnvironmentsSelector/AvailableEnvironmentsTable';
export interface GroupFormValues {
name: string;
description: string;
tagIds: Array<TagId>;
associatedEnvironments: Array<EnvironmentId>;
}
interface Props {
initialValues: GroupFormValues;
/** Should return a Promise that resolves when navigation happens (to keep isSubmitting true) */
onSubmit: (
values: GroupFormValues,
helpers: FormikHelpers<GroupFormValues>
) => Promise<void>;
submitLabel: string;
submitLoadingLabel: string;
/** Group ID - if provided, shows environment selector (not for unassigned group) */
groupId?: EnvironmentGroupId;
}
const validationSchema = object({
name: string().required('Name is required'),
description: string(),
tagIds: array(number()),
associatedEnvironments: array(),
});
export function GroupForm({
initialValues,
onSubmit,
submitLabel,
submitLoadingLabel,
groupId,
}: Props) {
const formikRef = useRef<FormikProps<GroupFormValues>>(null);
useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
return (
<Formik
innerRef={formikRef}
initialValues={initialValues}
onSubmit={onSubmit}
validationSchema={validationSchema}
validateOnMount
enableReinitialize
>
<InnerForm
initialValues={initialValues}
submitLabel={submitLabel}
submitLoadingLabel={submitLoadingLabel}
groupId={groupId}
/>
</Formik>
);
}
interface InnerFormProps {
initialValues: GroupFormValues;
submitLabel: string;
submitLoadingLabel: string;
groupId?: EnvironmentGroupId;
}
function InnerForm({
initialValues,
submitLabel,
submitLoadingLabel,
groupId,
}: InnerFormProps) {
const isPureAdmin = useIsPureAdmin();
const isUnassignedGroup = groupId === 1;
const {
values,
errors,
handleChange,
setFieldValue,
isValid,
dirty,
isSubmitting,
} = useFormikContext<GroupFormValues>();
return (
<Form className="form-horizontal">
<FormControl
label="Name"
required
errors={errors.name}
inputId="group-name"
>
<Input
id="group-name"
name="name"
value={values.name}
onChange={handleChange}
placeholder="e.g. my-group"
data-cy="group-name-input"
/>
</FormControl>
<FormControl label="Description" inputId="group-description">
<Input
id="group-description"
name="description"
value={values.description}
onChange={handleChange}
placeholder="e.g. production environments..."
data-cy="group-description-input"
/>
</FormControl>
<TagSelector
value={values.tagIds}
onChange={(tagIds) => setFieldValue('tagIds', tagIds)}
allowCreate={isPureAdmin}
/>
{isUnassignedGroup ? (
<FormSection title="Unassociated environments">
<AvailableEnvironmentsTable
title="Unassociated environments"
excludeIds={[]}
data-cy="group-unassociatedEndpoints"
/>
</FormSection>
) : (
<AssociatedEnvironmentsSelector
associatedEnvironmentIds={values.associatedEnvironments}
initialAssociatedEnvironmentIds={initialValues.associatedEnvironments}
onChange={(ids) => setFieldValue('associatedEnvironments', ids)}
/>
)}
<FormActions
submitLabel={submitLabel}
loadingText={submitLoadingLabel}
isLoading={isSubmitting}
isValid={isValid && !isSubmitting && dirty}
errors={errors}
data-cy="group-submit-button"
/>
</Form>
);
}

View File

@@ -17,16 +17,23 @@ import { queryKeys } from './query-keys';
interface CreateGroupPayload {
name: string;
description?: string;
associatedEndpoints?: EnvironmentId[];
associatedEnvironments?: EnvironmentId[];
tagIds?: TagId[];
}
export async function createGroup(requestPayload: CreateGroupPayload) {
export async function createGroup({
name,
description,
associatedEnvironments,
tagIds,
}: CreateGroupPayload) {
try {
const { data: group } = await axios.post<EnvironmentGroup>(
buildUrl(),
requestPayload
);
const { data: group } = await axios.post<EnvironmentGroup>(buildUrl(), {
Name: name,
Description: description,
AssociatedEndpoints: associatedEnvironments,
TagIDs: tagIds,
});
return group;
} catch (e) {
throw parseAxiosError(e as Error, 'Failed to create group');

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { withGlobalError } from '@/react-tools/react-query';
import { EnvironmentGroupId } from '../../types';
import { getGroup } from '../environment-groups.service';
import { queryKeys } from './query-keys';
export function useGroup(id?: EnvironmentGroupId) {
return useQuery(queryKeys.group(id), () => getGroup(id!), {
enabled: !!id,
...withGlobalError('Failed to load group'),
});
}

View File

@@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
import { EnvironmentGroupId, EnvironmentId } from '../../types';
import { EnvironmentGroup } from '../types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
interface UpdateGroupPayload {
id: EnvironmentGroupId;
name: string;
description?: string;
tagIds?: Array<TagId>;
associatedEnvironments?: Array<EnvironmentId>;
}
export async function updateGroup({
id,
name,
description,
tagIds,
associatedEnvironments,
}: UpdateGroupPayload) {
try {
const { data: group } = await axios.put<EnvironmentGroup>(buildUrl(id), {
Name: name,
Description: description,
TagIDs: tagIds,
AssociatedEndpoints: associatedEnvironments,
});
return group;
} catch (e) {
throw parseAxiosError(e as Error, 'Failed to update group');
}
}
export function useUpdateGroupMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateGroup,
...withGlobalError('Failed to update group'),
...withInvalidate(queryClient, [
queryKeys.base(),
environmentQueryKeys.base(),
]),
});
}

View File

@@ -76,12 +76,18 @@ export function isUnassociatedEdgeEnvironment(env: Environment) {
export function isLocalEnvironment(environment: Environment) {
return (
environment.URL.includes('unix://') ||
environment.URL.includes('npipe://') ||
isLocalDockerEnvironment(environment.URL) ||
environment.Type === EnvironmentType.KubernetesLocal
);
}
export function isLocalDockerEnvironment(environmentUrl: string) {
return (
environmentUrl.startsWith('unix://') ||
environmentUrl.startsWith('npipe://')
);
}
export function isDockerAPIEnvironment(environment: Environment) {
return (
environment.URL.startsWith('tcp://') &&

View File

@@ -37,7 +37,10 @@ export function ItemView() {
<>
<PageHeader
title="Team details"
breadcrumbs={[{ label: 'Teams' }, { label: team.Name }]}
breadcrumbs={[
{ label: 'Teams', link: 'portainer.teams' },
{ label: team.Name },
]}
reload
/>

View File

@@ -105,6 +105,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
to="kubernetes.moreResources.jobs"
pathOptions={{
includePaths: [
'kubernetes.moreResources.jobs',
'kubernetes.moreResources.serviceAccounts',
'kubernetes.moreResources.clusterRoles',
'kubernetes.moreResources.roles',

View File

@@ -0,0 +1,392 @@
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import * as featureFlags from '@/react/portainer/feature-flags/feature-flags.service';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { PublicSettingsResponse } from '@/react/portainer/settings/types';
import { TestSidebarProvider } from './useSidebarState';
import { SettingsSidebar } from './SettingsSidebar';
vi.mock('@/react/portainer/settings/queries', () => ({
usePublicSettings: vi.fn(),
}));
describe('SettingsSidebar', () => {
beforeEach(() => {
vi.spyOn(featureFlags, 'isBE', 'get').mockReturnValue(false);
// Default mock for usePublicSettings - returns data based on selector
vi.mocked(usePublicSettings).mockImplementation(((options?: {
select?: (settings: PublicSettingsResponse) => PublicSettingsResponse;
}) => {
const settings: PublicSettingsResponse = {
TeamSync: false,
EnableEdgeComputeFeatures: false,
} as unknown as PublicSettingsResponse;
return {
data: options?.select ? options.select(settings) : settings,
isLoading: false,
isError: false,
};
}) as typeof usePublicSettings);
window.ddExtension = false;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Pure Admin User', () => {
it('should render all admin sections for pure admin', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(screen.getByText('Administration')).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-userRelated')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-environments-area')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-registries')
).toBeInTheDocument();
expect(screen.getByTestId('k8sSidebar-logs')).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-notifications')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-settings')
).toBeInTheDocument();
});
it('should render user-related submenu items', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(screen.getByTestId('portainerSidebar-users')).toBeInTheDocument();
expect(screen.getByTestId('portainerSidebar-teams')).toBeInTheDocument();
expect(screen.getByTestId('portainerSidebar-roles')).toBeInTheDocument();
});
it('should render environment-related submenu items', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.getByTestId('portainerSidebar-environments')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-environmentGroups')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-environmentTags')
).toBeInTheDocument();
});
it('should render logs submenu items', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.getByTestId('portainerSidebar-authLogs')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-activityLogs')
).toBeInTheDocument();
});
it('should render settings submenu items', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.getByTestId('portainerSidebar-generalSettings')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-authentication')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-edgeCompute')
).toBeInTheDocument();
});
it('should render Get Help link with correct CE URL', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
const helpLink = screen.getByRole('link', { name: /Get Help/i });
expect(helpLink).toBeInTheDocument();
expect(helpLink).toHaveAttribute(
'href',
'https://www.portainer.io/community_help'
);
expect(helpLink).toHaveAttribute('target', '_blank');
expect(helpLink).toHaveAttribute('rel', 'noreferrer');
});
it('should not render Licenses sidebar item in CE', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.queryByTestId('portainerSidebar-licenses')
).not.toBeInTheDocument();
});
it('should not render Shared Credentials in CE', () => {
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.queryByTestId('portainerSidebar-cloud')
).not.toBeInTheDocument();
});
});
describe('Team Leader User', () => {
it('should render user-related section for team leader when TeamSync is disabled', () => {
vi.mocked(usePublicSettings).mockReturnValue({
data: false,
isLoading: false,
isError: false,
} as ReturnType<typeof usePublicSettings>);
renderComponent({
isPureAdmin: false,
isAdmin: false,
isTeamLeader: true,
});
expect(
screen.getByTestId('portainerSidebar-userRelated')
).toBeInTheDocument();
expect(screen.getByTestId('portainerSidebar-users')).toBeInTheDocument();
expect(screen.getByTestId('portainerSidebar-teams')).toBeInTheDocument();
});
it('should not render Roles for team leader', () => {
vi.mocked(usePublicSettings).mockReturnValue({
data: false,
isLoading: false,
isError: false,
} as ReturnType<typeof usePublicSettings>);
renderComponent({
isPureAdmin: false,
isAdmin: false,
isTeamLeader: true,
});
expect(
screen.queryByTestId('portainerSidebar-roles')
).not.toBeInTheDocument();
});
it('should not render user-related section when TeamSync is enabled', () => {
vi.mocked(usePublicSettings).mockReturnValue({
data: true,
isLoading: false,
isError: false,
} as ReturnType<typeof usePublicSettings>);
renderComponent({
isPureAdmin: false,
isAdmin: false,
isTeamLeader: true,
});
expect(
screen.queryByTestId('portainerSidebar-userRelated')
).not.toBeInTheDocument();
});
it('should not render admin-only sections for team leader', () => {
renderComponent({
isPureAdmin: false,
isAdmin: false,
isTeamLeader: true,
});
expect(
screen.queryByTestId('portainerSidebar-environments-area')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('portainerSidebar-registries')
).not.toBeInTheDocument();
expect(screen.queryByTestId('k8sSidebar-logs')).not.toBeInTheDocument();
expect(
screen.queryByTestId('portainerSidebar-settings')
).not.toBeInTheDocument();
});
it('should render notifications for team leader', () => {
renderComponent({
isPureAdmin: false,
isAdmin: false,
isTeamLeader: true,
});
expect(
screen.getByTestId('portainerSidebar-notifications')
).toBeInTheDocument();
});
});
describe('Regular Admin User (not Pure Admin)', () => {
it('should not render admin-only sections', () => {
renderComponent({ isPureAdmin: false, isAdmin: true });
expect(
screen.queryByTestId('portainerSidebar-userRelated')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('portainerSidebar-environments-area')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('portainerSidebar-registries')
).not.toBeInTheDocument();
expect(screen.queryByTestId('k8sSidebar-logs')).not.toBeInTheDocument();
expect(
screen.queryByTestId('portainerSidebar-settings')
).not.toBeInTheDocument();
});
it('should render notifications for regular admin', () => {
renderComponent({ isPureAdmin: false, isAdmin: true });
expect(
screen.getByTestId('portainerSidebar-notifications')
).toBeInTheDocument();
});
});
describe('DD Extension Environment', () => {
it('should not render user-related section when ddExtension is enabled', () => {
window.ddExtension = true;
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.queryByTestId('portainerSidebar-userRelated')
).not.toBeInTheDocument();
});
it('should not render authentication settings when ddExtension is enabled', () => {
window.ddExtension = true;
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.queryByTestId('portainerSidebar-authentication')
).not.toBeInTheDocument();
});
it('should still render other admin sections when ddExtension is enabled', () => {
window.ddExtension = true;
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.getByTestId('portainerSidebar-environments-area')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-registries')
).toBeInTheDocument();
expect(
screen.getByTestId('portainerSidebar-settings')
).toBeInTheDocument();
});
});
describe('Edge Compute Features', () => {
it('should not render Update & Rollback when edge compute is disabled in CE', () => {
vi.mocked(usePublicSettings).mockImplementation(((options?: {
select?: (settings: PublicSettingsResponse) => PublicSettingsResponse;
}) => {
const settings: PublicSettingsResponse = {
TeamSync: false,
EnableEdgeComputeFeatures: false,
} as unknown as PublicSettingsResponse;
return {
data: options?.select ? options.select(settings) : settings,
isLoading: false,
isError: false,
};
}) as typeof usePublicSettings);
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.queryByTestId('portainerSidebar-updateSchedules')
).not.toBeInTheDocument();
});
it('should not render Update & Rollback in CE even when edge compute is enabled', () => {
vi.mocked(usePublicSettings).mockImplementation(((options?: {
select?: (settings: PublicSettingsResponse) => PublicSettingsResponse;
}) => {
const settings: PublicSettingsResponse = {
TeamSync: false,
EnableEdgeComputeFeatures: true,
} as unknown as PublicSettingsResponse;
return {
data: options?.select ? options.select(settings) : settings,
isLoading: false,
isError: false,
};
}) as typeof usePublicSettings);
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(
screen.queryByTestId('portainerSidebar-updateSchedules')
).not.toBeInTheDocument();
});
});
describe('Loading States', () => {
it('should handle loading state for public settings', () => {
vi.mocked(usePublicSettings).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as ReturnType<typeof usePublicSettings>);
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(screen.getByText('Administration')).toBeInTheDocument();
});
it('should handle error state for public settings', () => {
vi.mocked(usePublicSettings).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as ReturnType<typeof usePublicSettings>);
renderComponent({ isPureAdmin: true, isAdmin: true });
expect(screen.getByText('Administration')).toBeInTheDocument();
});
});
});
function renderComponent({
isPureAdmin,
isAdmin,
isTeamLeader = false,
}: {
isPureAdmin: boolean;
isAdmin: boolean;
isTeamLeader?: boolean;
}) {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withUserProvider(withTestRouter(SettingsSidebar), user);
return render(
<TestSidebarProvider>
<Wrapped
isPureAdmin={isPureAdmin}
isAdmin={isAdmin}
isTeamLeader={isTeamLeader}
/>
</TestSidebarProvider>
);
}

View File

@@ -21,7 +21,11 @@ interface Props {
isTeamLeader?: boolean;
}
export function SettingsSidebar({ isPureAdmin, isAdmin, isTeamLeader }: Props) {
export function SettingsSidebar({
isPureAdmin,
isAdmin,
isTeamLeader = false,
}: Props) {
const teamSyncQuery = usePublicSettings<boolean>({
select: (settings) => settings.TeamSync,
});

View File

@@ -1,3 +1,3 @@
{
"docker": "v29.1.2"
"docker": "v29.2.1"
}

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/portainer/portainer
go 1.25.5
go 1.25.7
replace github.com/robfig/cron/v3 => github.com/robfig/cron/v3 v3.0.1 // Not actively maintained. Pinned to last known good version. Review needed when upgrading.

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "@portainer/ce",
"homepage": "http://portainer.io",
"version": "2.38.0",
"version": "2.38.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"

View File

@@ -0,0 +1,175 @@
package authorization
import (
portainer "github.com/portainer/portainer/api"
)
// ResolvedAccess represents the result of dynamic authorization resolution.
// It contains both the computed role and its authorizations for convenience.
type ResolvedAccess struct {
Role *portainer.Role
Authorizations portainer.Authorizations
}
// ResolverInput contains all the data needed to resolve user access to an endpoint.
// This struct is used to pass data to the resolution functions without requiring
// database access, making it easier to test and allowing callers to control data fetching.
type ResolverInput struct {
User *portainer.User
Endpoint *portainer.Endpoint
EndpointGroup portainer.EndpointGroup
UserMemberships []portainer.TeamMembership
Roles []portainer.Role
}
// ComputeBaseRole computes the user's role on an endpoint from base access settings.
// It checks access in precedence order:
// 1. User → Endpoint direct access
// 2. User → Endpoint Group access (inherited)
// 3. User's Teams → Endpoint access
// 4. User's Teams → Endpoint Group access (inherited)
//
// Returns the first matching role, or nil if no access is configured.
func ComputeBaseRole(input ResolverInput) *portainer.Role {
group := input.EndpointGroup
// 1. Check user → endpoint direct access
if role := GetRoleFromUserAccessPolicies(
input.User.ID,
input.Endpoint.UserAccessPolicies,
input.Roles,
); role != nil {
return role
}
// 2. Check user → endpoint group access (inherited)
if role := GetRoleFromUserAccessPolicies(
input.User.ID,
group.UserAccessPolicies,
input.Roles,
); role != nil {
return role
}
// 3. Check user's teams → endpoint access
if role := GetRoleFromTeamAccessPolicies(
input.UserMemberships,
input.Endpoint.TeamAccessPolicies,
input.Roles,
); role != nil {
return role
}
// 4. Check user's teams → endpoint group access (inherited)
if role := GetRoleFromTeamAccessPolicies(
input.UserMemberships,
group.TeamAccessPolicies,
input.Roles,
); role != nil {
return role
}
return nil
}
// ResolveUserEndpointAccess resolves a user's effective access to an endpoint.
// In CE, this returns the base role computed from endpoint/group access settings.
// EE extends this to also consider applied RBAC policies.
//
// Returns nil if the user has no access to the endpoint.
func ResolveUserEndpointAccess(input ResolverInput) *ResolvedAccess {
role := ComputeBaseRole(input)
if role == nil {
return nil
}
return &ResolvedAccess{
Role: role,
Authorizations: role.Authorizations,
}
}
// GetRoleFromUserAccessPolicies returns the role for a user from user access policies.
// Returns nil if the user is not in the policies.
func GetRoleFromUserAccessPolicies(
userID portainer.UserID,
policies portainer.UserAccessPolicies,
roles []portainer.Role,
) *portainer.Role {
if policies == nil {
return nil
}
policy, ok := policies[userID]
if !ok {
return nil
}
return FindRoleByID(policy.RoleID, roles)
}
// GetRoleFromTeamAccessPolicies returns the highest priority role for a user
// based on their team memberships and the team access policies.
// If a user belongs to multiple teams with access, the role with highest priority wins.
// Returns nil if none of the user's teams have access.
func GetRoleFromTeamAccessPolicies(
memberships []portainer.TeamMembership,
policies portainer.TeamAccessPolicies,
roles []portainer.Role,
) *portainer.Role {
if policies == nil || len(memberships) == 0 {
return nil
}
// Collect all roles from team memberships
var matchingRoles []*portainer.Role
for _, membership := range memberships {
policy, ok := policies[membership.TeamID]
if !ok {
continue
}
role := FindRoleByID(policy.RoleID, roles)
if role != nil {
matchingRoles = append(matchingRoles, role)
}
}
if len(matchingRoles) == 0 {
return nil
}
// Return the role with highest priority
return GetHighestPriorityRole(matchingRoles)
}
// GetHighestPriorityRole returns the role with the highest priority from a slice.
// In Portainer's role system, higher priority numbers = higher priority (lower access usually gives higher priority).
// Current role priorities from highest to lowest: Read-only User (6), Standard User (5),
// Namespace Operator (4), Helpdesk (3), Operator (2), Environment Administrator (1).
// Returns nil if the slice is empty.
func GetHighestPriorityRole(roles []*portainer.Role) *portainer.Role {
if len(roles) == 0 {
return nil
}
highest := roles[0]
for _, role := range roles[1:] {
if role.Priority > highest.Priority {
highest = role
}
}
return highest
}
// FindRoleByID finds a role by its ID in a slice of roles.
// Returns nil if the role is not found.
func FindRoleByID(roleID portainer.RoleID, roles []portainer.Role) *portainer.Role {
for i := range roles {
if roles[i].ID == roleID {
return &roles[i]
}
}
return nil
}

View File

@@ -0,0 +1,405 @@
package authorization
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
// Test role fixtures
// In Portainer's role system, higher priority numbers = higher priority (more powerful).
// Order from highest to lowest: Read-only (4), Helpdesk (3), Operator (2), Admin (1).
var (
roleAdmin = portainer.Role{
ID: 1,
Name: "Environment Administrator",
Priority: 1,
Authorizations: portainer.Authorizations{"admin": true},
}
roleOperator = portainer.Role{
ID: 2,
Name: "Operator",
Priority: 2,
Authorizations: portainer.Authorizations{"operator": true},
}
roleHelpdesk = portainer.Role{
ID: 3,
Name: "Helpdesk",
Priority: 3,
Authorizations: portainer.Authorizations{"helpdesk": true},
}
roleReadOnly = portainer.Role{
ID: 4,
Name: "Read-only",
Priority: 4,
Authorizations: portainer.Authorizations{"readonly": true},
}
allRoles = []portainer.Role{roleAdmin, roleOperator, roleHelpdesk, roleReadOnly}
)
func TestComputeBaseRole_UserEndpointAccess(t *testing.T) {
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleOperator.ID},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: portainer.EndpointGroup{},
UserMemberships: []portainer.TeamMembership{},
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleOperator.ID, role.ID)
is.Equal("Operator", role.Name)
}
func TestComputeBaseRole_UserGroupAccess(t *testing.T) {
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{}, // No direct access
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleHelpdesk.ID}, // User has access via group
},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: []portainer.TeamMembership{},
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleHelpdesk.ID, role.ID)
is.Equal("Helpdesk", role.Name)
}
func TestComputeBaseRole_TeamEndpointAccess(t *testing.T) {
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{}, // No user access
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleReadOnly.ID}, // Team 100 has access
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100}, // User is in team 100
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: portainer.EndpointGroup{},
UserMemberships: memberships,
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleReadOnly.ID, role.ID)
}
func TestComputeBaseRole_TeamGroupAccess(t *testing.T) {
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{}, // No direct team access
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleOperator.ID}, // Team 100 has group access
},
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: memberships,
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleOperator.ID, role.ID)
}
func TestComputeBaseRole_Precedence(t *testing.T) {
is := assert.New(t)
t.Run("User endpoint access takes precedence over group access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleOperator.ID}, // Direct access
},
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleAdmin.ID}, // Group access (higher role, but lower precedence)
},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleOperator.ID, role.ID, "Direct endpoint access should take precedence")
})
t.Run("User access takes precedence over team access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleHelpdesk.ID},
},
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleAdmin.ID}, // Team has higher role
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
UserMemberships: memberships,
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleHelpdesk.ID, role.ID, "User access should take precedence over team access")
})
t.Run("Team endpoint access takes precedence over team group access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleReadOnly.ID}, // Direct team endpoint access
},
}
groups := []portainer.EndpointGroup{
{
ID: 10,
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleAdmin.ID}, // Team group access (higher role)
},
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: memberships,
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleReadOnly.ID, role.ID, "Team endpoint access should take precedence over team group access")
})
}
func TestComputeBaseRole_NoAccess(t *testing.T) {
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
}
groups := []portainer.EndpointGroup{
{
ID: 10,
UserAccessPolicies: portainer.UserAccessPolicies{},
TeamAccessPolicies: portainer.TeamAccessPolicies{},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: groups[0],
UserMemberships: []portainer.TeamMembership{},
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.Nil(role)
}
func TestComputeBaseRole_MultipleTeams_HighestPriorityWins(t *testing.T) {
is := assert.New(t)
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
GroupID: 1,
TeamAccessPolicies: portainer.TeamAccessPolicies{
100: {RoleID: roleReadOnly.ID}, // Highest priority (4)
200: {RoleID: roleAdmin.ID}, // Lowest priority (1)
300: {RoleID: roleOperator.ID}, // Medium priority (2)
},
}
memberships := []portainer.TeamMembership{
{UserID: 1, TeamID: 100},
{UserID: 1, TeamID: 200},
{UserID: 1, TeamID: 300},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
EndpointGroup: portainer.EndpointGroup{},
UserMemberships: memberships,
Roles: allRoles,
}
role := ComputeBaseRole(input)
is.NotNil(role)
is.Equal(roleReadOnly.ID, role.ID, "Highest priority role should be selected when user is in multiple teams")
}
func TestResolveUserEndpointAccess(t *testing.T) {
is := assert.New(t)
t.Run("Returns resolved access with role and authorizations", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{
ID: 1,
UserAccessPolicies: portainer.UserAccessPolicies{
1: {RoleID: roleOperator.ID},
},
}
input := ResolverInput{
User: user,
Endpoint: endpoint,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.NotNil(access)
is.Equal(roleOperator.ID, access.Role.ID)
is.True(access.Authorizations["operator"])
})
t.Run("Returns nil when no access", func(t *testing.T) {
user := &portainer.User{ID: 1}
endpoint := &portainer.Endpoint{ID: 1}
input := ResolverInput{
User: user,
Endpoint: endpoint,
Roles: allRoles,
}
access := ResolveUserEndpointAccess(input)
is.Nil(access)
})
}
func TestFindRoleByID(t *testing.T) {
is := assert.New(t)
t.Run("Finds existing role", func(t *testing.T) {
role := FindRoleByID(roleOperator.ID, allRoles)
is.NotNil(role)
is.Equal(roleOperator.ID, role.ID)
})
t.Run("Returns nil for non-existent role", func(t *testing.T) {
role := FindRoleByID(999, allRoles)
is.Nil(role)
})
t.Run("Returns nil for empty roles slice", func(t *testing.T) {
role := FindRoleByID(1, []portainer.Role{})
is.Nil(role)
})
}
func TestGetHighestPriorityRole(t *testing.T) {
is := assert.New(t)
t.Run("Returns nil for empty slice", func(t *testing.T) {
result := GetHighestPriorityRole([]*portainer.Role{})
is.Nil(result)
})
t.Run("Returns single role", func(t *testing.T) {
result := GetHighestPriorityRole([]*portainer.Role{&roleOperator})
is.Equal(roleOperator.ID, result.ID)
})
t.Run("Returns highest priority from multiple roles", func(t *testing.T) {
result := GetHighestPriorityRole([]*portainer.Role{&roleReadOnly, &roleAdmin, &roleOperator})
is.Equal(roleReadOnly.ID, result.ID)
})
}

View File

@@ -1,10 +1,18 @@
package options
import "time"
// UninstallOptions are portainer supported options for `helm uninstall`
type UninstallOptions struct {
Name string
Namespace string
KubernetesClusterAccess *KubernetesClusterAccess
// Wait blocks until all resources are deleted before returning (helm uninstall --wait).
// Use when a restore will be applied immediately after uninstall so resources are gone first.
Wait bool
// Timeout is how long to wait for resources to be deleted when Wait is true (default 15m).
Timeout time.Duration
Env []string
}

View File

@@ -1,10 +1,13 @@
package sdk
import (
"time"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/storage/driver"
)
// Uninstall implements the HelmPackageManager interface by using the Helm SDK to uninstall a release.
@@ -34,6 +37,12 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
uninstallClient := action.NewUninstall(actionConfig)
// 'foreground' means the parent object remains in a "terminating" state until all of its children are deleted. This ensures that all dependent resources are completely removed before finalizing the deletion of the parent resource.
uninstallClient.DeletionPropagation = "foreground" // "background" or "orphan"
uninstallClient.Wait = uninstallOpts.Wait
if uninstallOpts.Timeout == 0 {
uninstallClient.Timeout = 15 * time.Minute
} else {
uninstallClient.Timeout = uninstallOpts.Timeout
}
// Run the uninstallation
log.Info().
@@ -63,3 +72,53 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
return nil
}
// ForceRemoveRelease removes all release history (Helm secrets) without attempting
// to delete Kubernetes resources. This is a last-resort recovery mechanism for when
// a standard Uninstall fails because CRDs are missing and Helm can't build kubernetes
// objects for deletion, leaving the release stuck with no way to recover.
func (hspm *HelmSDKPackageManager) ForceRemoveRelease(uninstallOpts options.UninstallOptions) error {
if uninstallOpts.Name == "" {
return errors.New("release name is required")
}
log.Warn().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Str("namespace", uninstallOpts.Namespace).
Msg("Force-removing release history (skipping resource deletion)")
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
if err != nil {
return errors.Wrap(err, "failed to initialize helm configuration for force-remove")
}
// Get all release versions from Helm's storage (Kubernetes secrets)
versions, err := actionConfig.Releases.History(uninstallOpts.Name)
if err != nil {
if errors.Is(err, driver.ErrReleaseNotFound) {
log.Debug().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Msg("Release not found in storage, nothing to force-remove")
return nil
}
return errors.Wrap(err, "failed to get release history for force-remove")
}
// Delete each release version from storage
for _, v := range versions {
if _, err := actionConfig.Releases.Delete(v.Name, v.Version); err != nil {
return errors.Wrapf(err, "failed to delete release version %d for force-remove", v.Version)
}
}
log.Info().
Str("context", "HelmClient").
Str("release", uninstallOpts.Name).
Int("versions_removed", len(versions)).
Msg("Successfully force-removed all release history")
return nil
}

View File

@@ -110,6 +110,11 @@ func (hpm helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptio
return nil
}
// ForceRemoveRelease removes release history without deleting resources (not thread safe)
func (hpm helmMockPackageManager) ForceRemoveRelease(uninstallOpts options.UninstallOptions) error {
return hpm.Uninstall(uninstallOpts)
}
// List a helm chart (not thread safe)
func (hpm helmMockPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
return mockCharts, nil

View File

@@ -14,6 +14,10 @@ type HelmPackageManager interface {
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error
// ForceRemoveRelease removes all release history (Helm secrets) without attempting
// to delete Kubernetes resources. Use as a last resort when Uninstall fails because
// CRDs are missing and Helm can't build kubernetes objects for deletion.
ForceRemoveRelease(uninstallOpts options.UninstallOptions) error
Get(getOpts options.GetOptions) (*release.Release, error)
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error)

View File

@@ -7,6 +7,7 @@ import (
"os"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -160,8 +161,9 @@ func (c *Client) applyResource(ctx context.Context, dynamicClient dynamic.Interf
return "", fmt.Errorf("failed to marshal object to JSON: %w", err)
}
// Apply using Server-Side Apply
// This is more efficient and handles field ownership better than traditional apply
// Apply using Server-Side Apply (Patch). If the resource does not exist (404),
// fall back to Create so restoration can create Deployments and other resources
// that were removed (e.g. by Helm uninstall).
patchOptions := metav1.PatchOptions{
FieldManager: "portainer",
Force: boolPtr(true),
@@ -175,7 +177,14 @@ func (c *Client) applyResource(ctx context.Context, dynamicClient dynamic.Interf
patchOptions,
)
if err != nil {
return "", fmt.Errorf("failed to apply %s %s/%s: %w", gvk.Kind, namespace, name, err)
if apierrors.IsNotFound(err) {
_, createErr := resourceClient.Create(ctx, obj, metav1.CreateOptions{})
if createErr != nil {
return "", fmt.Errorf("failed to create %s %s/%s: %w", gvk.Kind, namespace, name, createErr)
}
} else {
return "", fmt.Errorf("failed to apply %s %s/%s: %w", gvk.Kind, namespace, name, err)
}
}
// Format output message