diff --git a/api/dataservices/edgestack/tx.go b/api/dataservices/edgestack/tx.go index 1a1d3681a..1d47fd9c5 100644 --- a/api/dataservices/edgestack/tx.go +++ b/api/dataservices/edgestack/tx.go @@ -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) diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index 18797f14a..80b1fcd95 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -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 } diff --git a/api/http/handler/endpoints/endpoint_list_test.go b/api/http/handler/endpoints/endpoint_list_test.go index cdf5c651d..8bbefbf28 100644 --- a/api/http/handler/endpoints/endpoint_list_test.go +++ b/api/http/handler/endpoints/endpoint_list_test.go @@ -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) diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index a35f383ab..a21898a2e 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -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) } } diff --git a/api/http/handler/endpoints/endpoint_update_relations.go b/api/http/handler/endpoints/endpoint_update_relations.go new file mode 100644 index 000000000..a89e961b4 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_update_relations.go @@ -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) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index f51e7c67b..00c6cbaa7 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -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) diff --git a/api/http/handler/endpoints/update_edge_relations.go b/api/http/handler/endpoints/update_edge_relations.go new file mode 100644 index 000000000..3a5ff40ec --- /dev/null +++ b/api/http/handler/endpoints/update_edge_relations.go @@ -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 +} diff --git a/api/http/handler/endpoints/utils_update_edge_groups.go b/api/http/handler/endpoints/utils_update_edge_groups.go new file mode 100644 index 000000000..cec2e2481 --- /dev/null +++ b/api/http/handler/endpoints/utils_update_edge_groups.go @@ -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 +} diff --git a/api/http/handler/endpoints/utils_update_edge_groups_test.go b/api/http/handler/endpoints/utils_update_edge_groups_test.go new file mode 100644 index 000000000..90f036ac2 --- /dev/null +++ b/api/http/handler/endpoints/utils_update_edge_groups_test.go @@ -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) + }) + } +} diff --git a/api/http/handler/endpoints/utils_update_tags.go b/api/http/handler/endpoints/utils_update_tags.go new file mode 100644 index 000000000..6166b4450 --- /dev/null +++ b/api/http/handler/endpoints/utils_update_tags.go @@ -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 +} diff --git a/api/http/handler/endpoints/utils_update_tags_test.go b/api/http/handler/endpoints/utils_update_tags_test.go new file mode 100644 index 000000000..501a40309 --- /dev/null +++ b/api/http/handler/endpoints/utils_update_tags_test.go @@ -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) + }) + } +} diff --git a/api/internal/set/set.go b/api/internal/set/set.go index 5b7d7d770..62f4f111c 100644 --- a/api/internal/set/set.go +++ b/api/internal/set/set.go @@ -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 +} diff --git a/api/internal/slices/slices.go b/api/internal/slices/slices.go index b0591c861..a98505a3b 100644 --- a/api/internal/slices/slices.go +++ b/api/internal/slices/slices.go @@ -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] +} diff --git a/app/portainer/helpers/promise-utils.ts b/app/portainer/helpers/promise-utils.ts index a984267b9..5ef54ba68 100644 --- a/app/portainer/helpers/promise-utils.ts +++ b/app/portainer/helpers/promise-utils.ts @@ -9,3 +9,15 @@ export function promiseSequence(promises: (() => Promise)[]) { Promise.resolve(undefined as unknown as T) ); } + +export function isFulfilled( + result: PromiseSettledResult +): result is PromiseFulfilledResult { + return result.status === 'fulfilled'; +} + +export function getFulfilledResults( + results: Array> +) { + return results.filter(isFulfilled).map((result) => result.value); +} diff --git a/app/portainer/tags/queries.ts b/app/portainer/tags/queries.ts index b7ddff481..131da6b51 100644 --- a/app/portainer/tags/queries.ts +++ b/app/portainer/tags/queries.ts @@ -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, }; diff --git a/app/react/components/Tip/TextTip/TextTip.tsx b/app/react/components/Tip/TextTip/TextTip.tsx index aff8a40e0..3fdefa36d 100644 --- a/app/react/components/Tip/TextTip/TextTip.tsx +++ b/app/react/components/Tip/TextTip/TextTip.tsx @@ -19,8 +19,9 @@ export function TextTip({ children, }: PropsWithChildren) { return ( -
- +
+ + {children}
); diff --git a/app/react/components/form-components/Checkbox.tsx b/app/react/components/form-components/Checkbox.tsx index e1603f161..964a75248 100644 --- a/app/react/components/form-components/Checkbox.tsx +++ b/app/react/components/form-components/Checkbox.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { forwardRef, useRef, @@ -16,11 +17,21 @@ interface Props extends HTMLProps { className?: string; role?: string; onChange?: ChangeEventHandler; + bold?: boolean; } export const Checkbox = forwardRef( ( - { indeterminate, title, label, id, checked, onChange, ...props }: Props, + { + indeterminate, + title, + label, + id, + checked, + onChange, + bold = true, + ...props + }: Props, ref ) => { const defaultRef = useRef(null); @@ -50,7 +61,9 @@ export const Checkbox = forwardRef( // eslint-disable-next-line react/jsx-props-no-spreading {...props} /> - +
); } diff --git a/app/react/components/form-components/FormSection/FormSection.tsx b/app/react/components/form-components/FormSection/FormSection.tsx index 7c906712b..bd5c12a39 100644 --- a/app/react/components/form-components/FormSection/FormSection.tsx +++ b/app/react/components/form-components/FormSection/FormSection.tsx @@ -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; } diff --git a/app/react/components/modals/open-modal.tsx b/app/react/components/modals/open-modal.tsx index 9327a5ebb..30a1dbf88 100644 --- a/app/react/components/modals/open-modal.tsx +++ b/app/react/components/modals/open-modal.tsx @@ -6,7 +6,9 @@ import { OnSubmit } from './Modal/types'; let counter = 0; export async function openModal( - Modal: ComponentType<{ onSubmit: OnSubmit } & TProps>, + Modal: ComponentType< + { onSubmit: OnSubmit } & Omit + >, props: TProps = {} as TProps ) { const modal = document.createElement('div'); diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/AssignmentDialog.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/AssignmentDialog.tsx new file mode 100644 index 000000000..961fe8396 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/AssignmentDialog.tsx @@ -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; + environments: Array; +}) { + 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 ( + onSubmit()} + size="lg" + > + + + {({ values, setFieldValue, errors }) => ( +
+ +
+ + + + {hasPreAssignedGroup && ( +
+ + setFieldValue('overrideGroup', e.target.checked) + } + /> +
+ )} +
+ + + + + {hasPreAssignedEdgeGroups && ( +
+ + setFieldValue('overrideEdgeGroups', e.target.checked) + } + /> +
+ )} +
+ +
+ + Edge group(s) created here are static only, use tags to + assign to dynamic edge groups + +
+ + + + + {hasPreAssignedTags && ( +
+ + setFieldValue('overrideTags', e.target.checked) + } + /> +
+ )} +
+
+
+ + + + Associate + + +
+ )} +
+
+ ); + + function handleSubmit(values: FormValues) { + assignRelationsMutation.mutate( + Object.fromEntries(environments.map((e) => createPayload(e, values))), + { + onSuccess: () => { + notifySuccess('Success', 'Edge environments assigned successfully'); + onSubmit(true); + }, + } + ); + } +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/CreatableSelector.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/CreatableSelector.tsx new file mode 100644 index 000000000..b354f38dd --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/CreatableSelector.tsx @@ -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>; + onCreate: (label: string) => Promise; + isLoading: boolean; +}) { + const [{ onBlur, value }, , { setValue }] = useField>(name); + + const selectedValues = value.reduce( + (acc: Array>, cur) => + _.compact([...acc, findOption(cur, options)]), + [] + ); + + return ( + + ); + + 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[]; + 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, + }; +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/TagSelector.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/TagSelector.tsx new file mode 100644 index 000000000..d56734085 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/TagSelector.tsx @@ -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 ( + + ); + + async function handleCreate(newTag: string) { + const tag = await createMutation.mutateAsync(newTag); + + notifySuccess('Tag created', `Tag ${tag.Name} created`); + + return tag.ID; + } +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/index.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/index.ts new file mode 100644 index 000000000..5e4f47f8b --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/index.ts @@ -0,0 +1,3 @@ +export { EdgeGroupsSelector } from './EdgeGroupSelector'; +export { GroupSelector } from './GroupSelector'; +export { TagSelector } from './TagSelector'; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/createPayload.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/createPayload.tsx new file mode 100644 index 000000000..e5ab0974e --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/createPayload.tsx @@ -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 = {}; + + 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]; +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts new file mode 100644 index 000000000..480069523 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts @@ -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; + overrideEdgeGroups: boolean; + tags: Array; + overrideTags: boolean; +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/utils.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/utils.ts new file mode 100644 index 000000000..b27080669 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/utils.ts @@ -0,0 +1,5 @@ +import { Environment } from '@/react/portainer/environments/types'; + +export function isAssignedToGroup(environment: Environment) { + return ![0, 1].includes(environment.GroupId); +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx index 04b96f9d8..797d68c5f 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx @@ -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) => ( - <> - - - - Associating devices is disabled as your node count exceeds - your license limit - - ) - } - > - - - - - + )} isLoading={isLoading} totalCount={totalCount} description={} /> ); - - 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'); - }, - } - ); - } } diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/TableActions.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/TableActions.tsx new file mode 100644 index 000000000..f8a6a6c91 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/TableActions.tsx @@ -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 ( + <> + + + + Associate device(s) and assigning edge groups, group and tags with + overriding options + + ) + } + > + + + + + + + Associate device(s) based on their pre-assigned edge groups, group + and tags + + ) + } + > + + + + + + ); + + 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'); + }, + } + ); + } +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts index 1ee4fca02..f644a8ef6 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts @@ -55,7 +55,7 @@ export function useEnvironments() { const envs: Array = 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) || [], diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx index aa2f0faca..6b2c3005b 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx @@ -13,7 +13,7 @@ export default withLimitToBE(WaitingRoomView); function WaitingRoomView() { const untrustedCount = useUntrustedCount(); - const { willExceed } = useLicenseOverused(); + const licenseOverused = useLicenseOverused(untrustedCount); return ( <> - {willExceed(untrustedCount) && ( + {licenseOverused && (
diff --git a/app/react/edge/edge-devices/WaitingRoomView/queries.ts b/app/react/edge/edge-devices/WaitingRoomView/queries.ts index 2ac4311de..cd8b88678 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/queries.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/queries.ts @@ -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() { diff --git a/app/react/edge/edge-groups/queries/build-url.ts b/app/react/edge/edge-groups/queries/build-url.ts new file mode 100644 index 000000000..720780dd0 --- /dev/null +++ b/app/react/edge/edge-groups/queries/build-url.ts @@ -0,0 +1,3 @@ +export function buildUrl() { + return '/edge_groups'; +} diff --git a/app/react/edge/edge-groups/queries/query-keys.ts b/app/react/edge/edge-groups/queries/query-keys.ts new file mode 100644 index 000000000..a46e0c0e8 --- /dev/null +++ b/app/react/edge/edge-groups/queries/query-keys.ts @@ -0,0 +1,3 @@ +export const queryKeys = { + base: () => ['edge', 'groups'] as const, +}; diff --git a/app/react/edge/edge-groups/queries/useCreateEdgeGroupMutation.ts b/app/react/edge/edge-groups/queries/useCreateEdgeGroupMutation.ts new file mode 100644 index 000000000..8ec0941dd --- /dev/null +++ b/app/react/edge/edge-groups/queries/useCreateEdgeGroupMutation.ts @@ -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( + 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()]) + ) + ); +} diff --git a/app/react/edge/edge-groups/queries/useEdgeGroups.ts b/app/react/edge/edge-groups/queries/useEdgeGroups.ts index d2a256309..9adea5fb3 100644 --- a/app/react/edge/edge-groups/queries/useEdgeGroups.ts +++ b/app/react/edge/edge-groups/queries/useEdgeGroups.ts @@ -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; } async function getEdgeGroups() { try { - const { data } = await axios.get( - '/edge_groups' - ); + const { data } = await axios.get(buildUrl()); return data; } catch (err) { throw parseAxiosError(err as Error, 'Failed fetching edge groups'); @@ -25,5 +26,5 @@ export function useEdgeGroups({ }: { select?: (groups: EdgeGroupListItemResponse[]) => T; } = {}) { - return useQuery(['edge', 'groups'], getEdgeGroups, { select }); + return useQuery(queryKeys.base(), getEdgeGroups, { select }); } diff --git a/app/react/kubernetes/ServicesView/service.ts b/app/react/kubernetes/ServicesView/service.ts index ae9dd5658..708010d34 100644 --- a/app/react/kubernetes/ServicesView/service.ts +++ b/app/react/kubernetes/ServicesView/service.ts @@ -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'; diff --git a/app/react/kubernetes/applications/application.service.ts b/app/react/kubernetes/applications/application.service.ts index 1a2849829..84e25dd15 100644 --- a/app/react/kubernetes/applications/application.service.ts +++ b/app/react/kubernetes/applications/application.service.ts @@ -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'; diff --git a/app/react/kubernetes/ingresses/queries.ts b/app/react/kubernetes/ingresses/queries.ts index e2d952436..c4cbc3de7 100644 --- a/app/react/kubernetes/ingresses/queries.ts +++ b/app/react/kubernetes/ingresses/queries.ts @@ -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, diff --git a/app/react/portainer/environments/environment-groups/environment-groups.service.ts b/app/react/portainer/environments/environment-groups/environment-groups.service.ts index 12dd97e6b..32d7b11a0 100644 --- a/app/react/portainer/environments/environment-groups/environment-groups.service.ts +++ b/app/react/portainer/environments/environment-groups/environment-groups.service.ts @@ -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; -} diff --git a/app/react/portainer/environments/environment-groups/queries.ts b/app/react/portainer/environments/environment-groups/queries.ts index 9b2f937e9..8de2fae3d 100644 --- a/app/react/portainer/environments/environment-groups/queries.ts +++ b/app/react/portainer/environments/environment-groups/queries.ts @@ -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({ select, }: { select?: (group: EnvironmentGroup[]) => T } = {}) { - return useQuery(['environment-groups'], getGroups, { + return useQuery(queryKeys.base(), getGroups, { select, }); } @@ -17,17 +18,13 @@ export function useGroup( 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; } diff --git a/app/react/portainer/environments/environment-groups/queries/build-url.ts b/app/react/portainer/environments/environment-groups/queries/build-url.ts new file mode 100644 index 000000000..a2b918f7e --- /dev/null +++ b/app/react/portainer/environments/environment-groups/queries/build-url.ts @@ -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; +} diff --git a/app/react/portainer/environments/environment-groups/queries/query-keys.ts b/app/react/portainer/environments/environment-groups/queries/query-keys.ts new file mode 100644 index 000000000..b116c32b9 --- /dev/null +++ b/app/react/portainer/environments/environment-groups/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentGroupId } from '../types'; + +export const queryKeys = { + base: () => ['environment-groups'] as const, + group: (id: EnvironmentGroupId) => [...queryKeys.base(), id] as const, +}; diff --git a/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts b/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts new file mode 100644 index 000000000..6c160400a --- /dev/null +++ b/app/react/portainer/environments/environment-groups/queries/useCreateGroupMutation.ts @@ -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( + 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()]) + ) + ); +} diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts index 03d5a25e9..693e7378d 100644 --- a/app/react/portainer/environments/environment.service/index.ts +++ b/app/react/portainer/environments/environment.service/index.ts @@ -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(`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( - 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)); diff --git a/app/react/portainer/environments/queries/query-keys.ts b/app/react/portainer/environments/queries/query-keys.ts new file mode 100644 index 000000000..79f7b3cef --- /dev/null +++ b/app/react/portainer/environments/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { EnvironmentId } from '../types'; + +export const queryKeys = { + base: () => ['environments'] as const, + item: (id: EnvironmentId) => [...queryKeys.base(), id] as const, +}; diff --git a/app/react/portainer/environments/queries/useAgentVersionsList.ts b/app/react/portainer/environments/queries/useAgentVersionsList.ts index c1a3e7dec..3e51d85e4 100644 --- a/app/react/portainer/environments/queries/useAgentVersionsList.ts +++ b/app/react/portainer/environments/queries/useAgentVersionsList.ts @@ -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() + ); } diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts index 0ce1563e3..556b16bfa 100644 --- a/app/react/portainer/environments/queries/useEnvironment.ts +++ b/app/react/portainer/environments/queries/useEnvironment.ts @@ -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( 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, + } + ); } diff --git a/app/react/portainer/environments/queries/useEnvironmentList.ts b/app/react/portainer/environments/queries/useEnvironmentList.ts index de05d4772..6fbd9cb2b 100644 --- a/app/react/portainer/environments/queries/useEnvironmentList.ts +++ b/app/react/portainer/environments/queries/useEnvironmentList.ts @@ -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, diff --git a/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts b/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts new file mode 100644 index 000000000..d9330c14c --- /dev/null +++ b/app/react/portainer/environments/queries/useUpdateEnvironmentMutation.ts @@ -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; +}) { + try { + await uploadTLSFilesForEndpoint( + id, + payload.TLSCACert, + payload.TLSCert, + payload.TLSKey + ); + + const { data: endpoint } = await axios.put( + 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(`upload/tls/${type}`, cert, { + params: { folder: id }, + }); + } catch (e) { + throw parseAxiosError(e as Error); + } + } +} diff --git a/app/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation.ts b/app/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation.ts new file mode 100644 index 000000000..da4a7d4f5 --- /dev/null +++ b/app/react/portainer/environments/queries/useUpdateEnvironmentsRelationsMutation.ts @@ -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; + group: EnvironmentGroupId; + tags: Array; +} + +export async function updateEnvironmentRelations( + relations: Record +) { + try { + await axios.put(buildUrl(undefined, 'relations'), { relations }); + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to update environment relations'); + } +} diff --git a/app/react/utils.ts b/app/react/utils.ts deleted file mode 100644 index 783e5dcc6..000000000 --- a/app/react/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function isFulfilled( - input: PromiseSettledResult -): input is PromiseFulfilledResult { - return input.status === 'fulfilled'; -}