Compare commits
12 Commits
community
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74913e842d | ||
|
|
420488116b | ||
|
|
bb3de163a6 | ||
|
|
3cef57760b | ||
|
|
7d49f61a05 | ||
|
|
0b51ad7f01 | ||
|
|
92527f1212 | ||
|
|
1c903b35a6 | ||
|
|
8a354ceceb | ||
|
|
7519e7cb89 | ||
|
|
a33a72923d | ||
|
|
8ddd2ade8b |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.38.0
|
||||
// @version 2.38.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: '<',
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface GeneralEnvironmentFormValues {
|
||||
environmentUrl: string;
|
||||
publicUrl: string;
|
||||
|
||||
tls: TLSConfig;
|
||||
tls?: TLSConfig;
|
||||
|
||||
meta: EnvironmentMetadata;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
export type EnvironmentTableData = Pick<Environment, 'Name' | 'Id'>;
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -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://') &&
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
392
app/react/sidebar/SettingsSidebar.test.tsx
Normal file
392
app/react/sidebar/SettingsSidebar.test.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"docker": "v29.1.2"
|
||||
"docker": "v29.2.1"
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
175
pkg/authorization/resolver.go
Normal file
175
pkg/authorization/resolver.go
Normal 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
|
||||
}
|
||||
405
pkg/authorization/resolver_test.go
Normal file
405
pkg/authorization/resolver_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user