mirror of https://github.com/portainer/portainer
feat(waiting-room): choose relations when associated endpoint [EE-5187] (#8720)
parent
511adabce2
commit
365316971b
|
@ -28,7 +28,7 @@ func (service ServiceTx) EdgeStacks() ([]portainer.EdgeStack, error) {
|
|||
stack, ok := obj.(*portainer.EdgeStack)
|
||||
if !ok {
|
||||
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)
|
||||
|
|
|
@ -30,10 +30,6 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ func Test_EndpointList_AgentVersion(t *testing.T) {
|
|||
noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, 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,
|
||||
version1Endpoint,
|
||||
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}
|
||||
regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment}
|
||||
|
||||
handler, teardown := setup(t, []portainer.Endpoint{
|
||||
handler, teardown := setupEndpointListHandler(t, []portainer.Endpoint{
|
||||
trustedEdgeAsync,
|
||||
untrustedEdgeAsync,
|
||||
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)
|
||||
_, store, teardown := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
|
|
|
@ -9,9 +9,8 @@ import (
|
|||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/tag"
|
||||
)
|
||||
|
||||
type endpointUpdatePayload struct {
|
||||
|
@ -120,48 +119,31 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
|||
endpoint.EdgeCheckinInterval = *payload.EdgeCheckinInterval
|
||||
}
|
||||
|
||||
groupIDChanged := false
|
||||
updateRelations := false
|
||||
|
||||
if payload.GroupID != nil {
|
||||
groupID := portainer.EndpointGroupID(*payload.GroupID)
|
||||
groupIDChanged = groupID != endpoint.GroupID
|
||||
|
||||
endpoint.GroupID = groupID
|
||||
updateRelations = updateRelations || groupID != endpoint.GroupID
|
||||
}
|
||||
|
||||
tagsChanged := false
|
||||
if payload.TagIDs != nil {
|
||||
payloadTagSet := tag.Set(payload.TagIDs)
|
||||
endpointTagSet := tag.Set((endpoint.TagIDs))
|
||||
union := tag.Union(payloadTagSet, endpointTagSet)
|
||||
intersection := tag.Intersection(payloadTagSet, endpointTagSet)
|
||||
tagsChanged = len(union) > len(intersection)
|
||||
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
|
||||
if tagsChanged {
|
||||
removeTags := tag.Difference(endpointTagSet, payloadTagSet)
|
||||
|
||||
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)
|
||||
}
|
||||
tagsChanged, err := updateEnvironmentTags(tx, payload.TagIDs, endpoint.TagIDs, endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoint.TagIDs = payload.TagIDs
|
||||
for _, tagID := range payload.TagIDs {
|
||||
err = handler.DataStore.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) {
|
||||
tag.Endpoints[endpoint.ID] = true
|
||||
})
|
||||
updateRelations = updateRelations || tagsChanged
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) && (groupIDChanged || tagsChanged) {
|
||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||
if updateRelations {
|
||||
err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return handler.updateEdgeRelations(tx, endpoint)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to find environment relation inside the database", 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)
|
||||
return httperror.InternalServerError("Unable to update environment relations", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
h.Handle("/endpoints/agent_versions",
|
||||
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}",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
||||
// Add adds a key to the set.
|
||||
func (s Set[T]) Add(key T) {
|
||||
s[key] = true
|
||||
}
|
||||
|
||||
// Contains returns true if the set contains the key.
|
||||
func (s Set[T]) Contains(key T) bool {
|
||||
_, ok := s[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Remove removes a key from the set.
|
||||
func (s Set[T]) Remove(key T) {
|
||||
delete(s, key)
|
||||
}
|
||||
|
||||
// Len returns the number of keys in the set.
|
||||
func (s Set[T]) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the set is empty.
|
||||
func (s Set[T]) IsEmpty() bool {
|
||||
return len(s) == 0
|
||||
}
|
||||
|
||||
// Clear removes all keys from the set.
|
||||
func (s Set[T]) Keys() []T {
|
||||
keys := make([]T, s.Len())
|
||||
|
||||
|
@ -38,3 +44,67 @@ func (s Set[T]) Keys() []T {
|
|||
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
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 { Tag, TagId } from './types';
|
||||
|
||||
const tagKeys = {
|
||||
export const tagKeys = {
|
||||
all: ['tags'] as const,
|
||||
tag: (id: TagId) => [...tagKeys.all, id] as const,
|
||||
};
|
||||
|
|
|
@ -19,8 +19,9 @@ export function TextTip({
|
|||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className={clsx('small inline-flex items-center gap-1', className)}>
|
||||
<Icon icon={icon} mode={getMode(color)} className="shrink-0" />
|
||||
<div className={clsx('small inline-flex gap-1', className)}>
|
||||
<Icon icon={icon} mode={getMode(color)} className="!mt-[2px]" />
|
||||
|
||||
<span className="text-muted">{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import clsx from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
useRef,
|
||||
|
@ -16,11 +17,21 @@ interface Props extends HTMLProps<HTMLInputElement> {
|
|||
className?: string;
|
||||
role?: string;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
bold?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
) => {
|
||||
const defaultRef = useRef<HTMLInputElement>(null);
|
||||
|
@ -50,7 +61,9 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
|||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<label htmlFor={id} className={clsx({ '!font-normal': !bold })}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { PropsWithChildren, useState } from 'react';
|
||||
import { PropsWithChildren, ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
@ -6,7 +6,7 @@ import { Icon } from '@@/Icon';
|
|||
import { FormSectionTitle } from '../FormSectionTitle';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
title: ReactNode;
|
||||
isFoldable?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,9 @@ import { OnSubmit } from './Modal/types';
|
|||
|
||||
let counter = 0;
|
||||
export async function openModal<TProps, TResult>(
|
||||
Modal: ComponentType<{ onSubmit: OnSubmit<TResult> } & TProps>,
|
||||
Modal: ComponentType<
|
||||
{ onSubmit: OnSubmit<TResult> } & Omit<TProps, 'onSubmit'>
|
||||
>,
|
||||
props: TProps = {} as TProps
|
||||
) {
|
||||
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 { Button } from '@@/buttons';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
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 { Filter } from './Filter';
|
||||
import { TableActions } from './TableActions';
|
||||
import { useEnvironments } from './useEnvironments';
|
||||
|
||||
const storageKey = 'edge-devices-waiting-room';
|
||||
|
@ -24,9 +12,6 @@ const storageKey = 'edge-devices-waiting-room';
|
|||
const settingsStore = createPersistedStore(storageKey, 'Name');
|
||||
|
||||
export function Datatable() {
|
||||
const associateMutation = useAssociateDeviceMutation();
|
||||
const removeMutation = useDeleteEnvironmentsMutation();
|
||||
const { willExceed } = useLicenseOverused();
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const { data: environments, totalCount, isLoading } = useEnvironments();
|
||||
|
||||
|
@ -38,76 +23,11 @@ export function Datatable() {
|
|||
title="Edge Devices Waiting Room"
|
||||
emptyContentLabel="No Edge Devices found"
|
||||
renderTableActions={(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>
|
||||
</>
|
||||
<TableActions selectedRows={selectedRows} />
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
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> =
|
||||
environmentsQuery.environments.map((env) => ({
|
||||
...env,
|
||||
Group: groupsQuery.data?.[env.GroupId] || '',
|
||||
Group: (env.GroupId !== 1 && groupsQuery.data?.[env.GroupId]) || '',
|
||||
EdgeGroups:
|
||||
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
|
||||
[],
|
||||
|
|
|
@ -13,7 +13,7 @@ export default withLimitToBE(WaitingRoomView);
|
|||
|
||||
function WaitingRoomView() {
|
||||
const untrustedCount = useUntrustedCount();
|
||||
const { willExceed } = useLicenseOverused();
|
||||
const licenseOverused = useLicenseOverused(untrustedCount);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
@ -32,7 +32,7 @@ function WaitingRoomView() {
|
|||
</TextTip>
|
||||
</InformationPanel>
|
||||
|
||||
{willExceed(untrustedCount) && (
|
||||
{licenseOverused && (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Alert color="warn">
|
||||
|
|
|
@ -5,6 +5,14 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||
import { useIntegratedLicenseInfo } from '@/react/portainer/licenses/use-license.service';
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
|
@ -12,17 +20,10 @@ export function useAssociateDeviceMutation() {
|
|||
return useMutation(
|
||||
(ids: EnvironmentId[]) =>
|
||||
promiseSequence(ids.map((id) => () => associateDevice(id))),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['environments']);
|
||||
},
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Failed to associate devices',
|
||||
},
|
||||
},
|
||||
}
|
||||
mutationOptions(
|
||||
withError('Failed to associate devices'),
|
||||
withInvalidate(queryClient, [queryKeys.base(), nodesCountQueryKey])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -34,19 +35,14 @@ async function associateDevice(environmentId: EnvironmentId) {
|
|||
}
|
||||
}
|
||||
|
||||
export function useLicenseOverused() {
|
||||
export function useLicenseOverused(moreNodes: number) {
|
||||
const integratedInfo = useIntegratedLicenseInfo();
|
||||
return {
|
||||
willExceed,
|
||||
isOverused: willExceed(0),
|
||||
};
|
||||
|
||||
function willExceed(moreNodes: number) {
|
||||
return (
|
||||
!!integratedInfo &&
|
||||
integratedInfo.usedNodes + moreNodes >= integratedInfo.licenseInfo.nodes
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!integratedInfo &&
|
||||
integratedInfo.licenseInfo.type === LicenseType.Essentials &&
|
||||
integratedInfo.usedNodes + moreNodes > integratedInfo.licenseInfo.nodes
|
||||
);
|
||||
}
|
||||
|
||||
export function useUntrustedCount() {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export function buildUrl() {
|
||||
return '/edge_groups';
|
||||
}
|
|
@ -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 { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
interface EdgeGroupListItemResponse extends EdgeGroup {
|
||||
EndpointTypes: Array<EnvironmentType>;
|
||||
}
|
||||
|
||||
async function getEdgeGroups() {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeGroupListItemResponse[]>(
|
||||
'/edge_groups'
|
||||
);
|
||||
const { data } = await axios.get<EdgeGroupListItemResponse[]>(buildUrl());
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
|
||||
|
@ -25,5 +26,5 @@ export function useEdgeGroups<T = EdgeGroupListItemResponse[]>({
|
|||
}: {
|
||||
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 axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { isFulfilled } from '@/react/utils';
|
||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import { getNamespaces } from '../namespaces/service';
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
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 { getNakedPods } from './utils';
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||
import { isFulfilled } from '@/react/utils';
|
||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import {
|
||||
getIngresses,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { buildUrl } from './queries/build-url';
|
||||
import { EnvironmentGroup, EnvironmentGroupId } from './types';
|
||||
|
||||
export async function getGroup(id: EnvironmentGroupId) {
|
||||
|
@ -19,17 +20,3 @@ export async function getGroups() {
|
|||
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 { getGroup, getGroups } from './environment-groups.service';
|
||||
import { queryKeys } from './queries/query-keys';
|
||||
|
||||
export function useGroups<T = EnvironmentGroup[]>({
|
||||
select,
|
||||
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
|
||||
return useQuery(['environment-groups'], getGroups, {
|
||||
return useQuery(queryKeys.base(), getGroups, {
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
@ -17,17 +18,13 @@ export function useGroup<T = EnvironmentGroup>(
|
|||
groupId: EnvironmentGroupId,
|
||||
select?: (group: EnvironmentGroup) => T
|
||||
) {
|
||||
const { data } = useQuery(
|
||||
['environment-groups', groupId],
|
||||
() => getGroup(groupId),
|
||||
{
|
||||
staleTime: 50,
|
||||
select,
|
||||
onError(error) {
|
||||
notifyError('Failed loading group', error as Error);
|
||||
},
|
||||
}
|
||||
);
|
||||
const { data } = useQuery(queryKeys.group(groupId), () => getGroup(groupId), {
|
||||
staleTime: 50,
|
||||
select,
|
||||
onError(error) {
|
||||
notifyError('Failed loading group', error as Error);
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
try {
|
||||
await axios.delete(buildUrl(id));
|
||||
|
|
|
@ -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 { queryKeys } from './query-keys';
|
||||
|
||||
export function useAgentVersionsList() {
|
||||
return useQuery(['environments', 'agentVersions'], () => getAgentVersions());
|
||||
return useQuery([...queryKeys.base(), 'agentVersions'], () =>
|
||||
getAgentVersions()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,14 +7,20 @@ import {
|
|||
} from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useEnvironment<T = Environment | null>(
|
||||
id?: EnvironmentId,
|
||||
select?: (environment: Environment | null) => T
|
||||
) {
|
||||
return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), {
|
||||
select,
|
||||
...withError('Failed loading environment'),
|
||||
staleTime: 50,
|
||||
enabled: !!id,
|
||||
});
|
||||
return useQuery(
|
||||
id ? queryKeys.item(id) : [],
|
||||
() => (id ? getEndpoint(id) : null),
|
||||
{
|
||||
select,
|
||||
...withError('Failed loading environment'),
|
||||
staleTime: 50,
|
||||
enabled: !!id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import {
|
|||
getEnvironments,
|
||||
} from '../environment.service';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
||||
|
||||
export interface Query extends EnvironmentsQueryParams {
|
||||
|
@ -46,7 +48,7 @@ export function useEnvironmentList(
|
|||
) {
|
||||
const { isLoading, data } = useQuery(
|
||||
[
|
||||
'environments',
|
||||
...queryKeys.base(),
|
||||
{
|
||||
page,
|
||||
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…
Reference in New Issue