1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 13:29:41 +02:00

fix(edge): filtering of edge devices [EE-3210] (#7077)

* fix(edge): filtering of edge devices [EE-3210]

fixes [EE-3210]

changes:
- replaces `edgeDeviceFilter` with two filters:
	- `edgeDevice`
	- `edgeDeviceUntrusted`

these filters will only apply to the edge endpoints in the query (so it's possible to get both regular endpoints and edge devices).

if `edgeDevice` is true, will filter out edge agents which are not an edge device.
			false, will filter out edge devices

`edgeDeviceUntrusted` applies only when `edgeDevice` is true. then false (default) will hide the untrusted edge devices, true will show only untrusted edge devices.

fix(edge/job-create): retrieve only trusted endpoints + fix endpoint selector pagination limits onChange

fix(endpoint-groups): remove listing of untrusted edge envs (aka in waiting room)

refactor(endpoints): move filter to another function

feat(endpoints): separate edge filters

refactor(environments): change getEnv api

refactor(endpoints): use single getEnv

feat(groups): show error when failed loading envs

style(endpoints): remove unused endpointsByGroup

* chore(deps): update go to 1.18

* fix(endpoint): filter out untrusted by default

* fix(edge): show correct endpoints

* style(endpoints): fix typo

* fix(endpoints): fix swagger

* fix(admin): use new getEnv function

Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
This commit is contained in:
Chaim Lev-Ari 2022-07-19 18:00:45 +02:00 committed by GitHub
parent 1a8fe82821
commit 05357ecce5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 868 additions and 601 deletions

View file

@ -1,6 +1,6 @@
module github.com/portainer/portainer/api module github.com/portainer/portainer/api
go 1.17 go 1.18
require ( require (
github.com/Microsoft/go-winio v0.5.1 github.com/Microsoft/go-winio v0.5.1
@ -20,7 +20,7 @@ require (
github.com/go-playground/validator/v10 v10.10.1 github.com/go-playground/validator/v10 v10.10.1
github.com/gofrs/uuid v4.0.0+incompatible github.com/gofrs/uuid v4.0.0+incompatible
github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/go-cmp v0.5.6 github.com/google/go-cmp v0.5.8
github.com/gorilla/handlers v1.5.1 github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.7.3
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
@ -43,6 +43,7 @@ require (
github.com/viney-shih/go-lock v1.1.1 github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.3.6 go.etcd.io/bbolt v1.3.6
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/alecthomas/kingpin.v2 v2.2.6

View file

@ -213,8 +213,9 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
@ -437,6 +438,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -626,7 +629,6 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=

View file

@ -4,24 +4,14 @@ import (
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/utils"
)
const (
EdgeDeviceFilterAll = "all"
EdgeDeviceFilterTrusted = "trusted"
EdgeDeviceFilterUntrusted = "untrusted"
EdgeDeviceFilterNone = "none"
) )
const ( const (
@ -42,14 +32,19 @@ var endpointGroupNames map[portainer.EndpointGroupID]string
// @security jwt // @security jwt
// @produce json // @produce json
// @param start query int false "Start searching from" // @param start query int false "Start searching from"
// @param search query string false "Search query"
// @param groupId query int false "List environments(endpoints) of this group"
// @param limit query int false "Limit results to this value" // @param limit query int false "Limit results to this value"
// @param sort query int false "Sort results by this value"
// @param order query int false "Order sorted results by desc/asc" Enum("asc", "desc")
// @param search query string false "Search query"
// @param groupIds query []int false "List environments(endpoints) of these groups"
// @param status query []int false "List environments(endpoints) by this status"
// @param types query []int false "List environments(endpoints) of this type" // @param types query []int false "List environments(endpoints) of this type"
// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)" // @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)"
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags" // @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
// @param endpointIds query []int false "will return only these environments(endpoints)" // @param endpointIds query []int false "will return only these environments(endpoints)"
// @param edgeDeviceFilter query string false "will return only these edge environments, none will return only regular edge environments" Enum("all", "trusted", "untrusted", "none") // @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
// @param edgeDevice query bool false "if exists true show only edge devices, false show only regular edge endpoints. if missing, will show both types (relevant only for edge endpoints)"
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted endpoints, if false show only trusted (relevant only for edge devices, and if edgeDevice is true)"
// @param name query string false "will return only environments(endpoints) with this name" // @param name query string false "will return only environments(endpoints) with this name"
// @success 200 {array} portainer.Endpoint "Endpoints" // @success 200 {array} portainer.Endpoint "Endpoints"
// @failure 500 "Server error" // @failure 500 "Server error"
@ -60,103 +55,42 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
start-- start--
} }
search, _ := request.RetrieveQueryParameter(r, "search", true)
if search != "" {
search = strings.ToLower(search)
}
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true) limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
sortField, _ := request.RetrieveQueryParameter(r, "sort", true) sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true) sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
var endpointTypes []int
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
var tagIDs []portainer.TagID
request.RetrieveJSONQueryParameter(r, "tagIds", &tagIDs, true)
tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true)
var endpointIDs []portainer.EndpointID
request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true)
var statuses []int
request.RetrieveJSONQueryParameter(r, "status", &statuses, true)
var groupIDs []int
request.RetrieveJSONQueryParameter(r, "groupIds", &groupIDs, true)
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err} return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
} }
endpoints, err := handler.DataStore.Endpoint().Endpoints() endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} return httperror.InternalServerError("Unable to retrieve environments from the database", err)
} }
settings, err := handler.DataStore.Settings().Settings() settings, err := handler.DataStore.Settings().Settings()
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} return httperror.InternalServerError("Unable to retrieve settings from the database", err)
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
query, err := parseQuery(r)
if err != nil {
return httperror.BadRequest("Invalid query parameters", err)
} }
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
totalAvailableEndpoints := len(filteredEndpoints)
if groupID != 0 { filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, settings)
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, []int{groupID}) if err != nil {
return httperror.InternalServerError("Unable to filter endpoints", err)
} }
if endpointIDs != nil {
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
}
if len(groupIDs) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, groupIDs)
}
name, _ := request.RetrieveQueryParameter(r, "name", true)
if name != "" {
filteredEndpoints = filterEndpointsByName(filteredEndpoints, name)
}
edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false)
if edgeDeviceFilter != "" {
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
}
if len(statuses) > 0 {
filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, statuses, settings)
}
if search != "" {
tags, err := handler.DataStore.Tag().Tags()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err}
}
tagsMap := make(map[portainer.TagID]string)
for _, tag := range tags {
tagsMap[tag.ID] = tag.Name
}
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, tagsMap, search)
}
if endpointTypes != nil {
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, endpointTypes)
}
if tagIDs != nil {
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch)
}
// Sort endpoints by field
sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc") sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc")
filteredEndpointCount := len(filteredEndpoints) filteredEndpointCount := len(filteredEndpoints)
@ -196,64 +130,6 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
return endpoints[start:end] return endpoints[start:end]
} }
func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []int) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if utils.Contains(endpointGroupIDs, int(endpoint.GroupID)) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
if endpointMatchSearchCriteria(&endpoint, endpointTags, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
continue
}
if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, tagsMap, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []int, settings *portainer.Settings) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
status := endpoint.Status
if endpointutils.IsEdgeEndpoint(&endpoint) {
isCheckValid := false
edgeCheckinInterval := endpoint.EdgeCheckinInterval
if endpoint.EdgeCheckinInterval == 0 {
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
}
status = portainer.EndpointStatusDown // Offline
if isCheckValid {
status = portainer.EndpointStatusUp // Online
}
}
if utils.Contains(statuses, int(status)) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, sortField string, isSortDesc bool) { func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, sortField string, isSortDesc bool) {
switch sortField { switch sortField {
@ -294,123 +170,6 @@ func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []porta
} }
} }
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
return true
}
if strings.Contains(strings.ToLower(endpoint.URL), searchCriteria) {
return true
}
if endpoint.Status == portainer.EndpointStatusUp && searchCriteria == "up" {
return true
} else if endpoint.Status == portainer.EndpointStatusDown && searchCriteria == "down" {
return true
}
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
return false
}
func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
for _, group := range endpointGroups {
if group.ID == endpoint.GroupID {
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
tags := convertTagIDsToTags(tagsMap, group.TagIDs)
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
}
}
return false
}
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
typeSet := map[portainer.EndpointType]bool{}
for _, endpointType := range endpointTypes {
typeSet[portainer.EndpointType(endpointType)] = true
}
for _, endpoint := range endpoints {
if typeSet[endpoint.Type] {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter string) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if shouldReturnEdgeDevice(endpoint, edgeDeviceFilter) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceFilter string) bool {
// none - return all endpoints that are not edge devices
if edgeDeviceFilter == EdgeDeviceFilterNone && !endpoint.IsEdgeDevice {
return true
}
if !endpointutils.IsEdgeEndpoint(&endpoint) {
return false
}
switch edgeDeviceFilter {
case EdgeDeviceFilterAll:
return true
case EdgeDeviceFilterTrusted:
return endpoint.UserTrusted
case EdgeDeviceFilterUntrusted:
return !endpoint.UserTrusted
}
return false
}
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
tags := make([]string, 0)
for _, tagID := range tagIDs {
tags = append(tags, tagsMap[tagID])
}
return tags
}
func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups)
endpointMatched := false
if partialMatch {
endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs)
} else {
endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs)
}
if endpointMatched {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup { func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup {
var endpointGroup portainer.EndpointGroup var endpointGroup portainer.EndpointGroup
for _, group := range groups { for _, group := range groups {
@ -421,72 +180,3 @@ func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.Endp
} }
return endpointGroup return endpointGroup
} }
func endpointPartialMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
tagSet := make(map[portainer.TagID]bool)
for _, tagID := range tagIDs {
tagSet[tagID] = true
}
for _, tagID := range endpoint.TagIDs {
if tagSet[tagID] {
return true
}
}
for _, tagID := range endpointGroup.TagIDs {
if tagSet[tagID] {
return true
}
}
return false
}
func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
missingTags := make(map[portainer.TagID]bool)
for _, tagID := range tagIDs {
missingTags[tagID] = true
}
for _, tagID := range endpoint.TagIDs {
if missingTags[tagID] {
delete(missingTags, tagID)
}
}
for _, tagID := range endpointGroup.TagIDs {
if missingTags[tagID] {
delete(missingTags, tagID)
}
}
return len(missingTags) == 0
}
func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
idsSet := make(map[portainer.EndpointID]bool)
for _, id := range ids {
idsSet[id] = true
}
for _, endpoint := range endpoints {
if idsSet[endpoint.ID] {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsByName(endpoints []portainer.Endpoint, name string) []portainer.Endpoint {
if name == "" {
return endpoints
}
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if endpoint.Name == name {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}

View file

@ -16,66 +16,64 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
type endpointListEdgeDeviceTest struct { type endpointListTest struct {
title string title string
expected []portainer.EndpointID expected []portainer.EndpointID
filter string
} }
func Test_endpointList(t *testing.T) { func Test_endpointList_edgeDeviceFilter(t *testing.T) {
var err error
is := assert.New(t)
_, store, teardown := datastore.MustNewTestStore(true, true) trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
defer teardown() untrustedEdgeDevice := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
trustedEndpoint := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
untrustedEndpoint := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularEndpoint := portainer.Endpoint{ID: 5, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.DockerEnvironment} regularEndpoint := portainer.Endpoint{ID: 5, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.DockerEnvironment}
endpoints := []portainer.Endpoint{ handler, teardown := setup(t, []portainer.Endpoint{
trustedEndpoint, trustedEdgeDevice,
untrustedEndpoint, untrustedEdgeDevice,
regularUntrustedEdgeEndpoint, regularUntrustedEdgeEndpoint,
regularTrustedEdgeEndpoint, regularTrustedEdgeEndpoint,
regularEndpoint, regularEndpoint,
})
defer teardown()
type endpointListEdgeDeviceTest struct {
endpointListTest
edgeDevice *bool
edgeDeviceUntrusted bool
} }
for _, endpoint := range endpoints {
err = store.Endpoint().Create(&endpoint)
is.NoError(err, "error creating environment")
}
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
bouncer := helper.NewTestRequestBouncer()
h := NewHandler(bouncer, nil)
h.DataStore = store
h.ComposeStackManager = testhelpers.NewComposeStackManager()
tests := []endpointListEdgeDeviceTest{ tests := []endpointListEdgeDeviceTest{
{ {
"should show all edge endpoints", endpointListTest: endpointListTest{
[]portainer.EndpointID{trustedEndpoint.ID, untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID}, "should show all endpoints expect of the untrusted devices",
EdgeDeviceFilterAll, []portainer.EndpointID{trustedEdgeDevice.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID, regularEndpoint.ID},
},
edgeDevice: nil,
}, },
{ {
"should show only trusted edge devices", endpointListTest: endpointListTest{
[]portainer.EndpointID{trustedEndpoint.ID, regularTrustedEdgeEndpoint.ID}, "should show only trusted edge devices and regular endpoints",
EdgeDeviceFilterTrusted, []portainer.EndpointID{trustedEdgeDevice.ID, regularEndpoint.ID},
},
edgeDevice: BoolAddr(true),
}, },
{ {
"should show only untrusted edge devices", endpointListTest: endpointListTest{
[]portainer.EndpointID{untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID}, "should show only untrusted edge devices and regular endpoints",
EdgeDeviceFilterUntrusted, []portainer.EndpointID{untrustedEdgeDevice.ID, regularEndpoint.ID},
},
edgeDevice: BoolAddr(true),
edgeDeviceUntrusted: true,
}, },
{ {
"should show no edge devices", endpointListTest: endpointListTest{
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID}, "should show no edge devices",
EdgeDeviceFilterNone, []portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
},
edgeDevice: BoolAddr(false),
}, },
} }
@ -83,8 +81,13 @@ func Test_endpointList(t *testing.T) {
t.Run(test.title, func(t *testing.T) { t.Run(test.title, func(t *testing.T) {
is := assert.New(t) is := assert.New(t)
req := buildEndpointListRequest(test.filter) query := fmt.Sprintf("edgeDeviceUntrusted=%v&", test.edgeDeviceUntrusted)
resp, err := doEndpointListRequest(req, h, is) if test.edgeDevice != nil {
query += fmt.Sprintf("edgeDevice=%v&", *test.edgeDevice)
}
req := buildEndpointListRequest(query)
resp, err := doEndpointListRequest(req, handler, is)
is.NoError(err) is.NoError(err)
is.Equal(len(test.expected), len(resp)) is.Equal(len(test.expected), len(resp))
@ -100,8 +103,28 @@ func Test_endpointList(t *testing.T) {
} }
} }
func buildEndpointListRequest(filter string) *http.Request { func setup(t *testing.T, endpoints []portainer.Endpoint) (handler *Handler, teardown func()) {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/endpoints?edgeDeviceFilter=%s", filter), nil) is := assert.New(t)
_, store, teardown := datastore.MustNewTestStore(true, true)
for _, endpoint := range endpoints {
err := store.Endpoint().Create(&endpoint)
is.NoError(err, "error creating environment")
}
err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
bouncer := helper.NewTestRequestBouncer()
handler = NewHandler(bouncer, nil)
handler.DataStore = store
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
return handler, teardown
}
func buildEndpointListRequest(query string) *http.Request {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/endpoints?%s", query), nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx) req = req.WithContext(ctx)

View file

@ -0,0 +1,415 @@
package endpoints
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
"golang.org/x/exp/slices"
)
type EnvironmentsQuery struct {
search string
types []portainer.EndpointType
tagIds []portainer.TagID
endpointIds []portainer.EndpointID
tagsPartialMatch bool
groupIds []portainer.EndpointGroupID
status []portainer.EndpointStatus
edgeDevice *bool
edgeDeviceUntrusted bool
name string
}
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
search, _ := request.RetrieveQueryParameter(r, "search", true)
if search != "" {
search = strings.ToLower(search)
}
status, err := getNumberArrayQueryParameter[portainer.EndpointStatus](r, "status")
if err != nil {
return EnvironmentsQuery{}, err
}
groupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "groupIds")
if err != nil {
return EnvironmentsQuery{}, err
}
endpointTypes, err := getNumberArrayQueryParameter[portainer.EndpointType](r, "types")
if err != nil {
return EnvironmentsQuery{}, err
}
tagIDs, err := getNumberArrayQueryParameter[portainer.TagID](r, "tagIds")
if err != nil {
return EnvironmentsQuery{}, err
}
tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true)
endpointIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds")
if err != nil {
return EnvironmentsQuery{}, err
}
name, _ := request.RetrieveQueryParameter(r, "name", true)
edgeDeviceParam, _ := request.RetrieveQueryParameter(r, "edgeDevice", true)
var edgeDevice *bool
if edgeDeviceParam != "" {
edgeDevice = BoolAddr(edgeDeviceParam == "true")
}
edgeDeviceUntrusted, _ := request.RetrieveBooleanQueryParameter(r, "edgeDeviceUntrusted", true)
return EnvironmentsQuery{
search: search,
types: endpointTypes,
tagIds: tagIDs,
endpointIds: endpointIDs,
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
edgeDevice: edgeDevice,
edgeDeviceUntrusted: edgeDeviceUntrusted,
name: name,
}, nil
}
func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.Endpoint, query EnvironmentsQuery, groups []portainer.EndpointGroup, settings *portainer.Settings) ([]portainer.Endpoint, int, error) {
totalAvailableEndpoints := len(filteredEndpoints)
if len(query.endpointIds) > 0 {
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
}
if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
if query.name != "" {
filteredEndpoints = filterEndpointsByName(filteredEndpoints, query.name)
}
if query.edgeDevice != nil {
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, *query.edgeDevice, query.edgeDeviceUntrusted)
} else {
// If the edgeDevice parameter is not set, we need to filter out the untrusted edge devices
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !endpoint.IsEdgeDevice || endpoint.UserTrusted
})
}
if len(query.status) > 0 {
filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, query.status, settings)
}
if query.search != "" {
tags, err := handler.DataStore.Tag().Tags()
if err != nil {
return nil, 0, errors.WithMessage(err, "Unable to retrieve tags from the database")
}
tagsMap := make(map[portainer.TagID]string)
for _, tag := range tags {
tagsMap[tag.ID] = tag.Name
}
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, groups, tagsMap, query.search)
}
if len(query.types) > 0 {
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, query.types)
}
if len(query.tagIds) > 0 {
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, query.tagIds, groups, query.tagsPartialMatch)
}
return filteredEndpoints, totalAvailableEndpoints, nil
}
func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []portainer.EndpointGroupID) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if slices.Contains(endpointGroupIDs, endpoint.GroupID) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
if endpointMatchSearchCriteria(&endpoint, endpointTags, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
continue
}
if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, tagsMap, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portainer.EndpointStatus, settings *portainer.Settings) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
status := endpoint.Status
if endpointutils.IsEdgeEndpoint(&endpoint) {
isCheckValid := false
edgeCheckinInterval := endpoint.EdgeCheckinInterval
if endpoint.EdgeCheckinInterval == 0 {
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
}
status = portainer.EndpointStatusDown // Offline
if isCheckValid {
status = portainer.EndpointStatusUp // Online
}
}
if slices.Contains(statuses, status) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
return true
}
if strings.Contains(strings.ToLower(endpoint.URL), searchCriteria) {
return true
}
if endpoint.Status == portainer.EndpointStatusUp && searchCriteria == "up" {
return true
} else if endpoint.Status == portainer.EndpointStatusDown && searchCriteria == "down" {
return true
}
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
return false
}
func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
for _, group := range endpointGroups {
if group.ID == endpoint.GroupID {
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
tags := convertTagIDsToTags(tagsMap, group.TagIDs)
for _, tag := range tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
}
}
return false
}
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []portainer.EndpointType) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
typeSet := map[portainer.EndpointType]bool{}
for _, endpointType := range endpointTypes {
typeSet[portainer.EndpointType(endpointType)] = true
}
for _, endpoint := range endpoints {
if typeSet[endpoint.Type] {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDevice bool, untrusted bool) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if shouldReturnEdgeDevice(endpoint, edgeDevice, untrusted) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceParam bool, untrustedParam bool) bool {
if !endpointutils.IsEdgeEndpoint(&endpoint) {
return true
}
if !edgeDeviceParam {
return !endpoint.IsEdgeDevice
}
return endpoint.IsEdgeDevice && endpoint.UserTrusted == !untrustedParam
}
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
tags := make([]string, 0)
for _, tagID := range tagIDs {
tags = append(tags, tagsMap[tagID])
}
return tags
}
func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups)
endpointMatched := false
if partialMatch {
endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs)
} else {
endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs)
}
if endpointMatched {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func endpointPartialMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
tagSet := make(map[portainer.TagID]bool)
for _, tagID := range tagIDs {
tagSet[tagID] = true
}
for _, tagID := range endpoint.TagIDs {
if tagSet[tagID] {
return true
}
}
for _, tagID := range endpointGroup.TagIDs {
if tagSet[tagID] {
return true
}
}
return false
}
func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
missingTags := make(map[portainer.TagID]bool)
for _, tagID := range tagIDs {
missingTags[tagID] = true
}
for _, tagID := range endpoint.TagIDs {
if missingTags[tagID] {
delete(missingTags, tagID)
}
}
for _, tagID := range endpointGroup.TagIDs {
if missingTags[tagID] {
delete(missingTags, tagID)
}
}
return len(missingTags) == 0
}
func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
idsSet := make(map[portainer.EndpointID]bool)
for _, id := range ids {
idsSet[id] = true
}
for _, endpoint := range endpoints {
if idsSet[endpoint.ID] {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsByName(endpoints []portainer.Endpoint, name string) []portainer.Endpoint {
if name == "" {
return endpoints
}
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if endpoint.Name == name {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filter(endpoints []portainer.Endpoint, predicate func(endpoint portainer.Endpoint) bool) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if predicate(endpoint) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func getArrayQueryParameter(r *http.Request, parameter string) []string {
list, exists := r.Form[fmt.Sprintf("%s[]", parameter)]
if !exists {
list = []string{}
}
return list
}
func getNumberArrayQueryParameter[T ~int](r *http.Request, parameter string) ([]T, error) {
list := getArrayQueryParameter(r, parameter)
if list == nil {
return []T{}, nil
}
var result []T
for _, item := range list {
number, err := strconv.Atoi(item)
if err != nil {
return nil, errors.Wrapf(err, "Unable to parse parameter %s", parameter)
}
result = append(result, T(number))
}
return result, nil
}

View file

@ -0,0 +1,119 @@
package endpoints
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
type filterTest struct {
title string
expected []portainer.EndpointID
query EnvironmentsQuery
}
func Test_Filter_edgeDeviceFilter(t *testing.T) {
trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
untrustedEdgeDevice := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment}
endpoints := []portainer.Endpoint{
trustedEdgeDevice,
untrustedEdgeDevice,
regularUntrustedEdgeEndpoint,
regularTrustedEdgeEndpoint,
regularEndpoint,
}
handler, teardown := setupFilterTest(t, endpoints)
defer teardown()
tests := []filterTest{
{
"should show all edge endpoints except of the untrusted devices",
[]portainer.EndpointID{trustedEdgeDevice.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
EnvironmentsQuery{
types: []portainer.EndpointType{portainer.EdgeAgentOnDockerEnvironment, portainer.EdgeAgentOnKubernetesEnvironment},
},
},
{
"should show only trusted edge devices and other regular endpoints",
[]portainer.EndpointID{trustedEdgeDevice.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeDevice: BoolAddr(true),
},
},
{
"should show only untrusted edge devices and other regular endpoints",
[]portainer.EndpointID{untrustedEdgeDevice.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeDevice: BoolAddr(true),
edgeDeviceUntrusted: true,
},
},
{
"should show no edge devices",
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
EnvironmentsQuery{
edgeDevice: BoolAddr(false),
},
},
}
runTests(tests, t, handler, endpoints)
}
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {
runTest(t, test, handler, endpoints)
})
}
}
func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portainer.Endpoint) {
is := assert.New(t)
filteredEndpoints, _, err := handler.filterEndpointsByQuery(endpoints, test.query, []portainer.EndpointGroup{}, &portainer.Settings{})
is.NoError(err)
is.Equal(len(test.expected), len(filteredEndpoints))
respIds := []portainer.EndpointID{}
for _, endpoint := range filteredEndpoints {
respIds = append(respIds, endpoint.ID)
}
is.ElementsMatch(test.expected, respIds)
}
func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) (handler *Handler, teardown func()) {
is := assert.New(t)
_, store, teardown := datastore.MustNewTestStore(true, true)
for _, endpoint := range endpoints {
err := store.Endpoint().Create(&endpoint)
is.NoError(err, "error creating environment")
}
err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
bouncer := helper.NewTestRequestBouncer()
handler = NewHandler(bouncer, nil)
handler.DataStore = store
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
return handler, teardown
}

View file

@ -0,0 +1,6 @@
package endpoints
func BoolAddr(b bool) *bool {
boolVar := b
return &boolVar
}

View file

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
import { Environment } from '@/portainer/environments/types'; import { EdgeTypes, Environment } from '@/portainer/environments/types';
import { useDebounce } from '@/portainer/hooks/useDebounce'; import { useDebounce } from '@/portainer/hooks/useDebounce';
import { useSearchBarState } from '@@/datatables/SearchBar'; import { useSearchBarState } from '@@/datatables/SearchBar';
@ -89,8 +89,9 @@ function Loader({ children, storageKey }: LoaderProps) {
const { environments, isLoading, totalCount } = useEnvironmentList( const { environments, isLoading, totalCount } = useEnvironmentList(
{ {
edgeDeviceFilter: 'trusted', edgeDevice: true,
search: debouncedSearchValue, search: debouncedSearchValue,
types: EdgeTypes,
...pagination, ...pagination,
}, },
settings.autoRefreshRate * 1000 settings.autoRefreshRate * 1000

View file

@ -2,6 +2,7 @@ import { useRouter } from '@uirouter/react';
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { EdgeTypes } from '@/portainer/environments/types';
import { InformationPanel } from '@@/InformationPanel'; import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
@ -15,7 +16,9 @@ export function WaitingRoomView() {
const storageKey = 'edge-devices-waiting-room'; const storageKey = 'edge-devices-waiting-room';
const router = useRouter(); const router = useRouter();
const { environments, isLoading, totalCount } = useEnvironmentList({ const { environments, isLoading, totalCount } = useEnvironmentList({
edgeDeviceFilter: 'untrusted', edgeDevice: true,
edgeDeviceUntrusted: true,
types: EdgeTypes,
}); });
if (process.env.PORTAINER_EDITION !== 'BE') { if (process.env.PORTAINER_EDITION !== 'BE') {

View file

@ -1,10 +1,11 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { confirmAsync } from '@/portainer/services/modal.service/confirm'; import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { EdgeTypes } from '@/portainer/environments/types';
import { getEnvironments } from '@/portainer/environments/environment.service';
export class EdgeGroupFormController { export class EdgeGroupFormController {
/* @ngInject */ /* @ngInject */
constructor(EndpointService, $async, $scope) { constructor($async, $scope) {
this.EndpointService = EndpointService;
this.$async = $async; this.$async = $async;
this.$scope = $scope; this.$scope = $scope;
@ -19,7 +20,6 @@ export class EdgeGroupFormController {
}; };
this.associateEndpoint = this.associateEndpoint.bind(this); this.associateEndpoint = this.associateEndpoint.bind(this);
this.dissociateEndpointAsync = this.dissociateEndpointAsync.bind(this);
this.dissociateEndpoint = this.dissociateEndpoint.bind(this); this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this); this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this);
this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this); this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this);
@ -49,30 +49,28 @@ export class EdgeGroupFormController {
} }
dissociateEndpoint(endpoint) { dissociateEndpoint(endpoint) {
return this.$async(this.dissociateEndpointAsync, endpoint); return this.$async(async () => {
} const confirmed = await confirmAsync({
title: 'Confirm action',
message: 'Removing the environment from this group will remove its corresponding edge stacks',
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
async dissociateEndpointAsync(endpoint) { if (!confirmed) {
const confirmed = await confirmAsync({ return;
title: 'Confirm action', }
message: 'Removing the environment from this group will remove its corresponding edge stacks',
buttons: { this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
}); });
if (!confirmed) {
return;
}
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
} }
getDynamicEndpoints() { getDynamicEndpoints() {
@ -82,9 +80,9 @@ export class EdgeGroupFormController {
async getDynamicEndpointsAsync() { async getDynamicEndpointsAsync() {
const { pageNumber, limit, search } = this.endpoints.state; const { pageNumber, limit, search } = this.endpoints.state;
const start = (pageNumber - 1) * limit + 1; const start = (pageNumber - 1) * limit + 1;
const query = { search, types: [4, 7], tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch }; const query = { search, types: EdgeTypes, tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
const response = await this.EndpointService.endpoints(start, limit, query); const response = await getEnvironments({ start, limit, query });
const totalCount = parseInt(response.totalCount, 10); const totalCount = parseInt(response.totalCount, 10);
this.endpoints.value = response.value; this.endpoints.value = response.value;

View file

@ -1,8 +1,9 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { getEnvironments } from '@/portainer/environments/environment.service';
export class EdgeJobController { export class EdgeJobController {
/* @ngInject */ /* @ngInject */
constructor($async, $q, $state, $window, ModalService, EdgeJobService, EndpointService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) { constructor($async, $q, $state, $window, ModalService, EdgeJobService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) {
this.state = { this.state = {
actionInProgress: false, actionInProgress: false,
showEditorTab: false, showEditorTab: false,
@ -15,7 +16,6 @@ export class EdgeJobController {
this.$window = $window; this.$window = $window;
this.ModalService = ModalService; this.ModalService = ModalService;
this.EdgeJobService = EdgeJobService; this.EdgeJobService = EdgeJobService;
this.EndpointService = EndpointService;
this.FileSaver = FileSaver; this.FileSaver = FileSaver;
this.GroupService = GroupService; this.GroupService = GroupService;
this.HostBrowserService = HostBrowserService; this.HostBrowserService = HostBrowserService;
@ -114,7 +114,7 @@ export class EdgeJobController {
const results = await this.EdgeJobService.jobResults(id); const results = await this.EdgeJobService.jobResults(id);
if (results.length > 0) { if (results.length > 0) {
const endpointIds = _.map(results, (result) => result.EndpointId); const endpointIds = _.map(results, (result) => result.EndpointId);
const endpoints = await this.EndpointService.endpoints(undefined, undefined, { endpointIds }); const endpoints = await getEnvironments({ query: { endpointIds } });
this.results = this.associateEndpointsToResults(results, endpoints.value); this.results = this.associateEndpointsToResults(results, endpoints.value);
} else { } else {
this.results = results; this.results = results;
@ -155,7 +155,7 @@ export class EdgeJobController {
if (results.length > 0) { if (results.length > 0) {
const endpointIds = _.map(results, (result) => result.EndpointId); const endpointIds = _.map(results, (result) => result.EndpointId);
const endpoints = await this.EndpointService.endpoints(undefined, undefined, { endpointIds }); const endpoints = await getEnvironments({ query: { endpointIds } });
this.results = this.associateEndpointsToResults(results, endpoints.value); this.results = this.associateEndpointsToResults(results, endpoints.value);
} else { } else {
this.results = results; this.results = results;

View file

@ -1,15 +1,15 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { getEnvironments } from '@/portainer/environments/environment.service';
export class EditEdgeStackViewController { export class EditEdgeStackViewController {
/* @ngInject */ /* @ngInject */
constructor($async, $state, $window, ModalService, EdgeGroupService, EdgeStackService, EndpointService, Notifications) { constructor($async, $state, $window, ModalService, EdgeGroupService, EdgeStackService, Notifications) {
this.$async = $async; this.$async = $async;
this.$state = $state; this.$state = $state;
this.$window = $window; this.$window = $window;
this.ModalService = ModalService; this.ModalService = ModalService;
this.EdgeGroupService = EdgeGroupService; this.EdgeGroupService = EdgeGroupService;
this.EdgeStackService = EdgeStackService; this.EdgeStackService = EdgeStackService;
this.EndpointService = EndpointService;
this.Notifications = Notifications; this.Notifications = Notifications;
this.stack = null; this.stack = null;
@ -99,8 +99,8 @@ export class EditEdgeStackViewController {
async getPaginatedEndpointsAsync(lastId, limit, search) { async getPaginatedEndpointsAsync(lastId, limit, search) {
try { try {
const query = { search, types: [4, 7], endpointIds: this.stackEndpointIds }; const query = { search, endpointIds: this.stackEndpointIds };
const { value, totalCount } = await this.EndpointService.endpoints(lastId, limit, query); const { value, totalCount } = await getEnvironments({ start: lastId, limit, query });
const endpoints = _.map(value, (endpoint) => { const endpoints = _.map(value, (endpoint) => {
const status = this.stack.Status[endpoint.Id]; const status = this.stack.Status[endpoint.Id];
endpoint.Status = status; endpoint.Status = status;

View file

@ -11,7 +11,7 @@
loaded="$ctrl.loaded" loaded="$ctrl.loaded"
page-type="$ctrl.pageType" page-type="$ctrl.pageType"
table-type="available" table-type="available"
retrieve-page="$ctrl.getPaginatedEndpoints" retrieve-page="$ctrl.getAvailableEndpoints"
dataset="$ctrl.endpoints.available" dataset="$ctrl.endpoints.available"
entry-click="$ctrl.associateEndpoint" entry-click="$ctrl.associateEndpoint"
pagination-state="$ctrl.state.available" pagination-state="$ctrl.state.available"
@ -34,7 +34,7 @@
loaded="$ctrl.loaded" loaded="$ctrl.loaded"
page-type="$ctrl.pageType" page-type="$ctrl.pageType"
table-type="associated" table-type="associated"
retrieve-page="$ctrl.getPaginatedEndpoints" retrieve-page="$ctrl.getAssociatedEndpoints"
dataset="$ctrl.endpoints.associated" dataset="$ctrl.endpoints.associated"
entry-click="$ctrl.dissociateEndpoint" entry-click="$ctrl.dissociateEndpoint"
pagination-state="$ctrl.state.associated" pagination-state="$ctrl.state.associated"

View file

@ -1,11 +1,13 @@
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash-es'; import _ from 'lodash-es';
import { EdgeTypes } from '@/portainer/environments/types';
import { getEnvironments } from '@/portainer/environments/environment.service';
class AssoicatedEndpointsSelectorController { class AssoicatedEndpointsSelectorController {
/* @ngInject */ /* @ngInject */
constructor($async, EndpointService) { constructor($async) {
this.$async = $async; this.$async = $async;
this.EndpointService = EndpointService;
this.state = { this.state = {
available: { available: {
@ -27,12 +29,11 @@ class AssoicatedEndpointsSelectorController {
available: null, available: null,
}; };
this.getEndpoints = this.getEndpoints.bind(this); this.getAvailableEndpoints = this.getAvailableEndpoints.bind(this);
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
this.getAssociatedEndpoints = this.getAssociatedEndpoints.bind(this); this.getAssociatedEndpoints = this.getAssociatedEndpoints.bind(this);
this.getAssociatedEndpointsAsync = this.getAssociatedEndpointsAsync.bind(this);
this.associateEndpoint = this.associateEndpoint.bind(this); this.associateEndpoint = this.associateEndpoint.bind(this);
this.dissociateEndpoint = this.dissociateEndpoint.bind(this); this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
this.loadData = this.loadData.bind(this);
} }
$onInit() { $onInit() {
@ -46,41 +47,41 @@ class AssoicatedEndpointsSelectorController {
} }
loadData() { loadData() {
this.getAvailableEndpoints();
this.getAssociatedEndpoints(); this.getAssociatedEndpoints();
this.getEndpoints();
} }
getEndpoints() { /* #region internal queries to retrieve endpoints per "side" of the selector */
return this.$async(this.getEndpointsAsync); getAvailableEndpoints() {
} return this.$async(async () => {
const { start, search, limit } = this.getPaginationData('available');
const query = { search, types: EdgeTypes };
async getEndpointsAsync() { const response = await getEnvironments({ start, limit, query });
const { start, search, limit } = this.getPaginationData('available');
const query = { search, types: [4, 7] };
const response = await this.EndpointService.endpoints(start, limit, query); const endpoints = _.filter(response.value, (endpoint) => !_.includes(this.endpointIds, endpoint.Id));
this.setTableData('available', endpoints, response.totalCount);
const endpoints = _.filter(response.value, (endpoint) => !_.includes(this.endpointIds, endpoint.Id)); this.noEndpoints = this.state.available.totalCount === 0;
this.setTableData('available', endpoints, response.totalCount); });
this.noEndpoints = this.state.available.totalCount === 0;
} }
getAssociatedEndpoints() { getAssociatedEndpoints() {
return this.$async(this.getAssociatedEndpointsAsync); return this.$async(async () => {
} let response = { value: [], totalCount: 0 };
if (this.endpointIds.length > 0) {
async getAssociatedEndpointsAsync() { // fetch only if already has associated endpoints
let response = { value: [], totalCount: 0 }; const { start, search, limit } = this.getPaginationData('associated');
if (this.endpointIds.length > 0) { const query = { search, types: EdgeTypes, endpointIds: this.endpointIds };
const { start, search, limit } = this.getPaginationData('associated');
const query = { search, types: [4, 7], endpointIds: this.endpointIds }; response = await getEnvironments({ start, limit, query });
}
response = await this.EndpointService.endpoints(start, limit, query);
} this.setTableData('associated', response.value, response.totalCount);
});
this.setTableData('associated', response.value, response.totalCount);
} }
/* #endregion */
/* #region On endpoint click (either available or associated) */
associateEndpoint(endpoint) { associateEndpoint(endpoint) {
this.onAssociate(endpoint); this.onAssociate(endpoint);
} }
@ -88,7 +89,9 @@ class AssoicatedEndpointsSelectorController {
dissociateEndpoint(endpoint) { dissociateEndpoint(endpoint) {
this.onDissociate(endpoint); this.onDissociate(endpoint);
} }
/* #endregion */
/* #region Utils funcs */
getPaginationData(tableType) { getPaginationData(tableType) {
const { pageNumber, limit, search } = this.state[tableType]; const { pageNumber, limit, search } = this.state[tableType];
const start = (pageNumber - 1) * limit + 1; const start = (pageNumber - 1) * limit + 1;
@ -100,6 +103,7 @@ class AssoicatedEndpointsSelectorController {
this.endpoints[tableType] = endpoints; this.endpoints[tableType] = endpoints;
this.state[tableType].totalCount = parseInt(totalCount, 10); this.state[tableType].totalCount = parseInt(totalCount, 10);
} }
/* #endregion */
} }
angular.module('portainer.app').controller('AssoicatedEndpointsSelectorController', AssoicatedEndpointsSelectorController); angular.module('portainer.app').controller('AssoicatedEndpointsSelectorController', AssoicatedEndpointsSelectorController);

View file

@ -1,12 +1,13 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import angular from 'angular'; import angular from 'angular';
import { endpointsByGroup } from '@/portainer/environments/environment.service';
import { notifyError } from '@/portainer/services/notifications';
class GroupFormController { class GroupFormController {
/* @ngInject */ /* @ngInject */
constructor($q, $scope, EndpointService, GroupService, Notifications, Authentication) { constructor($async, $scope, GroupService, Notifications, Authentication) {
this.$q = $q; this.$async = $async;
this.$scope = $scope; this.$scope = $scope;
this.EndpointService = EndpointService;
this.GroupService = GroupService; this.GroupService = GroupService;
this.Notifications = Notifications; this.Notifications = Notifications;
this.Authentication = Authentication; this.Authentication = Authentication;
@ -75,23 +76,27 @@ class GroupFormController {
} }
getPaginatedEndpointsByGroup(pageType, tableType) { getPaginatedEndpointsByGroup(pageType, tableType) {
if (tableType === 'available') { this.$async(async () => {
const context = this.state.available; try {
const start = (context.pageNumber - 1) * context.limit + 1; if (tableType === 'available') {
this.EndpointService.endpointsByGroup(start, context.limit, context.filter, 1).then((data) => { const context = this.state.available;
this.availableEndpoints = data.value; const start = (context.pageNumber - 1) * context.limit + 1;
this.state.available.totalCount = data.totalCount; const data = await endpointsByGroup(1, start, context.limit, { search: context.filter });
}); this.availableEndpoints = data.value;
} else if (tableType === 'associated' && pageType === 'edit') { this.state.available.totalCount = data.totalCount;
const groupId = this.model.Id ? this.model.Id : 1; } else if (tableType === 'associated' && pageType === 'edit') {
const context = this.state.associated; const groupId = this.model.Id ? this.model.Id : 1;
const start = (context.pageNumber - 1) * context.limit + 1; const context = this.state.associated;
this.EndpointService.endpointsByGroup(start, context.limit, context.filter, groupId).then((data) => { const start = (context.pageNumber - 1) * context.limit + 1;
this.associatedEndpoints = data.value; const data = await endpointsByGroup(groupId, start, context.limit, { search: context.filter });
this.state.associated.totalCount = data.totalCount; this.associatedEndpoints = data.value;
}); this.state.associated.totalCount = data.totalCount;
} }
// ignore (associated + create) group as there is no backend pagination for this table // ignore (associated + create) group as there is no backend pagination for this table
} catch (err) {
notifyError('Failure', err, 'Failed getting endpoints for group');
}
});
} }
} }

View file

@ -12,61 +12,50 @@ import type {
EnvironmentStatus, EnvironmentStatus,
} from '../types'; } from '../types';
import { arrayToJson, buildUrl } from './utils'; import { buildUrl } from './utils';
export interface EnvironmentsQueryParams { export interface EnvironmentsQueryParams {
search?: string; search?: string;
types?: EnvironmentType[]; types?: EnvironmentType[] | readonly EnvironmentType[];
tagIds?: TagId[]; tagIds?: TagId[];
endpointIds?: EnvironmentId[]; endpointIds?: EnvironmentId[];
tagsPartialMatch?: boolean; tagsPartialMatch?: boolean;
groupIds?: EnvironmentGroupId[]; groupIds?: EnvironmentGroupId[];
status?: EnvironmentStatus[]; status?: EnvironmentStatus[];
sort?: string; edgeDevice?: boolean;
order?: 'asc' | 'desc'; edgeDeviceUntrusted?: boolean;
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted' | 'none'; provisioned?: boolean;
name?: string; name?: string;
} }
export async function getEndpoints( export interface GetEnvironmentsOptions {
start: number, start?: number;
limit: number, limit?: number;
sort?: { by?: string; order?: 'asc' | 'desc' };
query?: EnvironmentsQueryParams;
}
export async function getEnvironments(
{ {
types, start,
tagIds, limit,
endpointIds, sort = { by: '', order: 'asc' },
status, query = {},
groupIds, }: GetEnvironmentsOptions = { query: {} }
...query
}: EnvironmentsQueryParams = {}
) { ) {
if (tagIds && tagIds.length === 0) { if (query.tagIds && query.tagIds.length === 0) {
return { totalCount: 0, value: <Environment[]>[] }; return { totalCount: 0, value: <Environment[]>[] };
} }
const url = buildUrl(); const url = buildUrl();
const params: Record<string, unknown> = { start, limit, ...query }; const params: Record<string, unknown> = {
start,
if (types) { limit,
params.types = arrayToJson(types); sort: sort.by,
} order: sort.order,
...query,
if (tagIds) { };
params.tagIds = arrayToJson(tagIds);
}
if (endpointIds) {
params.endpointIds = arrayToJson(endpointIds);
}
if (status) {
params.status = arrayToJson(status);
}
if (groupIds) {
params.groupIds = arrayToJson(groupIds);
}
try { try {
const response = await axios.get<Environment[]>(url, { params }); const response = await axios.get<Environment[]>(url, { params });
@ -109,12 +98,16 @@ export async function snapshotEndpoint(id: EnvironmentId) {
} }
export async function endpointsByGroup( export async function endpointsByGroup(
groupId: EnvironmentGroupId,
start: number, start: number,
limit: number, limit: number,
search: string, query: Omit<EnvironmentsQueryParams, 'groupIds'>
groupId: EnvironmentGroupId
) { ) {
return getEndpoints(start, limit, { search, groupIds: [groupId] }); return getEnvironments({
start,
limit,
query: { groupIds: [groupId], ...query },
});
} }
export async function disassociateEndpoint(id: EnvironmentId) { export async function disassociateEndpoint(id: EnvironmentId) {

View file

@ -3,16 +3,21 @@ import { useQuery } from 'react-query';
import { withError } from '@/react-tools/react-query'; import { withError } from '@/react-tools/react-query';
import { EnvironmentStatus } from '../types'; import { EnvironmentStatus } from '../types';
import { EnvironmentsQueryParams, getEndpoints } from '../environment.service'; import {
EnvironmentsQueryParams,
getEnvironments,
} from '../environment.service';
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
interface Query extends EnvironmentsQueryParams { export interface Query extends EnvironmentsQueryParams {
page?: number; page?: number;
pageLimit?: number; pageLimit?: number;
sort?: string;
order?: 'asc' | 'desc';
} }
type GetEndpointsResponse = Awaited<ReturnType<typeof getEndpoints>>; type GetEndpointsResponse = Awaited<ReturnType<typeof getEnvironments>>;
export function refetchIfAnyOffline(data?: GetEndpointsResponse) { export function refetchIfAnyOffline(data?: GetEndpointsResponse) {
if (!data) { if (!data) {
@ -31,7 +36,7 @@ export function refetchIfAnyOffline(data?: GetEndpointsResponse) {
} }
export function useEnvironmentList( export function useEnvironmentList(
{ page = 1, pageLimit = 100, ...query }: Query = {}, { page = 1, pageLimit = 100, sort, order, ...query }: Query = {},
refetchInterval?: refetchInterval?:
| number | number
| false | false
@ -45,12 +50,19 @@ export function useEnvironmentList(
{ {
page, page,
pageLimit, pageLimit,
sort,
order,
...query, ...query,
}, },
], ],
async () => { async () => {
const start = (page - 1) * pageLimit + 1; const start = (page - 1) * pageLimit + 1;
return getEndpoints(start, pageLimit, query); return getEnvironments({
start,
limit: pageLimit,
sort: { by: sort, order },
query,
});
}, },
{ {
staleTime, staleTime,

View file

@ -20,6 +20,11 @@ export enum EnvironmentType {
EdgeAgentOnKubernetes, EdgeAgentOnKubernetes,
} }
export const EdgeTypes = [
EnvironmentType.EdgeAgentOnDocker,
EnvironmentType.EdgeAgentOnKubernetes,
] as const;
export enum EnvironmentStatus { export enum EnvironmentStatus {
Up = 1, Up = 1,
Down, Down,

View file

@ -133,7 +133,8 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
groupIds: groupFilter, groupIds: groupFilter,
sort: sortByFilter, sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc', order: sortByDescending ? 'desc' : 'asc',
edgeDeviceFilter: 'none', provisioned: true,
edgeDevice: false,
tagsPartialMatch: true, tagsPartialMatch: true,
}, },
refetchIfAnyOffline refetchIfAnyOffline
@ -312,7 +313,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
groupIds: groupFilter, groupIds: groupFilter,
sort: sortByFilter, sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc', order: sortByDescending ? 'desc' : 'asc',
edgeDeviceFilter: 'none', edgeDevice: false,
}} }}
/> />
</div> </div>

View file

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Download } from 'react-feather'; import { Download } from 'react-feather';
import { Environment } from '@/portainer/environments/types'; import { Environment } from '@/portainer/environments/types';
import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service/index'; import { Query } from '@/portainer/environments/queries/useEnvironmentList';
import { isKubernetesEnvironment } from '@/portainer/environments/utils'; import { isKubernetesEnvironment } from '@/portainer/environments/utils';
import { trackEvent } from '@/angulartics.matomo/analytics-services'; import { trackEvent } from '@/angulartics.matomo/analytics-services';
@ -14,7 +14,7 @@ import '@reach/dialog/styles.css';
export interface Props { export interface Props {
environments: Environment[]; environments: Environment[];
envQueryParams: EnvironmentsQueryParams; envQueryParams: Query;
} }
export function KubeconfigButton({ environments, envQueryParams }: Props) { export function KubeconfigButton({ environments, envQueryParams }: Props) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);

View file

@ -1,15 +1,15 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service'; import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import { getEnvironments } from '@/portainer/environments/environment.service';
import AccessViewerPolicyModel from '../../models/access'; import AccessViewerPolicyModel from '../../models/access';
export default class AccessViewerController { export default class AccessViewerController {
/* @ngInject */ /* @ngInject */
constructor(Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService, Authentication) { constructor(Notifications, RoleService, UserService, GroupService, TeamService, TeamMembershipService, Authentication) {
this.Notifications = Notifications; this.Notifications = Notifications;
this.RoleService = RoleService; this.RoleService = RoleService;
this.UserService = UserService; this.UserService = UserService;
this.EndpointService = EndpointService;
this.GroupService = GroupService; this.GroupService = GroupService;
this.TeamService = TeamService; this.TeamService = TeamService;
this.TeamMembershipService = TeamMembershipService; this.TeamMembershipService = TeamMembershipService;
@ -138,7 +138,7 @@ export default class AccessViewerController {
this.isAdmin = this.Authentication.isAdmin(); this.isAdmin = this.Authentication.isAdmin();
this.allUsers = await this.UserService.users(); this.allUsers = await this.UserService.users();
this.endpoints = _.keyBy((await this.EndpointService.endpoints()).value, 'Id'); this.endpoints = _.keyBy((await getEnvironments()).value, 'Id');
const groups = await this.GroupService.groups(); const groups = await this.GroupService.groups();
this.groupUserAccessPolicies = {}; this.groupUserAccessPolicies = {};
this.groupTeamAccessPolicies = {}; this.groupTeamAccessPolicies = {};

View file

@ -16,14 +16,6 @@ angular.module('portainer.app').factory('EndpointService', [
return Endpoints.get({ id: endpointID }).$promise; return Endpoints.get({ id: endpointID }).$promise;
}; };
service.endpoints = function (start, limit, { search, types, tagIds, endpointIds, tagsPartialMatch } = {}) {
if (tagIds && !tagIds.length) {
return Promise.resolve({ value: [], totalCount: 0 });
}
return Endpoints.query({ start, limit, search, types: JSON.stringify(types), tagIds: JSON.stringify(tagIds), endpointIds: JSON.stringify(endpointIds), tagsPartialMatch })
.$promise;
};
service.snapshotEndpoints = function () { service.snapshotEndpoints = function () {
return Endpoints.snapshots({}, {}).$promise; return Endpoints.snapshots({}, {}).$promise;
}; };
@ -32,10 +24,6 @@ angular.module('portainer.app').factory('EndpointService', [
return Endpoints.snapshot({ id: endpointID }, {}).$promise; return Endpoints.snapshot({ id: endpointID }, {}).$promise;
}; };
service.endpointsByGroup = function (start, limit, search, groupId) {
return Endpoints.query({ start, limit, search, groupId }).$promise;
};
service.updateAccess = function (id, userAccessPolicies, teamAccessPolicies) { service.updateAccess = function (id, userAccessPolicies, teamAccessPolicies) {
return Endpoints.updateAccess({ id: id }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise; return Endpoints.updateAccess({ id: id }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise;
}; };

View file

@ -1,18 +1,17 @@
import angular from 'angular'; import angular from 'angular';
import { getEnvironments } from '../environments/environment.service';
angular.module('portainer.app').factory('NameValidator', NameValidatorFactory); angular.module('portainer.app').factory('NameValidator', NameValidatorFactory);
/* @ngInject */ /* @ngInject */
function NameValidatorFactory(EndpointService, Notifications) { function NameValidatorFactory(Notifications) {
return { return {
validateEnvironmentName, validateEnvironmentName,
}; };
async function validateEnvironmentName(environmentName) { async function validateEnvironmentName(name) {
try { try {
const endpoints = await EndpointService.endpoints(); const endpoints = await getEnvironments({ limit: 1, name });
const endpointArray = endpoints.value; return endpoints.value.length > 0;
const nameDuplicated = endpointArray.filter((item) => item.Name === environmentName);
return nameDuplicated.length > 0;
} catch (err) { } catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve environment details'); Notifications.error('Failure', err, 'Unable to retrieve environment details');
} }

View file

@ -1,5 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import uuidv4 from 'uuid/v4'; import uuidv4 from 'uuid/v4';
import { getEnvironments } from '@/portainer/environments/environment.service';
class AuthenticationController { class AuthenticationController {
/* @ngInject */ /* @ngInject */
@ -12,7 +13,6 @@ class AuthenticationController {
$window, $window,
Authentication, Authentication,
UserService, UserService,
EndpointService,
StateManager, StateManager,
Notifications, Notifications,
SettingsService, SettingsService,
@ -28,7 +28,6 @@ class AuthenticationController {
this.$window = $window; this.$window = $window;
this.Authentication = Authentication; this.Authentication = Authentication;
this.UserService = UserService; this.UserService = UserService;
this.EndpointService = EndpointService;
this.StateManager = StateManager; this.StateManager = StateManager;
this.Notifications = Notifications; this.Notifications = Notifications;
this.SettingsService = SettingsService; this.SettingsService = SettingsService;
@ -119,8 +118,8 @@ class AuthenticationController {
async checkForEndpointsAsync() { async checkForEndpointsAsync() {
try { try {
const endpoints = await this.EndpointService.endpoints(0, 1);
const isAdmin = this.Authentication.isAdmin(); const isAdmin = this.Authentication.isAdmin();
const endpoints = await getEnvironments({ limit: 1 });
if (this.Authentication.getUserDetails().forceChangePassword) { if (this.Authentication.getUserDetails().forceChangePassword) {
return this.$state.go('portainer.account'); return this.$state.go('portainer.account');

View file

@ -1,5 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import EndpointHelper from 'Portainer/helpers/endpointHelper'; import EndpointHelper from '@/portainer/helpers/endpointHelper';
import { getEnvironments } from '@/portainer/environments/environment.service';
angular.module('portainer.app').controller('EndpointsController', EndpointsController); angular.module('portainer.app').controller('EndpointsController', EndpointsController);
@ -46,10 +47,10 @@ function EndpointsController($q, $scope, $state, $async, EndpointService, GroupS
} }
$scope.getPaginatedEndpoints = getPaginatedEndpoints; $scope.getPaginatedEndpoints = getPaginatedEndpoints;
function getPaginatedEndpoints(lastId, limit, search) { function getPaginatedEndpoints(start, limit, search) {
const deferred = $q.defer(); const deferred = $q.defer();
$q.all({ $q.all({
endpoints: EndpointService.endpoints(lastId, limit, { search }), endpoints: getEnvironments({ start, limit, query: { search } }),
groups: GroupService.groups(), groups: GroupService.groups(),
}) })
.then(function success(data) { .then(function success(data) {

View file

@ -1,3 +1,5 @@
import { getEnvironments } from '@/portainer/environments/environment.service';
angular.module('portainer.app').controller('InitAdminController', [ angular.module('portainer.app').controller('InitAdminController', [
'$scope', '$scope',
'$state', '$state',
@ -6,10 +8,9 @@ angular.module('portainer.app').controller('InitAdminController', [
'StateManager', 'StateManager',
'SettingsService', 'SettingsService',
'UserService', 'UserService',
'EndpointService',
'BackupService', 'BackupService',
'StatusService', 'StatusService',
function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService, BackupService, StatusService) { function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, BackupService, StatusService) {
$scope.uploadBackup = uploadBackup; $scope.uploadBackup = uploadBackup;
$scope.logo = StateManager.getState().application.logo; $scope.logo = StateManager.getState().application.logo;
@ -50,7 +51,7 @@ angular.module('portainer.app').controller('InitAdminController', [
return StateManager.initialize(); return StateManager.initialize();
}) })
.then(function () { .then(function () {
return EndpointService.endpoints(0, 100); return getEnvironments({ limit: 100 });
}) })
.then(function success(data) { .then(function success(data) {
if (data.value.length === 0) { if (data.value.length === 0) {

View file

@ -1,6 +1,7 @@
import { ResourceControlType } from '@/portainer/access-control/types'; import { ResourceControlType } from '@/portainer/access-control/types';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { FeatureId } from 'Portainer/feature-flags/enums'; import { FeatureId } from 'Portainer/feature-flags/enums';
import { getEnvironments } from '@/portainer/environments/environment.service';
angular.module('portainer.app').controller('StackController', [ angular.module('portainer.app').controller('StackController', [
'$async', '$async',
@ -20,7 +21,6 @@ angular.module('portainer.app').controller('StackController', [
'Notifications', 'Notifications',
'FormHelper', 'FormHelper',
'EndpointProvider', 'EndpointProvider',
'EndpointService',
'GroupService', 'GroupService',
'ModalService', 'ModalService',
'StackHelper', 'StackHelper',
@ -46,7 +46,6 @@ angular.module('portainer.app').controller('StackController', [
Notifications, Notifications,
FormHelper, FormHelper,
EndpointProvider, EndpointProvider,
EndpointService,
GroupService, GroupService,
ModalService, ModalService,
StackHelper, StackHelper,
@ -317,60 +316,62 @@ angular.module('portainer.app').controller('StackController', [
} }
function loadStack(id) { function loadStack(id) {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; return $async(() => {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
EndpointService.endpoints() getEnvironments()
.then(function success(data) { .then(function success(data) {
$scope.endpoints = data.value; $scope.endpoints = data.value;
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve environments'); Notifications.error('Failure', err, 'Unable to retrieve environments');
});
$q.all({
stack: StackService.stack(id),
groups: GroupService.groups(),
containers: ContainerService.containers(true),
})
.then(function success(data) {
var stack = data.stack;
$scope.groups = data.groups;
$scope.stack = stack;
$scope.containerNames = ContainerHelper.getContainerNames(data.containers);
$scope.formValues.Env = $scope.stack.Env;
let resourcesPromise = Promise.resolve({});
if (!stack.Status || stack.Status === 1) {
resourcesPromise = stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name);
}
return $q.all({
stackFile: StackService.getStackFile(id),
resources: resourcesPromise,
}); });
})
.then(function success(data) {
const isSwarm = $scope.stack.Type === 1;
$scope.stackFileContent = data.stackFile;
// workaround for missing status, if stack has resources, set the status to 1 (active), otherwise to 2 (inactive) (https://github.com/portainer/portainer/issues/4422)
if (!$scope.stack.Status) {
$scope.stack.Status = data.resources && ((isSwarm && data.resources.services.length) || data.resources.containers.length) ? 1 : 2;
}
if ($scope.stack.Status === 1) { $q.all({
if (isSwarm) { stack: StackService.stack(id),
assignSwarmStackResources(data.resources, agentProxy); groups: GroupService.groups(),
} else { containers: ContainerService.containers(true),
assignComposeStackResources(data.resources); })
.then(function success(data) {
var stack = data.stack;
$scope.groups = data.groups;
$scope.stack = stack;
$scope.containerNames = ContainerHelper.getContainerNames(data.containers);
$scope.formValues.Env = $scope.stack.Env;
let resourcesPromise = Promise.resolve({});
if (!stack.Status || stack.Status === 1) {
resourcesPromise = stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name);
} }
}
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames); return $q.all({
}) stackFile: StackService.getStackFile(id),
.catch(function error(err) { resources: resourcesPromise,
Notifications.error('Failure', err, 'Unable to retrieve stack details'); });
}); })
.then(function success(data) {
const isSwarm = $scope.stack.Type === 1;
$scope.stackFileContent = data.stackFile;
// workaround for missing status, if stack has resources, set the status to 1 (active), otherwise to 2 (inactive) (https://github.com/portainer/portainer/issues/4422)
if (!$scope.stack.Status) {
$scope.stack.Status = data.resources && ((isSwarm && data.resources.services.length) || data.resources.containers.length) ? 1 : 2;
}
if ($scope.stack.Status === 1) {
if (isSwarm) {
assignSwarmStackResources(data.resources, agentProxy);
} else {
assignComposeStackResources(data.resources);
}
}
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve stack details');
});
});
} }
function retrieveSwarmStackResources(stackName, agentProxy) { function retrieveSwarmStackResources(stackName, agentProxy) {

View file

@ -2,7 +2,7 @@ import { Field, useField } from 'formik';
import { string } from 'yup'; import { string } from 'yup';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { getEndpoints } from '@/portainer/environments/environment.service'; import { getEnvironments } from '@/portainer/environments/environment.service';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
@ -30,13 +30,13 @@ export function NameField({ readonly }: Props) {
); );
} }
async function isNameUnique(name?: string) { export async function isNameUnique(name?: string) {
if (!name) { if (!name) {
return true; return true;
} }
try { try {
const result = await getEndpoints(0, 1, { name }); const result = await getEnvironments({ limit: 1, query: { name } });
if (result.totalCount > 0) { if (result.totalCount > 0) {
return false; return false;
} }