feat(waiting-room): choose relations when associated endpoint [EE-5187] (#8720)

pull/8866/head
Chaim Lev-Ari 2023-05-14 09:26:11 +07:00 committed by GitHub
parent 511adabce2
commit 365316971b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1712 additions and 303 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}
}

View 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)
}

View File

@ -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)

View 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
}

View 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
}

View 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)
})
}
}

View 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
}

View 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)
})
}
}

View File

@ -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
}

View File

@ -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]
}

View File

@ -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);
}

View File

@ -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,
};

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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');

View File

@ -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);
},
}
);
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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,
};
}

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
export { EdgeGroupsSelector } from './EdgeGroupSelector';
export { GroupSelector } from './GroupSelector';
export { TagSelector } from './TagSelector';

View File

@ -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];
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
import { Environment } from '@/react/portainer/environments/types';
export function isAssignedToGroup(environment: Environment) {
return ![0, 1].includes(environment.GroupId);
}

View File

@ -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');
},
}
);
}
}

View File

@ -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');
},
}
);
}
}

View File

@ -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) ||
[],

View File

@ -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">

View File

@ -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() {

View File

@ -0,0 +1,3 @@
export function buildUrl() {
return '/edge_groups';
}

View File

@ -0,0 +1,3 @@
export const queryKeys = {
base: () => ['edge', 'groups'] as const,
};

View File

@ -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()])
)
);
}

View File

@ -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 });
}

View File

@ -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';

View File

@ -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';

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import { EnvironmentGroupId } from '../types';
export const queryKeys = {
base: () => ['environment-groups'] as const,
group: (id: EnvironmentGroupId) => [...queryKeys.base(), id] as const,
};

View File

@ -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()])
)
);
}

View File

@ -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));

View File

@ -0,0 +1,6 @@
import { EnvironmentId } from '../types';
export const queryKeys = {
base: () => ['environments'] as const,
item: (id: EnvironmentId) => [...queryKeys.base(), id] as const,
};

View File

@ -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()
);
}

View File

@ -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,
}
);
}

View File

@ -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,

View File

@ -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);
}
}
}

View File

@ -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');
}
}

View File

@ -1,5 +0,0 @@
export function isFulfilled<T>(
input: PromiseSettledResult<T>
): input is PromiseFulfilledResult<T> {
return input.status === 'fulfilled';
}