mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
feat(waiting-room): choose relations when associated endpoint [EE-5187] (#8720)
This commit is contained in:
parent
511adabce2
commit
365316971b
53 changed files with 1712 additions and 303 deletions
|
@ -28,7 +28,7 @@ func (service ServiceTx) EdgeStacks() ([]portainer.EdgeStack, error) {
|
||||||
stack, ok := obj.(*portainer.EdgeStack)
|
stack, ok := obj.(*portainer.EdgeStack)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to EdgeStack object")
|
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to EdgeStack object")
|
||||||
return nil, fmt.Errorf("Failed to convert to EdgeStack object: %s", obj)
|
return nil, fmt.Errorf("failed to convert to EdgeStack object: %s", obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
stacks = append(stacks, *stack)
|
stacks = append(stacks, *stack)
|
||||||
|
|
|
@ -30,10 +30,6 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error {
|
||||||
return errors.New("tagIDs is mandatory for a dynamic Edge group")
|
return errors.New("tagIDs is mandatory for a dynamic Edge group")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !payload.Dynamic && len(payload.Endpoints) == 0 {
|
|
||||||
return errors.New("environment is mandatory for a static Edge group")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@ func Test_EndpointList_AgentVersion(t *testing.T) {
|
||||||
noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1}
|
noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1}
|
||||||
notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1}
|
notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1}
|
||||||
|
|
||||||
handler, teardown := setup(t, []portainer.Endpoint{
|
handler, teardown := setupEndpointListHandler(t, []portainer.Endpoint{
|
||||||
notAgentEnvironments,
|
notAgentEnvironments,
|
||||||
version1Endpoint,
|
version1Endpoint,
|
||||||
version2Endpoint,
|
version2Endpoint,
|
||||||
|
@ -111,7 +111,7 @@ func Test_endpointList_edgeFilter(t *testing.T) {
|
||||||
regularTrustedEdgeStandard := portainer.Endpoint{ID: 4, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
regularTrustedEdgeStandard := portainer.Endpoint{ID: 4, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||||
regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment}
|
regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment}
|
||||||
|
|
||||||
handler, teardown := setup(t, []portainer.Endpoint{
|
handler, teardown := setupEndpointListHandler(t, []portainer.Endpoint{
|
||||||
trustedEdgeAsync,
|
trustedEdgeAsync,
|
||||||
untrustedEdgeAsync,
|
untrustedEdgeAsync,
|
||||||
regularUntrustedEdgeStandard,
|
regularUntrustedEdgeStandard,
|
||||||
|
@ -184,7 +184,7 @@ func Test_endpointList_edgeFilter(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setup(t *testing.T, endpoints []portainer.Endpoint) (handler *Handler, teardown func()) {
|
func setupEndpointListHandler(t *testing.T, endpoints []portainer.Endpoint) (handler *Handler, teardown func()) {
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
_, store, teardown := datastore.MustNewTestStore(t, true, true)
|
_, store, teardown := datastore.MustNewTestStore(t, true, true)
|
||||||
|
|
||||||
|
|
|
@ -9,9 +9,8 @@ import (
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/client"
|
"github.com/portainer/portainer/api/http/client"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
|
||||||
"github.com/portainer/portainer/api/internal/tag"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type endpointUpdatePayload struct {
|
type endpointUpdatePayload struct {
|
||||||
|
@ -120,48 +119,31 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
endpoint.EdgeCheckinInterval = *payload.EdgeCheckinInterval
|
endpoint.EdgeCheckinInterval = *payload.EdgeCheckinInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
groupIDChanged := false
|
updateRelations := false
|
||||||
|
|
||||||
if payload.GroupID != nil {
|
if payload.GroupID != nil {
|
||||||
groupID := portainer.EndpointGroupID(*payload.GroupID)
|
groupID := portainer.EndpointGroupID(*payload.GroupID)
|
||||||
groupIDChanged = groupID != endpoint.GroupID
|
|
||||||
endpoint.GroupID = groupID
|
endpoint.GroupID = groupID
|
||||||
|
updateRelations = updateRelations || groupID != endpoint.GroupID
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsChanged := false
|
|
||||||
if payload.TagIDs != nil {
|
if payload.TagIDs != nil {
|
||||||
payloadTagSet := tag.Set(payload.TagIDs)
|
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
endpointTagSet := tag.Set((endpoint.TagIDs))
|
|
||||||
union := tag.Union(payloadTagSet, endpointTagSet)
|
|
||||||
intersection := tag.Intersection(payloadTagSet, endpointTagSet)
|
|
||||||
tagsChanged = len(union) > len(intersection)
|
|
||||||
|
|
||||||
if tagsChanged {
|
tagsChanged, err := updateEnvironmentTags(tx, payload.TagIDs, endpoint.TagIDs, endpoint.ID)
|
||||||
removeTags := tag.Difference(endpointTagSet, payloadTagSet)
|
if err != nil {
|
||||||
|
return err
|
||||||
for tagID := range removeTags {
|
|
||||||
err = handler.DataStore.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) {
|
|
||||||
delete(tag.Endpoints, endpoint.ID)
|
|
||||||
})
|
|
||||||
|
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
|
||||||
return httperror.InternalServerError("Unable to find a tag inside the database", err)
|
|
||||||
} else if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to persist tag changes inside the database", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint.TagIDs = payload.TagIDs
|
endpoint.TagIDs = payload.TagIDs
|
||||||
for _, tagID := range payload.TagIDs {
|
updateRelations = updateRelations || tagsChanged
|
||||||
err = handler.DataStore.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) {
|
|
||||||
tag.Endpoints[endpoint.ID] = true
|
|
||||||
})
|
|
||||||
|
|
||||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
return nil
|
||||||
return httperror.InternalServerError("Unable to find a tag inside the database", err)
|
})
|
||||||
} else if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to persist tag changes inside the database", err)
|
if err != nil {
|
||||||
}
|
httperror.InternalServerError("Unable to update environment tags", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -286,39 +268,13 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
return httperror.InternalServerError("Unable to persist environment changes inside the database", err)
|
return httperror.InternalServerError("Unable to persist environment changes inside the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) && (groupIDChanged || tagsChanged) {
|
if updateRelations {
|
||||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
|
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
return handler.updateEdgeRelations(tx, endpoint)
|
||||||
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to find environment relation inside the database", err)
|
return httperror.InternalServerError("Unable to update environment relations", err)
|
||||||
}
|
|
||||||
|
|
||||||
endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to find environment group inside the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve edge groups from the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentEdgeStackSet := map[portainer.EdgeStackID]bool{}
|
|
||||||
|
|
||||||
endpointEdgeStacks := edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks)
|
|
||||||
for _, edgeStackID := range endpointEdgeStacks {
|
|
||||||
currentEdgeStackSet[edgeStackID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
relation.EdgeStacks = currentEdgeStackSet
|
|
||||||
|
|
||||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return httperror.InternalServerError("Unable to persist environment relation changes inside the database", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
110
api/http/handler/endpoints/endpoint_update_relations.go
Normal file
110
api/http/handler/endpoints/endpoint_update_relations.go
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type endpointUpdateRelationsPayload struct {
|
||||||
|
Relations map[portainer.EndpointID]struct {
|
||||||
|
EdgeGroups []portainer.EdgeGroupID
|
||||||
|
Tags []portainer.TagID
|
||||||
|
Group portainer.EndpointGroupID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *endpointUpdateRelationsPayload) Validate(r *http.Request) error {
|
||||||
|
for eID := range payload.Relations {
|
||||||
|
if eID == 0 {
|
||||||
|
return errors.New("Missing environment identifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// @id EndpointUpdateRelations
|
||||||
|
// @summary Update relations for a list of environments
|
||||||
|
// @description Update relations for a list of environments
|
||||||
|
// @description Edge groups, tags and environment group can be updated.
|
||||||
|
// @description
|
||||||
|
// @description **Access policy**: administrator
|
||||||
|
// @tags endpoints
|
||||||
|
// @security jwt
|
||||||
|
// @accept json
|
||||||
|
// @param body body endpointUpdateRelationsPayload true "Environment relations data"
|
||||||
|
// @success 204 "Success"
|
||||||
|
// @failure 400 "Invalid request"
|
||||||
|
// @failure 401 "Unauthorized"
|
||||||
|
// @failure 404 "Not found"
|
||||||
|
// @failure 500 "Server error"
|
||||||
|
// @router /endpoints/relations [put]
|
||||||
|
func (handler *Handler) updateRelations(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
|
||||||
|
payload, err := request.GetPayload[endpointUpdateRelationsPayload](r)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest("Invalid request payload", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
for environmentID, relationPayload := range payload.Relations {
|
||||||
|
endpoint, err := tx.Endpoint().Endpoint(environmentID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to find an environment with the specified identifier inside the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRelations := false
|
||||||
|
|
||||||
|
if relationPayload.Group != 0 {
|
||||||
|
groupIDChanged := endpoint.GroupID != relationPayload.Group
|
||||||
|
endpoint.GroupID = relationPayload.Group
|
||||||
|
updateRelations = updateRelations || groupIDChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
if relationPayload.Tags != nil {
|
||||||
|
tagsChanged, err := updateEnvironmentTags(tx, relationPayload.Tags, endpoint.TagIDs, endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to update environment tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint.TagIDs = relationPayload.Tags
|
||||||
|
updateRelations = updateRelations || tagsChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
if relationPayload.EdgeGroups != nil {
|
||||||
|
edgeGroupsChanged, err := updateEnvironmentEdgeGroups(tx, relationPayload.EdgeGroups, endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to update environment edge groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRelations = updateRelations || edgeGroupsChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateRelations {
|
||||||
|
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to update environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.updateEdgeRelations(tx, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to update environment relations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return httperror.InternalServerError("Unable to update environment relations", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
|
@ -69,6 +69,7 @@ func NewHandler(bouncer requestBouncer, demoService *demo.Service) *Handler {
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
||||||
h.Handle("/endpoints/agent_versions",
|
h.Handle("/endpoints/agent_versions",
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/endpoints/relations", bouncer.RestrictedAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
|
||||||
|
|
||||||
h.Handle("/endpoints/{id}",
|
h.Handle("/endpoints/{id}",
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
||||||
|
|
48
api/http/handler/endpoints/update_edge_relations.go
Normal file
48
api/http/handler/endpoints/update_edge_relations.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
"github.com/portainer/portainer/api/internal/set"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateEdgeRelations updates the edge stacks associated to an edge endpoint
|
||||||
|
func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||||
|
if !endpointutils.IsEdgeEndpoint(endpoint) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to find environment relation inside the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointGroup, err := tx.EndpointGroup().EndpointGroup(endpoint.GroupID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to find environment group inside the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeGroups, err := tx.EdgeGroup().EdgeGroups()
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to retrieve edge groups from the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeStacks, err := tx.EdgeStack().EdgeStacks()
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to retrieve edge stacks from the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEdgeStackSet := set.ToSet(edge.EndpointRelatedEdgeStacks(endpoint, endpointGroup, edgeGroups, edgeStacks))
|
||||||
|
|
||||||
|
relation.EdgeStacks = currentEdgeStackSet
|
||||||
|
|
||||||
|
err = tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to persist environment relation changes inside the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
72
api/http/handler/endpoints/utils_update_edge_groups.go
Normal file
72
api/http/handler/endpoints/utils_update_edge_groups.go
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/internal/set"
|
||||||
|
"github.com/portainer/portainer/api/internal/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) {
|
||||||
|
edgeGroups, err := tx.EdgeGroup().EdgeGroups()
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.WithMessage(err, "Unable to retrieve edge groups from the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
newEdgeGroupsSet := set.ToSet(newEdgeGroups)
|
||||||
|
|
||||||
|
environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{}
|
||||||
|
for _, edgeGroup := range edgeGroups {
|
||||||
|
for _, eID := range edgeGroup.Endpoints {
|
||||||
|
if eID == environmentID {
|
||||||
|
environmentEdgeGroupsSet[edgeGroup.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
union := set.Union(newEdgeGroupsSet, environmentEdgeGroupsSet)
|
||||||
|
intersection := set.Intersection(newEdgeGroupsSet, environmentEdgeGroupsSet)
|
||||||
|
|
||||||
|
if len(union) <= len(intersection) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSet := func(groupIDs set.Set[portainer.EdgeGroupID], updateItem func(*portainer.EdgeGroup)) error {
|
||||||
|
for groupID := range groupIDs {
|
||||||
|
group, err := tx.EdgeGroup().EdgeGroup(groupID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to find a Edge group inside the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItem(group)
|
||||||
|
|
||||||
|
err = tx.EdgeGroup().UpdateEdgeGroup(groupID, group)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to persist Edge group changes inside the database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet)
|
||||||
|
err = updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
|
||||||
|
edgeGroup.Endpoints = slices.RemoveItem(edgeGroup.Endpoints, func(eID portainer.EndpointID) bool {
|
||||||
|
return eID == environmentID
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet)
|
||||||
|
err = updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) {
|
||||||
|
edgeGroup.Endpoints = append(edgeGroup.Endpoints, environmentID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
156
api/http/handler/endpoints/utils_update_edge_groups_test.go
Normal file
156
api/http/handler/endpoints/utils_update_edge_groups_test.go
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_updateEdgeGroups(t *testing.T) {
|
||||||
|
|
||||||
|
createGroups := func(store *datastore.Store, names []string) ([]portainer.EdgeGroup, error) {
|
||||||
|
groups := make([]portainer.EdgeGroup, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
group := &portainer.EdgeGroup{
|
||||||
|
Name: name,
|
||||||
|
Dynamic: false,
|
||||||
|
TagIDs: make([]portainer.TagID, 0),
|
||||||
|
Endpoints: make([]portainer.EndpointID, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.EdgeGroup().Create(group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[index] = *group
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
checkGroups := func(store *datastore.Store, is *assert.Assertions, groupIDs []portainer.EdgeGroupID, endpointID portainer.EndpointID) {
|
||||||
|
for _, groupID := range groupIDs {
|
||||||
|
group, err := store.EdgeGroup().EdgeGroup(groupID)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
for _, endpoint := range group.Endpoints {
|
||||||
|
if endpoint == endpointID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is.Fail("expected endpoint to be in group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupsByName := func(groups []portainer.EdgeGroup, groupNames []string) []portainer.EdgeGroup {
|
||||||
|
result := make([]portainer.EdgeGroup, len(groupNames))
|
||||||
|
for i, tagName := range groupNames {
|
||||||
|
for j, tag := range groups {
|
||||||
|
if tag.Name == tagName {
|
||||||
|
result[i] = groups[j]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
title string
|
||||||
|
endpoint *portainer.Endpoint
|
||||||
|
groupNames []string
|
||||||
|
endpointGroupNames []string
|
||||||
|
groupsToApply []string
|
||||||
|
shouldNotBeUpdated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
testFn := func(t *testing.T, testCase testCase) {
|
||||||
|
|
||||||
|
is := assert.New(t)
|
||||||
|
_, store, teardown := datastore.MustNewTestStore(t, true, true)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
err := store.Endpoint().Create(testCase.endpoint)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
groups, err := createGroups(store, testCase.groupNames)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
endpointGroups := groupsByName(groups, testCase.endpointGroupNames)
|
||||||
|
for _, group := range endpointGroups {
|
||||||
|
group.Endpoints = append(group.Endpoints, testCase.endpoint.ID)
|
||||||
|
|
||||||
|
err = store.EdgeGroup().UpdateEdgeGroup(group.ID, &group)
|
||||||
|
is.NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedGroups := groupsByName(groups, testCase.groupsToApply)
|
||||||
|
expectedIDs := make([]portainer.EdgeGroupID, len(expectedGroups))
|
||||||
|
for i, tag := range expectedGroups {
|
||||||
|
expectedIDs[i] = tag.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
updated, err := updateEnvironmentEdgeGroups(tx, expectedIDs, testCase.endpoint.ID)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
is.Equal(testCase.shouldNotBeUpdated, !updated)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
checkGroups(store, is, expectedIDs, testCase.endpoint.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []testCase{
|
||||||
|
{
|
||||||
|
title: "applying edge groups to an endpoint without edge groups",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
groupNames: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
endpointGroupNames: []string{},
|
||||||
|
groupsToApply: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "applying edge groups to an endpoint with edge groups",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
groupNames: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"},
|
||||||
|
endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
groupsToApply: []string{"edge group4", "edge group5", "edge group6"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "applying edge groups to an endpoint with edge groups that are already applied",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
groupNames: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
groupsToApply: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
shouldNotBeUpdated: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "adding new edge groups to an endpoint with edge groups ",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
groupNames: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"},
|
||||||
|
endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
groupsToApply: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "mixing edge groups that are already applied and new edge groups",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
groupNames: []string{"edge group1", "edge group2", "edge group3", "edge group4", "edge group5", "edge group6"},
|
||||||
|
endpointGroupNames: []string{"edge group1", "edge group2", "edge group3"},
|
||||||
|
groupsToApply: []string{"edge group2", "edge group4", "edge group5"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.title, func(t *testing.T) {
|
||||||
|
testFn(t, testCase)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
56
api/http/handler/endpoints/utils_update_tags.go
Normal file
56
api/http/handler/endpoints/utils_update_tags.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/internal/set"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateEnvironmentTags updates the tags associated to an environment
|
||||||
|
func updateEnvironmentTags(tx dataservices.DataStoreTx, newTags []portainer.TagID, oldTags []portainer.TagID, environmentID portainer.EndpointID) (bool, error) {
|
||||||
|
payloadTagSet := set.ToSet(newTags)
|
||||||
|
environmentTagSet := set.ToSet(oldTags)
|
||||||
|
union := set.Union(payloadTagSet, environmentTagSet)
|
||||||
|
intersection := set.Intersection(payloadTagSet, environmentTagSet)
|
||||||
|
|
||||||
|
if len(union) <= len(intersection) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSet := func(tagIDs set.Set[portainer.TagID], updateItem func(*portainer.Tag)) error {
|
||||||
|
for tagID := range tagIDs {
|
||||||
|
tag, err := tx.Tag().Tag(tagID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to find a tag inside the database")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItem(tag)
|
||||||
|
|
||||||
|
err = tx.Tag().UpdateTag(tagID, tag)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithMessage(err, "Unable to persist tag changes inside the database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTags := environmentTagSet.Difference(payloadTagSet)
|
||||||
|
err := updateSet(removeTags, func(tag *portainer.Tag) {
|
||||||
|
delete(tag.Endpoints, environmentID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
addTags := payloadTagSet.Difference(environmentTagSet)
|
||||||
|
err = updateSet(addTags, func(tag *portainer.Tag) {
|
||||||
|
tag.Endpoints[environmentID] = true
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
165
api/http/handler/endpoints/utils_update_tags_test.go
Normal file
165
api/http/handler/endpoints/utils_update_tags_test.go
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_updateTags(t *testing.T) {
|
||||||
|
|
||||||
|
createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) {
|
||||||
|
tags := make([]portainer.Tag, len(tagNames))
|
||||||
|
for index, tagName := range tagNames {
|
||||||
|
tag := &portainer.Tag{
|
||||||
|
Name: tagName,
|
||||||
|
Endpoints: make(map[portainer.EndpointID]bool),
|
||||||
|
EndpointGroups: make(map[portainer.EndpointGroupID]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.Tag().Create(tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags[index] = *tag
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTags := func(store *datastore.Store, is *assert.Assertions, tagIDs []portainer.TagID, endpointID portainer.EndpointID) {
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
tag, err := store.Tag().Tag(tagID)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
_, ok := tag.Endpoints[endpointID]
|
||||||
|
is.True(ok, "expected endpoint to be tagged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsByName := func(tags []portainer.Tag, tagNames []string) []portainer.Tag {
|
||||||
|
result := make([]portainer.Tag, len(tagNames))
|
||||||
|
for i, tagName := range tagNames {
|
||||||
|
for j, tag := range tags {
|
||||||
|
if tag.Name == tagName {
|
||||||
|
result[i] = tags[j]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
getIDs := func(tags []portainer.Tag) []portainer.TagID {
|
||||||
|
ids := make([]portainer.TagID, len(tags))
|
||||||
|
for i, tag := range tags {
|
||||||
|
ids[i] = tag.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
title string
|
||||||
|
endpoint *portainer.Endpoint
|
||||||
|
tagNames []string
|
||||||
|
endpointTagNames []string
|
||||||
|
tagsToApply []string
|
||||||
|
shouldNotBeUpdated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
testFn := func(t *testing.T, testCase testCase) {
|
||||||
|
|
||||||
|
is := assert.New(t)
|
||||||
|
_, store, teardown := datastore.MustNewTestStore(t, true, true)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
err := store.Endpoint().Create(testCase.endpoint)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
tags, err := createTags(store, testCase.tagNames)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
endpointTags := tagsByName(tags, testCase.endpointTagNames)
|
||||||
|
for _, tag := range endpointTags {
|
||||||
|
tag.Endpoints[testCase.endpoint.ID] = true
|
||||||
|
|
||||||
|
err = store.Tag().UpdateTag(tag.ID, &tag)
|
||||||
|
is.NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointTagIDs := getIDs(endpointTags)
|
||||||
|
testCase.endpoint.TagIDs = endpointTagIDs
|
||||||
|
err = store.Endpoint().UpdateEndpoint(testCase.endpoint.ID, testCase.endpoint)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
expectedTags := tagsByName(tags, testCase.tagsToApply)
|
||||||
|
expectedTagIDs := make([]portainer.TagID, len(expectedTags))
|
||||||
|
for i, tag := range expectedTags {
|
||||||
|
expectedTagIDs[i] = tag.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
updated, err := updateEnvironmentTags(tx, expectedTagIDs, testCase.endpoint.TagIDs, testCase.endpoint.ID)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
is.Equal(testCase.shouldNotBeUpdated, !updated)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
checkTags(store, is, expectedTagIDs, testCase.endpoint.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []testCase{
|
||||||
|
{
|
||||||
|
title: "applying tags to an endpoint without tags",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
tagNames: []string{"tag1", "tag2", "tag3"},
|
||||||
|
endpointTagNames: []string{},
|
||||||
|
tagsToApply: []string{"tag1", "tag2", "tag3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "applying tags to an endpoint with tags",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
tagNames: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"},
|
||||||
|
endpointTagNames: []string{"tag1", "tag2", "tag3"},
|
||||||
|
tagsToApply: []string{"tag4", "tag5", "tag6"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "applying tags to an endpoint with tags that are already applied",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
tagNames: []string{"tag1", "tag2", "tag3"},
|
||||||
|
endpointTagNames: []string{"tag1", "tag2", "tag3"},
|
||||||
|
tagsToApply: []string{"tag1", "tag2", "tag3"},
|
||||||
|
shouldNotBeUpdated: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "adding new tags to an endpoint with tags ",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
tagNames: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"},
|
||||||
|
endpointTagNames: []string{"tag1", "tag2", "tag3"},
|
||||||
|
tagsToApply: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "mixing tags that are already applied and new tags",
|
||||||
|
endpoint: &portainer.Endpoint{},
|
||||||
|
tagNames: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"},
|
||||||
|
endpointTagNames: []string{"tag1", "tag2", "tag3"},
|
||||||
|
tagsToApply: []string{"tag2", "tag4", "tag5"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.title, func(t *testing.T) {
|
||||||
|
testFn(t, testCase)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,27 +6,33 @@ type SetKey interface {
|
||||||
|
|
||||||
type Set[T SetKey] map[T]bool
|
type Set[T SetKey] map[T]bool
|
||||||
|
|
||||||
|
// Add adds a key to the set.
|
||||||
func (s Set[T]) Add(key T) {
|
func (s Set[T]) Add(key T) {
|
||||||
s[key] = true
|
s[key] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contains returns true if the set contains the key.
|
||||||
func (s Set[T]) Contains(key T) bool {
|
func (s Set[T]) Contains(key T) bool {
|
||||||
_, ok := s[key]
|
_, ok := s[key]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove removes a key from the set.
|
||||||
func (s Set[T]) Remove(key T) {
|
func (s Set[T]) Remove(key T) {
|
||||||
delete(s, key)
|
delete(s, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Len returns the number of keys in the set.
|
||||||
func (s Set[T]) Len() int {
|
func (s Set[T]) Len() int {
|
||||||
return len(s)
|
return len(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if the set is empty.
|
||||||
func (s Set[T]) IsEmpty() bool {
|
func (s Set[T]) IsEmpty() bool {
|
||||||
return len(s) == 0
|
return len(s) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear removes all keys from the set.
|
||||||
func (s Set[T]) Keys() []T {
|
func (s Set[T]) Keys() []T {
|
||||||
keys := make([]T, s.Len())
|
keys := make([]T, s.Len())
|
||||||
|
|
||||||
|
@ -38,3 +44,67 @@ func (s Set[T]) Keys() []T {
|
||||||
|
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear removes all keys from the set.
|
||||||
|
func (s Set[T]) Copy() Set[T] {
|
||||||
|
copy := make(Set[T])
|
||||||
|
|
||||||
|
for key := range s {
|
||||||
|
copy.Add(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difference returns a new set containing the keys that are in the first set but not in the second set.
|
||||||
|
func (set Set[T]) Difference(second Set[T]) Set[T] {
|
||||||
|
|
||||||
|
difference := set.Copy()
|
||||||
|
|
||||||
|
for key := range second {
|
||||||
|
difference.Remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return difference
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union returns a new set containing the keys that are in either set.
|
||||||
|
func Union[T SetKey](sets ...Set[T]) Set[T] {
|
||||||
|
union := make(Set[T])
|
||||||
|
|
||||||
|
for _, set := range sets {
|
||||||
|
for key := range set {
|
||||||
|
union.Add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return union
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersection returns a new set containing the keys that are in all sets.
|
||||||
|
func Intersection[T SetKey](sets ...Set[T]) Set[T] {
|
||||||
|
if len(sets) == 0 {
|
||||||
|
return make(Set[T])
|
||||||
|
}
|
||||||
|
|
||||||
|
intersection := sets[0].Copy()
|
||||||
|
|
||||||
|
for _, set := range sets[1:] {
|
||||||
|
for key := range intersection {
|
||||||
|
if !set.Contains(key) {
|
||||||
|
intersection.Remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSet returns a new set containing the keys.
|
||||||
|
func ToSet[T SetKey](keys []T) Set[T] {
|
||||||
|
set := make(Set[T])
|
||||||
|
for _, key := range keys {
|
||||||
|
set.Add(key)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
|
@ -20,3 +20,27 @@ func IndexFunc[E any](s []E, f func(E) bool) int {
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveItem removes the first element from the slice that satisfies the given predicate
|
||||||
|
func RemoveItem[E comparable](s []E, predicate func(E) bool) []E {
|
||||||
|
index := IndexFunc(s, predicate)
|
||||||
|
if index == -1 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemoveIndex(s, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveIndex removes the element at the given index from the slice
|
||||||
|
func RemoveIndex[T any](s []T, index int) []T {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
if index < 0 || index >= len(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
s[index] = s[len(s)-1]
|
||||||
|
return s[:len(s)-1]
|
||||||
|
}
|
||||||
|
|
|
@ -9,3 +9,15 @@ export function promiseSequence<T>(promises: (() => Promise<T>)[]) {
|
||||||
Promise.resolve<T>(undefined as unknown as T)
|
Promise.resolve<T>(undefined as unknown as T)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFulfilled<T>(
|
||||||
|
result: PromiseSettledResult<T>
|
||||||
|
): result is PromiseFulfilledResult<T> {
|
||||||
|
return result.status === 'fulfilled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFulfilledResults<T>(
|
||||||
|
results: Array<PromiseSettledResult<T>>
|
||||||
|
) {
|
||||||
|
return results.filter(isFulfilled).map((result) => result.value);
|
||||||
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { createTag, getTags } from './tags.service';
|
import { createTag, getTags } from './tags.service';
|
||||||
import { Tag, TagId } from './types';
|
import { Tag, TagId } from './types';
|
||||||
|
|
||||||
const tagKeys = {
|
export const tagKeys = {
|
||||||
all: ['tags'] as const,
|
all: ['tags'] as const,
|
||||||
tag: (id: TagId) => [...tagKeys.all, id] as const,
|
tag: (id: TagId) => [...tagKeys.all, id] as const,
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,8 +19,9 @@ export function TextTip({
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx('small inline-flex items-center gap-1', className)}>
|
<div className={clsx('small inline-flex gap-1', className)}>
|
||||||
<Icon icon={icon} mode={getMode(color)} className="shrink-0" />
|
<Icon icon={icon} mode={getMode(color)} className="!mt-[2px]" />
|
||||||
|
|
||||||
<span className="text-muted">{children}</span>
|
<span className="text-muted">{children}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useRef,
|
useRef,
|
||||||
|
@ -16,11 +17,21 @@ interface Props extends HTMLProps<HTMLInputElement> {
|
||||||
className?: string;
|
className?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
bold?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||||
(
|
(
|
||||||
{ indeterminate, title, label, id, checked, onChange, ...props }: Props,
|
{
|
||||||
|
indeterminate,
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
bold = true,
|
||||||
|
...props
|
||||||
|
}: Props,
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const defaultRef = useRef<HTMLInputElement>(null);
|
const defaultRef = useRef<HTMLInputElement>(null);
|
||||||
|
@ -50,7 +61,9 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={id}>{label}</label>
|
<label htmlFor={id} className={clsx({ '!font-normal': !bold })}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { PropsWithChildren, useState } from 'react';
|
import { PropsWithChildren, ReactNode, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
|
@ -6,7 +6,7 @@ import { Icon } from '@@/Icon';
|
||||||
import { FormSectionTitle } from '../FormSectionTitle';
|
import { FormSectionTitle } from '../FormSectionTitle';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: ReactNode;
|
||||||
isFoldable?: boolean;
|
isFoldable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,9 @@ import { OnSubmit } from './Modal/types';
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
export async function openModal<TProps, TResult>(
|
export async function openModal<TProps, TResult>(
|
||||||
Modal: ComponentType<{ onSubmit: OnSubmit<TResult> } & TProps>,
|
Modal: ComponentType<
|
||||||
|
{ onSubmit: OnSubmit<TResult> } & Omit<TProps, 'onSubmit'>
|
||||||
|
>,
|
||||||
props: TProps = {} as TProps
|
props: TProps = {} as TProps
|
||||||
) {
|
) {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
|
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
|
||||||
|
import { addPlural } from '@/portainer/helpers/strings';
|
||||||
|
import { useUpdateEnvironmentsRelationsMutation } from '@/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { OnSubmit, Modal } from '@@/modals';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { Button, LoadingButton } from '@@/buttons';
|
||||||
|
|
||||||
|
import { WaitingRoomEnvironment } from '../../types';
|
||||||
|
|
||||||
|
import { GroupSelector, EdgeGroupsSelector, TagSelector } from './Selectors';
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { isAssignedToGroup } from './utils';
|
||||||
|
import { createPayload } from './createPayload';
|
||||||
|
|
||||||
|
export function AssignmentDialog({
|
||||||
|
onSubmit,
|
||||||
|
environments,
|
||||||
|
}: {
|
||||||
|
onSubmit: OnSubmit<boolean>;
|
||||||
|
environments: Array<WaitingRoomEnvironment>;
|
||||||
|
}) {
|
||||||
|
const assignRelationsMutation = useUpdateEnvironmentsRelationsMutation();
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
group: 1,
|
||||||
|
overrideGroup: false,
|
||||||
|
edgeGroups: [],
|
||||||
|
overrideEdgeGroups: false,
|
||||||
|
tags: [],
|
||||||
|
overrideTags: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPreAssignedEdgeGroups = environments.some(
|
||||||
|
(e) => e.EdgeGroups?.length > 0
|
||||||
|
);
|
||||||
|
const hasPreAssignedTags = environments.some((e) => e.TagIds.length > 0);
|
||||||
|
const hasPreAssignedGroup = environments.some((e) => isAssignedToGroup(e));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
aria-label="Associate and assignment"
|
||||||
|
onDismiss={() => onSubmit()}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Modal.Header
|
||||||
|
title={`Associate with assignment (${addPlural(
|
||||||
|
environments.length,
|
||||||
|
'selected edge environment'
|
||||||
|
)})`}
|
||||||
|
/>
|
||||||
|
<Formik onSubmit={handleSubmit} initialValues={initialValues}>
|
||||||
|
{({ values, setFieldValue, errors }) => (
|
||||||
|
<Form noValidate>
|
||||||
|
<Modal.Body>
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
size="vertical"
|
||||||
|
label="Group"
|
||||||
|
tooltip="For managing RBAC with user access"
|
||||||
|
errors={errors.group}
|
||||||
|
>
|
||||||
|
<GroupSelector />
|
||||||
|
|
||||||
|
{hasPreAssignedGroup && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Checkbox
|
||||||
|
label="Override pre-assigned group"
|
||||||
|
id="overrideGroup"
|
||||||
|
bold={false}
|
||||||
|
checked={values.overrideGroup}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue('overrideGroup', e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
size="vertical"
|
||||||
|
label="Edge Groups"
|
||||||
|
tooltip="Required to manage edge job and edge stack deployments"
|
||||||
|
errors={errors.edgeGroups}
|
||||||
|
>
|
||||||
|
<EdgeGroupsSelector />
|
||||||
|
|
||||||
|
{hasPreAssignedEdgeGroups && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Checkbox
|
||||||
|
label="Override pre-assigned edge groups"
|
||||||
|
bold={false}
|
||||||
|
id="overrideEdgeGroups"
|
||||||
|
checked={values.overrideEdgeGroups}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue('overrideEdgeGroups', e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Edge group(s) created here are static only, use tags to
|
||||||
|
assign to dynamic edge groups
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
size="vertical"
|
||||||
|
label="Tags"
|
||||||
|
tooltip="Assigning tags will auto populate environments to dynamic edge groups that these tags are assigned to and any ege jobs or stacks that are deployed to that edge group"
|
||||||
|
errors={errors.tags}
|
||||||
|
>
|
||||||
|
<TagSelector />
|
||||||
|
|
||||||
|
{hasPreAssignedTags && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Checkbox
|
||||||
|
label="Override pre-assigned tags"
|
||||||
|
bold={false}
|
||||||
|
id="overrideTags"
|
||||||
|
checked={values.overrideTags}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFieldValue('overrideTags', e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button onClick={() => onSubmit()} color="default">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
isLoading={assignRelationsMutation.isLoading}
|
||||||
|
loadingText="Associating..."
|
||||||
|
>
|
||||||
|
Associate
|
||||||
|
</LoadingButton>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit(values: FormValues) {
|
||||||
|
assignRelationsMutation.mutate(
|
||||||
|
Object.fromEntries(environments.map((e) => createPayload(e, values))),
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess('Success', 'Edge environments assigned successfully');
|
||||||
|
onSubmit(true);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
|
import {
|
||||||
|
Option,
|
||||||
|
Option as OptionType,
|
||||||
|
} from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
|
export function CreatableSelector({
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
onCreate,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
options: Array<OptionType<number>>;
|
||||||
|
onCreate: (label: string) => Promise<number>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const [{ onBlur, value }, , { setValue }] = useField<Array<number>>(name);
|
||||||
|
|
||||||
|
const selectedValues = value.reduce(
|
||||||
|
(acc: Array<OptionType<number>>, cur) =>
|
||||||
|
_.compact([...acc, findOption(cur, options)]),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
isCreatable
|
||||||
|
options={options}
|
||||||
|
value={
|
||||||
|
isLoading
|
||||||
|
? [...selectedValues, { label: 'Creating...', value: 0 }]
|
||||||
|
: selectedValues
|
||||||
|
}
|
||||||
|
isMulti
|
||||||
|
onCreateOption={handleCreate}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
closeMenuOnSelect={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleCreate(label: string) {
|
||||||
|
const id = await onCreate(label);
|
||||||
|
setValue([...value, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(value: ReadonlyArray<{ value: number }>) {
|
||||||
|
setValue(value.map((v) => v.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOption<T>(option: T, options: Array<Option<T>>) {
|
||||||
|
return options.find((t) => t.value === option);
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useCreateGroupMutation } from '@/react/edge/edge-groups/queries/useCreateEdgeGroupMutation';
|
||||||
|
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||||
|
|
||||||
|
import { CreatableSelector } from './CreatableSelector';
|
||||||
|
|
||||||
|
export function EdgeGroupsSelector() {
|
||||||
|
const createMutation = useCreateGroupMutation();
|
||||||
|
|
||||||
|
const edgeGroupsQuery = useEdgeGroups({
|
||||||
|
select: (edgeGroups) =>
|
||||||
|
edgeGroups
|
||||||
|
.filter((g) => !g.Dynamic)
|
||||||
|
.map((opt) => ({ label: opt.Name, value: opt.Id })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!edgeGroupsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeGroups = edgeGroupsQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelector
|
||||||
|
name="edgeGroups"
|
||||||
|
options={edgeGroups}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
isLoading={createMutation.isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleCreate(newGroup: string) {
|
||||||
|
const group = await createMutation.mutateAsync({
|
||||||
|
name: newGroup,
|
||||||
|
dynamic: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySuccess('Edge group created', `Group ${group.Name} created`);
|
||||||
|
return group.Id;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||||
|
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
|
||||||
|
import { useCreateGroupMutation } from '@/react/portainer/environments/environment-groups/queries/useCreateGroupMutation';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
|
import { Option } from '@@/form-components/PortainerSelect';
|
||||||
|
|
||||||
|
import { FormValues } from '../types';
|
||||||
|
|
||||||
|
export function GroupSelector() {
|
||||||
|
const [{ value, onBlur }, , { setValue }] =
|
||||||
|
useField<FormValues['group']>('group');
|
||||||
|
const createMutation = useCreateGroupMutation();
|
||||||
|
|
||||||
|
const groupsQuery = useGroups({
|
||||||
|
select: (groups) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.Id !== 1)
|
||||||
|
.map((opt) => ({ label: opt.Name, value: opt.Id })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { onInputChange, clearInputValue } = useCreateOnBlur({
|
||||||
|
options: groupsQuery.data || [],
|
||||||
|
setValue,
|
||||||
|
createValue: handleCreate,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!groupsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = groupsQuery.data;
|
||||||
|
const selectedValue = value ? options.find((g) => g.value === value) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
isCreatable
|
||||||
|
options={options}
|
||||||
|
value={
|
||||||
|
createMutation.isLoading
|
||||||
|
? { label: 'Creating...', value: 0 }
|
||||||
|
: selectedValue
|
||||||
|
}
|
||||||
|
onCreateOption={handleCreate}
|
||||||
|
onChange={handleChange}
|
||||||
|
onInputChange={onInputChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
isLoading={createMutation.isLoading}
|
||||||
|
isDisabled={createMutation.isLoading}
|
||||||
|
placeholder="Select a group"
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleCreate(newGroup: string) {
|
||||||
|
createMutation.mutate(
|
||||||
|
{ name: newGroup },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setValue(data.Id);
|
||||||
|
notifySuccess('Group created', `Group ${data.Name} created`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(value: { value: EnvironmentGroupId } | null) {
|
||||||
|
setValue(value ? value.value : 1);
|
||||||
|
clearInputValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCreateOnBlur({
|
||||||
|
options,
|
||||||
|
setValue,
|
||||||
|
createValue,
|
||||||
|
}: {
|
||||||
|
options: Option<number>[];
|
||||||
|
setValue: (value: number) => void;
|
||||||
|
createValue: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
const label = inputValue?.trim() || '';
|
||||||
|
if (!label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = options.find((opt) => opt.label === label);
|
||||||
|
if (option) {
|
||||||
|
setValue(option.value);
|
||||||
|
} else {
|
||||||
|
createValue(label);
|
||||||
|
}
|
||||||
|
setInputValue('');
|
||||||
|
}, [createValue, inputValue, options, setValue]);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(inputValue, { action }) => {
|
||||||
|
if (action === 'input-change') {
|
||||||
|
setInputValue(inputValue);
|
||||||
|
}
|
||||||
|
if (action === 'input-blur') {
|
||||||
|
handleBlur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleBlur]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearInputValue = useCallback(() => {
|
||||||
|
setInputValue('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onInputChange: handleInputChange,
|
||||||
|
clearInputValue,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useCreateTagMutation, useTags } from '@/portainer/tags/queries';
|
||||||
|
|
||||||
|
import { CreatableSelector } from './CreatableSelector';
|
||||||
|
|
||||||
|
export function TagSelector() {
|
||||||
|
const createMutation = useCreateTagMutation();
|
||||||
|
|
||||||
|
const tagsQuery = useTags({
|
||||||
|
select: (tags) => tags.map((opt) => ({ label: opt.Name, value: opt.ID })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tagsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = tagsQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelector
|
||||||
|
name="tags"
|
||||||
|
options={tags}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
isLoading={createMutation.isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleCreate(newTag: string) {
|
||||||
|
const tag = await createMutation.mutateAsync(newTag);
|
||||||
|
|
||||||
|
notifySuccess('Tag created', `Tag ${tag.Name} created`);
|
||||||
|
|
||||||
|
return tag.ID;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { EdgeGroupsSelector } from './EdgeGroupSelector';
|
||||||
|
export { GroupSelector } from './GroupSelector';
|
||||||
|
export { TagSelector } from './TagSelector';
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { EnvironmentRelationsPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation';
|
||||||
|
|
||||||
|
import { WaitingRoomEnvironment } from '../../types';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { isAssignedToGroup } from './utils';
|
||||||
|
|
||||||
|
export function createPayload(
|
||||||
|
environment: WaitingRoomEnvironment,
|
||||||
|
values: FormValues
|
||||||
|
) {
|
||||||
|
const relations: Partial<EnvironmentRelationsPayload> = {};
|
||||||
|
|
||||||
|
if (environment.TagIds.length === 0 || values.overrideTags) {
|
||||||
|
relations.tags = values.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (environment.EdgeGroups.length === 0 || values.overrideEdgeGroups) {
|
||||||
|
relations.edgeGroups = values.edgeGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!isAssignedToGroup(environment) || values.overrideGroup) &&
|
||||||
|
values.group
|
||||||
|
) {
|
||||||
|
relations.group = values.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [environment.Id, relations];
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
group: EnvironmentGroupId | null;
|
||||||
|
overrideGroup: boolean;
|
||||||
|
edgeGroups: Array<EdgeGroup['Id']>;
|
||||||
|
overrideEdgeGroups: boolean;
|
||||||
|
tags: Array<TagId>;
|
||||||
|
overrideTags: boolean;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Environment } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
export function isAssignedToGroup(environment: Environment) {
|
||||||
|
return ![0, 1].includes(environment.GroupId);
|
||||||
|
}
|
|
@ -1,22 +1,10 @@
|
||||||
import { Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Environment } from '@/react/portainer/environments/types';
|
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
|
||||||
import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation';
|
|
||||||
|
|
||||||
import { Datatable as GenericDatatable } from '@@/datatables';
|
import { Datatable as GenericDatatable } from '@@/datatables';
|
||||||
import { Button } from '@@/buttons';
|
|
||||||
import { createPersistedStore } from '@@/datatables/types';
|
import { createPersistedStore } from '@@/datatables/types';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
import { confirm } from '@@/modals/confirm';
|
|
||||||
import { buildConfirmButton } from '@@/modals/utils';
|
|
||||||
import { ModalType } from '@@/modals';
|
|
||||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
|
||||||
|
|
||||||
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
|
|
||||||
|
|
||||||
import { columns } from './columns';
|
import { columns } from './columns';
|
||||||
import { Filter } from './Filter';
|
import { Filter } from './Filter';
|
||||||
|
import { TableActions } from './TableActions';
|
||||||
import { useEnvironments } from './useEnvironments';
|
import { useEnvironments } from './useEnvironments';
|
||||||
|
|
||||||
const storageKey = 'edge-devices-waiting-room';
|
const storageKey = 'edge-devices-waiting-room';
|
||||||
|
@ -24,9 +12,6 @@ const storageKey = 'edge-devices-waiting-room';
|
||||||
const settingsStore = createPersistedStore(storageKey, 'Name');
|
const settingsStore = createPersistedStore(storageKey, 'Name');
|
||||||
|
|
||||||
export function Datatable() {
|
export function Datatable() {
|
||||||
const associateMutation = useAssociateDeviceMutation();
|
|
||||||
const removeMutation = useDeleteEnvironmentsMutation();
|
|
||||||
const { willExceed } = useLicenseOverused();
|
|
||||||
const tableState = useTableState(settingsStore, storageKey);
|
const tableState = useTableState(settingsStore, storageKey);
|
||||||
const { data: environments, totalCount, isLoading } = useEnvironments();
|
const { data: environments, totalCount, isLoading } = useEnvironments();
|
||||||
|
|
||||||
|
@ -38,76 +23,11 @@ export function Datatable() {
|
||||||
title="Edge Devices Waiting Room"
|
title="Edge Devices Waiting Room"
|
||||||
emptyContentLabel="No Edge Devices found"
|
emptyContentLabel="No Edge Devices found"
|
||||||
renderTableActions={(selectedRows) => (
|
renderTableActions={(selectedRows) => (
|
||||||
<>
|
<TableActions selectedRows={selectedRows} />
|
||||||
<Button
|
|
||||||
onClick={() => handleRemoveDevice(selectedRows)}
|
|
||||||
disabled={selectedRows.length === 0}
|
|
||||||
color="dangerlight"
|
|
||||||
icon={Trash2}
|
|
||||||
>
|
|
||||||
Remove Device
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<TooltipWithChildren
|
|
||||||
message={
|
|
||||||
willExceed(selectedRows.length) && (
|
|
||||||
<>
|
|
||||||
Associating devices is disabled as your node count exceeds
|
|
||||||
your license limit
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleAssociateDevice(selectedRows)}
|
|
||||||
disabled={
|
|
||||||
selectedRows.length === 0 || willExceed(selectedRows.length)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Associate Device
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipWithChildren>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
description={<Filter />}
|
description={<Filter />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleAssociateDevice(devices: Environment[]) {
|
|
||||||
associateMutation.mutate(
|
|
||||||
devices.map((d) => d.Id),
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess('Success', 'Edge devices associated successfully');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveDevice(devices: Environment[]) {
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: 'Are you sure?',
|
|
||||||
message:
|
|
||||||
"You're about to remove edge device(s) from waiting room, which will not be shown until next agent startup.",
|
|
||||||
confirmButton: buildConfirmButton('Remove', 'danger'),
|
|
||||||
modalType: ModalType.Destructive,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeMutation.mutate(
|
|
||||||
devices.map((d) => d.Id),
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess('Success', 'Edge devices were hidden successfully');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { Check, CheckCircle, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation';
|
||||||
|
import { Environment } from '@/react/portainer/environments/types';
|
||||||
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { ModalType, openModal } from '@@/modals';
|
||||||
|
import { confirm } from '@@/modals/confirm';
|
||||||
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
|
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||||
|
|
||||||
|
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
|
||||||
|
import { WaitingRoomEnvironment } from '../types';
|
||||||
|
|
||||||
|
import { AssignmentDialog } from './AssignmentDialog/AssignmentDialog';
|
||||||
|
|
||||||
|
const overusedTooltip = (
|
||||||
|
<>
|
||||||
|
Associating devices is disabled as your node count exceeds your license
|
||||||
|
limit
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function TableActions({
|
||||||
|
selectedRows,
|
||||||
|
}: {
|
||||||
|
selectedRows: WaitingRoomEnvironment[];
|
||||||
|
}) {
|
||||||
|
const associateMutation = useAssociateDeviceMutation();
|
||||||
|
const removeMutation = useDeleteEnvironmentsMutation();
|
||||||
|
const licenseOverused = useLicenseOverused(selectedRows.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRemoveDevice(selectedRows)}
|
||||||
|
disabled={selectedRows.length === 0}
|
||||||
|
color="dangerlight"
|
||||||
|
icon={Trash2}
|
||||||
|
>
|
||||||
|
Remove Device
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<TooltipWithChildren
|
||||||
|
message={
|
||||||
|
licenseOverused ? (
|
||||||
|
overusedTooltip
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Associate device(s) and assigning edge groups, group and tags with
|
||||||
|
overriding options
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAssociateAndAssign(selectedRows)}
|
||||||
|
disabled={selectedRows.length === 0 || licenseOverused}
|
||||||
|
color="secondary"
|
||||||
|
icon={CheckCircle}
|
||||||
|
>
|
||||||
|
Associate and assignment
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipWithChildren>
|
||||||
|
|
||||||
|
<TooltipWithChildren
|
||||||
|
message={
|
||||||
|
licenseOverused ? (
|
||||||
|
overusedTooltip
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Associate device(s) based on their pre-assigned edge groups, group
|
||||||
|
and tags
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAssociateDevice(selectedRows)}
|
||||||
|
disabled={selectedRows.length === 0 || licenseOverused}
|
||||||
|
icon={Check}
|
||||||
|
>
|
||||||
|
Associate Device
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipWithChildren>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleAssociateAndAssign(
|
||||||
|
environments: WaitingRoomEnvironment[]
|
||||||
|
) {
|
||||||
|
const assigned = await openModal(withReactQuery(AssignmentDialog), {
|
||||||
|
environments,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assigned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAssociateDevice(environments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssociateDevice(devices: Environment[]) {
|
||||||
|
associateMutation.mutate(
|
||||||
|
devices.map((d) => d.Id),
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Edge devices associated successfully');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveDevice(devices: Environment[]) {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Are you sure?',
|
||||||
|
message:
|
||||||
|
"You're about to remove edge device(s) from waiting room, which will not be shown until next agent startup.",
|
||||||
|
confirmButton: buildConfirmButton('Remove', 'danger'),
|
||||||
|
modalType: ModalType.Destructive,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMutation.mutate(
|
||||||
|
devices.map((d) => d.Id),
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Edge devices were hidden successfully');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -55,7 +55,7 @@ export function useEnvironments() {
|
||||||
const envs: Array<WaitingRoomEnvironment> =
|
const envs: Array<WaitingRoomEnvironment> =
|
||||||
environmentsQuery.environments.map((env) => ({
|
environmentsQuery.environments.map((env) => ({
|
||||||
...env,
|
...env,
|
||||||
Group: groupsQuery.data?.[env.GroupId] || '',
|
Group: (env.GroupId !== 1 && groupsQuery.data?.[env.GroupId]) || '',
|
||||||
EdgeGroups:
|
EdgeGroups:
|
||||||
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
|
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
|
||||||
[],
|
[],
|
||||||
|
|
|
@ -13,7 +13,7 @@ export default withLimitToBE(WaitingRoomView);
|
||||||
|
|
||||||
function WaitingRoomView() {
|
function WaitingRoomView() {
|
||||||
const untrustedCount = useUntrustedCount();
|
const untrustedCount = useUntrustedCount();
|
||||||
const { willExceed } = useLicenseOverused();
|
const licenseOverused = useLicenseOverused(untrustedCount);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
@ -32,7 +32,7 @@ function WaitingRoomView() {
|
||||||
</TextTip>
|
</TextTip>
|
||||||
</InformationPanel>
|
</InformationPanel>
|
||||||
|
|
||||||
{willExceed(untrustedCount) && (
|
{licenseOverused && (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<Alert color="warn">
|
<Alert color="warn">
|
||||||
|
|
|
@ -5,6 +5,14 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||||
import { useIntegratedLicenseInfo } from '@/react/portainer/licenses/use-license.service';
|
import { useIntegratedLicenseInfo } from '@/react/portainer/licenses/use-license.service';
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
|
import {
|
||||||
|
mutationOptions,
|
||||||
|
withError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
import { queryKey as nodesCountQueryKey } from '@/react/portainer/system/useNodesCount';
|
||||||
|
import { LicenseType } from '@/react/portainer/licenses/types';
|
||||||
|
import { queryKeys } from '@/react/portainer/environments/queries/query-keys';
|
||||||
|
|
||||||
export function useAssociateDeviceMutation() {
|
export function useAssociateDeviceMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -12,17 +20,10 @@ export function useAssociateDeviceMutation() {
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(ids: EnvironmentId[]) =>
|
(ids: EnvironmentId[]) =>
|
||||||
promiseSequence(ids.map((id) => () => associateDevice(id))),
|
promiseSequence(ids.map((id) => () => associateDevice(id))),
|
||||||
{
|
mutationOptions(
|
||||||
onSuccess: () => {
|
withError('Failed to associate devices'),
|
||||||
queryClient.invalidateQueries(['environments']);
|
withInvalidate(queryClient, [queryKeys.base(), nodesCountQueryKey])
|
||||||
},
|
)
|
||||||
meta: {
|
|
||||||
error: {
|
|
||||||
title: 'Failure',
|
|
||||||
message: 'Failed to associate devices',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,19 +35,14 @@ async function associateDevice(environmentId: EnvironmentId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLicenseOverused() {
|
export function useLicenseOverused(moreNodes: number) {
|
||||||
const integratedInfo = useIntegratedLicenseInfo();
|
const integratedInfo = useIntegratedLicenseInfo();
|
||||||
return {
|
|
||||||
willExceed,
|
|
||||||
isOverused: willExceed(0),
|
|
||||||
};
|
|
||||||
|
|
||||||
function willExceed(moreNodes: number) {
|
return (
|
||||||
return (
|
!!integratedInfo &&
|
||||||
!!integratedInfo &&
|
integratedInfo.licenseInfo.type === LicenseType.Essentials &&
|
||||||
integratedInfo.usedNodes + moreNodes >= integratedInfo.licenseInfo.nodes
|
integratedInfo.usedNodes + moreNodes > integratedInfo.licenseInfo.nodes
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUntrustedCount() {
|
export function useUntrustedCount() {
|
||||||
|
|
3
app/react/edge/edge-groups/queries/build-url.ts
Normal file
3
app/react/edge/edge-groups/queries/build-url.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export function buildUrl() {
|
||||||
|
return '/edge_groups';
|
||||||
|
}
|
3
app/react/edge/edge-groups/queries/query-keys.ts
Normal file
3
app/react/edge/edge-groups/queries/query-keys.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const queryKeys = {
|
||||||
|
base: () => ['edge', 'groups'] as const,
|
||||||
|
};
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
import {
|
||||||
|
mutationOptions,
|
||||||
|
withError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { EdgeGroup } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
interface CreateGroupPayload {
|
||||||
|
name: string;
|
||||||
|
dynamic: boolean;
|
||||||
|
tagIds?: TagId[];
|
||||||
|
endpoints?: EnvironmentId[];
|
||||||
|
partialMatch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEdgeGroup(requestPayload: CreateGroupPayload) {
|
||||||
|
try {
|
||||||
|
const { data: group } = await axios.post<EdgeGroup>(
|
||||||
|
buildUrl(),
|
||||||
|
requestPayload
|
||||||
|
);
|
||||||
|
return group;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Failed to create Edge group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateGroupMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation(
|
||||||
|
createEdgeGroup,
|
||||||
|
mutationOptions(
|
||||||
|
withError('Failed to create Edge group'),
|
||||||
|
withInvalidate(queryClient, [queryKeys.base()])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
|
@ -5,15 +5,16 @@ import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
import { EdgeGroup } from '../types';
|
import { EdgeGroup } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
|
||||||
interface EdgeGroupListItemResponse extends EdgeGroup {
|
interface EdgeGroupListItemResponse extends EdgeGroup {
|
||||||
EndpointTypes: Array<EnvironmentType>;
|
EndpointTypes: Array<EnvironmentType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEdgeGroups() {
|
async function getEdgeGroups() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<EdgeGroupListItemResponse[]>(
|
const { data } = await axios.get<EdgeGroupListItemResponse[]>(buildUrl());
|
||||||
'/edge_groups'
|
|
||||||
);
|
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
|
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
|
||||||
|
@ -25,5 +26,5 @@ export function useEdgeGroups<T = EdgeGroupListItemResponse[]>({
|
||||||
}: {
|
}: {
|
||||||
select?: (groups: EdgeGroupListItemResponse[]) => T;
|
select?: (groups: EdgeGroupListItemResponse[]) => T;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return useQuery(['edge', 'groups'], getEdgeGroups, { select });
|
return useQuery(queryKeys.base(), getEdgeGroups, { select });
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { compact } from 'lodash';
|
||||||
import { withError } from '@/react-tools/react-query';
|
import { withError } from '@/react-tools/react-query';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { isFulfilled } from '@/react/utils';
|
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||||
|
|
||||||
import { getNamespaces } from '../namespaces/service';
|
import { getNamespaces } from '../namespaces/service';
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { isFulfilled } from '@/react/utils';
|
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||||
|
|
||||||
import { getPod, getPods, patchPod } from './pod.service';
|
import { getPod, getPods, patchPod } from './pod.service';
|
||||||
import { getNakedPods } from './utils';
|
import { getNakedPods } from './utils';
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
withInvalidate,
|
withInvalidate,
|
||||||
} from '@/react-tools/react-query';
|
} from '@/react-tools/react-query';
|
||||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||||
import { isFulfilled } from '@/react/utils';
|
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getIngresses,
|
getIngresses,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
import { buildUrl } from './queries/build-url';
|
||||||
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
||||||
|
|
||||||
export async function getGroup(id: EnvironmentGroupId) {
|
export async function getGroup(id: EnvironmentGroupId) {
|
||||||
|
@ -19,17 +20,3 @@ export async function getGroups() {
|
||||||
throw parseAxiosError(e as Error, '');
|
throw parseAxiosError(e as Error, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(id?: EnvironmentGroupId, action?: string) {
|
|
||||||
let url = '/endpoint_groups';
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
url += `/${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action) {
|
|
||||||
url += `/${action}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,11 +4,12 @@ import { error as notifyError } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
||||||
import { getGroup, getGroups } from './environment-groups.service';
|
import { getGroup, getGroups } from './environment-groups.service';
|
||||||
|
import { queryKeys } from './queries/query-keys';
|
||||||
|
|
||||||
export function useGroups<T = EnvironmentGroup[]>({
|
export function useGroups<T = EnvironmentGroup[]>({
|
||||||
select,
|
select,
|
||||||
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
|
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
|
||||||
return useQuery(['environment-groups'], getGroups, {
|
return useQuery(queryKeys.base(), getGroups, {
|
||||||
select,
|
select,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -17,17 +18,13 @@ export function useGroup<T = EnvironmentGroup>(
|
||||||
groupId: EnvironmentGroupId,
|
groupId: EnvironmentGroupId,
|
||||||
select?: (group: EnvironmentGroup) => T
|
select?: (group: EnvironmentGroup) => T
|
||||||
) {
|
) {
|
||||||
const { data } = useQuery(
|
const { data } = useQuery(queryKeys.group(groupId), () => getGroup(groupId), {
|
||||||
['environment-groups', groupId],
|
staleTime: 50,
|
||||||
() => getGroup(groupId),
|
select,
|
||||||
{
|
onError(error) {
|
||||||
staleTime: 50,
|
notifyError('Failed loading group', error as Error);
|
||||||
select,
|
},
|
||||||
onError(error) {
|
});
|
||||||
notifyError('Failed loading group', error as Error);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { EnvironmentGroupId } from '../types';
|
||||||
|
|
||||||
|
export function buildUrl(id?: EnvironmentGroupId, action?: string) {
|
||||||
|
let url = '/endpoint_groups';
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
url += `/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
url += `/${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { EnvironmentGroupId } from '../types';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
base: () => ['environment-groups'] as const,
|
||||||
|
group: (id: EnvironmentGroupId) => [...queryKeys.base(), id] as const,
|
||||||
|
};
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
import {
|
||||||
|
mutationOptions,
|
||||||
|
withError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '../../types';
|
||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
interface CreateGroupPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
associatedEndpoints?: EnvironmentId[];
|
||||||
|
tagIds?: TagId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(requestPayload: CreateGroupPayload) {
|
||||||
|
try {
|
||||||
|
const { data: group } = await axios.post<EnvironmentGroup>(
|
||||||
|
buildUrl(),
|
||||||
|
requestPayload
|
||||||
|
);
|
||||||
|
return group;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Failed to create group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateGroupMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation(
|
||||||
|
createGroup,
|
||||||
|
mutationOptions(
|
||||||
|
withError('Failed to create group'),
|
||||||
|
withInvalidate(queryClient, [queryKeys.base()])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
|
@ -140,75 +140,6 @@ export async function disassociateEndpoint(id: EnvironmentId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdatePayload {
|
|
||||||
TLSCACert?: File;
|
|
||||||
TLSCert?: File;
|
|
||||||
TLSKey?: File;
|
|
||||||
|
|
||||||
Name: string;
|
|
||||||
PublicURL: string;
|
|
||||||
GroupID: EnvironmentGroupId;
|
|
||||||
TagIds: TagId[];
|
|
||||||
|
|
||||||
EdgeCheckinInterval: number;
|
|
||||||
|
|
||||||
TLS: boolean;
|
|
||||||
TLSSkipVerify: boolean;
|
|
||||||
TLSSkipClientVerify: boolean;
|
|
||||||
AzureApplicationID: string;
|
|
||||||
AzureTenantID: string;
|
|
||||||
AzureAuthenticationKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadTLSFilesForEndpoint(
|
|
||||||
id: EnvironmentId,
|
|
||||||
tlscaCert?: File,
|
|
||||||
tlsCert?: File,
|
|
||||||
tlsKey?: File
|
|
||||||
) {
|
|
||||||
await Promise.all([
|
|
||||||
uploadCert('ca', tlscaCert),
|
|
||||||
uploadCert('cert', tlsCert),
|
|
||||||
uploadCert('key', tlsKey),
|
|
||||||
]);
|
|
||||||
|
|
||||||
function uploadCert(type: 'ca' | 'cert' | 'key', cert?: File) {
|
|
||||||
if (!cert) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return axios.post<void>(`upload/tls/${type}`, cert, {
|
|
||||||
params: { folder: id },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateEndpoint(
|
|
||||||
id: EnvironmentId,
|
|
||||||
payload: UpdatePayload
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await uploadTLSFilesForEndpoint(
|
|
||||||
id,
|
|
||||||
payload.TLSCACert,
|
|
||||||
payload.TLSCert,
|
|
||||||
payload.TLSKey
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: endpoint } = await axios.put<Environment>(
|
|
||||||
buildUrl(id),
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
|
|
||||||
return endpoint;
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to update environment');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteEndpoint(id: EnvironmentId) {
|
export async function deleteEndpoint(id: EnvironmentId) {
|
||||||
try {
|
try {
|
||||||
await axios.delete(buildUrl(id));
|
await axios.delete(buildUrl(id));
|
||||||
|
|
6
app/react/portainer/environments/queries/query-keys.ts
Normal file
6
app/react/portainer/environments/queries/query-keys.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { EnvironmentId } from '../types';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
base: () => ['environments'] as const,
|
||||||
|
item: (id: EnvironmentId) => [...queryKeys.base(), id] as const,
|
||||||
|
};
|
|
@ -2,6 +2,10 @@ import { useQuery } from 'react-query';
|
||||||
|
|
||||||
import { getAgentVersions } from '../environment.service';
|
import { getAgentVersions } from '../environment.service';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useAgentVersionsList() {
|
export function useAgentVersionsList() {
|
||||||
return useQuery(['environments', 'agentVersions'], () => getAgentVersions());
|
return useQuery([...queryKeys.base(), 'agentVersions'], () =>
|
||||||
|
getAgentVersions()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,14 +7,20 @@ import {
|
||||||
} from '@/react/portainer/environments/types';
|
} from '@/react/portainer/environments/types';
|
||||||
import { withError } from '@/react-tools/react-query';
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
export function useEnvironment<T = Environment | null>(
|
export function useEnvironment<T = Environment | null>(
|
||||||
id?: EnvironmentId,
|
id?: EnvironmentId,
|
||||||
select?: (environment: Environment | null) => T
|
select?: (environment: Environment | null) => T
|
||||||
) {
|
) {
|
||||||
return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), {
|
return useQuery(
|
||||||
select,
|
id ? queryKeys.item(id) : [],
|
||||||
...withError('Failed loading environment'),
|
() => (id ? getEndpoint(id) : null),
|
||||||
staleTime: 50,
|
{
|
||||||
enabled: !!id,
|
select,
|
||||||
});
|
...withError('Failed loading environment'),
|
||||||
|
staleTime: 50,
|
||||||
|
enabled: !!id,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ import {
|
||||||
getEnvironments,
|
getEnvironments,
|
||||||
} from '../environment.service';
|
} from '../environment.service';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
||||||
|
|
||||||
export interface Query extends EnvironmentsQueryParams {
|
export interface Query extends EnvironmentsQueryParams {
|
||||||
|
@ -46,7 +48,7 @@ export function useEnvironmentList(
|
||||||
) {
|
) {
|
||||||
const { isLoading, data } = useQuery(
|
const { isLoading, data } = useQuery(
|
||||||
[
|
[
|
||||||
'environments',
|
...queryKeys.base(),
|
||||||
{
|
{
|
||||||
page,
|
page,
|
||||||
pageLimit,
|
pageLimit,
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { EnvironmentGroupId } from '../environment-groups/types';
|
||||||
|
import { buildUrl } from '../environment.service/utils';
|
||||||
|
import { EnvironmentId, Environment } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useUpdateEnvironmentMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation(updateEnvironment, {
|
||||||
|
onSuccess(data, { id }) {
|
||||||
|
queryClient.invalidateQueries(queryKeys.item(id));
|
||||||
|
},
|
||||||
|
...withError('Unable to update environment'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePayload {
|
||||||
|
TLSCACert?: File;
|
||||||
|
TLSCert?: File;
|
||||||
|
TLSKey?: File;
|
||||||
|
|
||||||
|
Name: string;
|
||||||
|
PublicURL: string;
|
||||||
|
GroupID: EnvironmentGroupId;
|
||||||
|
TagIds: TagId[];
|
||||||
|
|
||||||
|
EdgeCheckinInterval: number;
|
||||||
|
|
||||||
|
TLS: boolean;
|
||||||
|
TLSSkipVerify: boolean;
|
||||||
|
TLSSkipClientVerify: boolean;
|
||||||
|
AzureApplicationID: string;
|
||||||
|
AzureTenantID: string;
|
||||||
|
AzureAuthenticationKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEnvironment({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
id: EnvironmentId;
|
||||||
|
payload: Partial<UpdatePayload>;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await uploadTLSFilesForEndpoint(
|
||||||
|
id,
|
||||||
|
payload.TLSCACert,
|
||||||
|
payload.TLSCert,
|
||||||
|
payload.TLSKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: endpoint } = await axios.put<Environment>(
|
||||||
|
buildUrl(id),
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return endpoint;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to update environment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadTLSFilesForEndpoint(
|
||||||
|
id: EnvironmentId,
|
||||||
|
tlscaCert?: File,
|
||||||
|
tlsCert?: File,
|
||||||
|
tlsKey?: File
|
||||||
|
) {
|
||||||
|
await Promise.all([
|
||||||
|
uploadCert('ca', tlscaCert),
|
||||||
|
uploadCert('cert', tlsCert),
|
||||||
|
uploadCert('key', tlsKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function uploadCert(type: 'ca' | 'cert' | 'key', cert?: File) {
|
||||||
|
if (!cert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return axios.post<void>(`upload/tls/${type}`, cert, {
|
||||||
|
params: { folder: id },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import {
|
||||||
|
mutationOptions,
|
||||||
|
withError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
import { TagId } from '@/portainer/tags/types';
|
||||||
|
import { queryKeys as edgeGroupQueryKeys } from '@/react/edge/edge-groups/queries/query-keys';
|
||||||
|
import { queryKeys as groupQueryKeys } from '@/react/portainer/environments/environment-groups/queries/query-keys';
|
||||||
|
import { tagKeys } from '@/portainer/tags/queries';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '../types';
|
||||||
|
import { buildUrl } from '../environment.service/utils';
|
||||||
|
import { EnvironmentGroupId } from '../environment-groups/types';
|
||||||
|
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useUpdateEnvironmentsRelationsMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation(
|
||||||
|
updateEnvironmentRelations,
|
||||||
|
mutationOptions(
|
||||||
|
withInvalidate(queryClient, [
|
||||||
|
queryKeys.base(),
|
||||||
|
edgeGroupQueryKeys.base(),
|
||||||
|
groupQueryKeys.base(),
|
||||||
|
tagKeys.all,
|
||||||
|
]),
|
||||||
|
withError('Unable to update environment relations')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentRelationsPayload {
|
||||||
|
edgeGroups: Array<EdgeGroup['Id']>;
|
||||||
|
group: EnvironmentGroupId;
|
||||||
|
tags: Array<TagId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEnvironmentRelations(
|
||||||
|
relations: Record<EnvironmentId, EnvironmentRelationsPayload>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await axios.put(buildUrl(undefined, 'relations'), { relations });
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to update environment relations');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +0,0 @@
|
||||||
export function isFulfilled<T>(
|
|
||||||
input: PromiseSettledResult<T>
|
|
||||||
): input is PromiseFulfilledResult<T> {
|
|
||||||
return input.status === 'fulfilled';
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue