Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3fd62bd96 | |||
| 6efc7084cd | |||
| 4c747bfd11 | |||
| c273d3b787 | |||
| 4e91ca4b1f | |||
| 6423a7bd17 | |||
| 8214119137 | |||
| fe3aeab115 | |||
| 5e25f8fe7d | |||
| 8471d2ae26 | |||
| eb7875290d | |||
| 26649219b3 | |||
| c1072da667 | |||
| a992cdbe53 | |||
| 175fddff8e | |||
| 8034966ea3 | |||
| 4af28d59cf | |||
| 6a77e8cfa3 | |||
| 79c16700dd | |||
| c1a2ca5a51 |
@@ -97,6 +97,9 @@ func (m *Migrator) Migrate() error {
|
|||||||
newMigration(35, m.migrateDBVersionToDB35),
|
newMigration(35, m.migrateDBVersionToDB35),
|
||||||
|
|
||||||
newMigration(36, m.migrateDBVersionToDB36),
|
newMigration(36, m.migrateDBVersionToDB36),
|
||||||
|
|
||||||
|
// Portainer 2.13
|
||||||
|
newMigration(40, m.migrateDBVersionToDB40),
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastDbVersion int
|
var lastDbVersion int
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package migrator
|
||||||
|
|
||||||
|
import "github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
|
||||||
|
func (m *Migrator) migrateDBVersionToDB40() error {
|
||||||
|
if err := m.trustCurrentEdgeEndpointsDB40(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migrator) trustCurrentEdgeEndpointsDB40() error {
|
||||||
|
migrateLog.Info("- trusting current edge endpoints")
|
||||||
|
endpoints, err := m.endpointService.Endpoints()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||||
|
endpoint.UserTrusted = true
|
||||||
|
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
|
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
|
||||||
github.com/docker/cli v20.10.9+incompatible
|
github.com/docker/cli v20.10.9+incompatible
|
||||||
github.com/docker/docker v20.10.9+incompatible
|
github.com/docker/docker v20.10.9+incompatible
|
||||||
|
github.com/fvbommel/sortorder v1.0.2
|
||||||
github.com/fxamacker/cbor/v2 v2.3.0
|
github.com/fxamacker/cbor/v2 v2.3.0
|
||||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||||
github.com/go-git/go-git/v5 v5.3.0
|
github.com/go-git/go-git/v5 v5.3.0
|
||||||
|
|||||||
@@ -376,6 +376,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
|
|||||||
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
|
github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
|
||||||
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
||||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
|
||||||
|
github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo=
|
||||||
|
github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
|
||||||
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
|
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
|
||||||
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
|
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package endpoints
|
package endpoints
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
@@ -21,6 +22,9 @@ type endpointCreateGlobalKeyResponse struct {
|
|||||||
// @router /endpoints/global-key [post]
|
// @router /endpoints/global-key [post]
|
||||||
func (handler *Handler) endpointCreateGlobalKey(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) endpointCreateGlobalKey(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
edgeID := r.Header.Get(portainer.PortainerAgentEdgeIDHeader)
|
edgeID := r.Header.Get(portainer.PortainerAgentEdgeIDHeader)
|
||||||
|
if edgeID == "" {
|
||||||
|
return httperror.BadRequest("Invalid Edge ID", errors.New("the Edge ID cannot be empty"))
|
||||||
|
}
|
||||||
|
|
||||||
// Search for existing endpoints for the given edgeID
|
// Search for existing endpoints for the given edgeID
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmptyGlobalKey(t *testing.T) {
|
||||||
|
handler := NewHandler(
|
||||||
|
helper.NewTestRequestBouncer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://portainer.io:9443/endpoints/global-key", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("request error:", err)
|
||||||
|
}
|
||||||
|
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatal("expected a 400 response, found:", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package endpoints
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,14 +13,24 @@ import (
|
|||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
"github.com/portainer/portainer/api/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EdgeDeviceFilterAll = "all"
|
EdgeDeviceFilterAll = "all"
|
||||||
EdgeDeviceFilterTrusted = "trusted"
|
EdgeDeviceFilterTrusted = "trusted"
|
||||||
EdgeDeviceFilterUntrusted = "untrusted"
|
EdgeDeviceFilterUntrusted = "untrusted"
|
||||||
|
EdgeDeviceFilterNone = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EdgeDeviceIntervalMultiplier = 2
|
||||||
|
EdgeDeviceIntervalAdd = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var endpointGroupNames map[portainer.EndpointGroupID]string
|
||||||
|
|
||||||
// @id EndpointList
|
// @id EndpointList
|
||||||
// @summary List environments(endpoints)
|
// @summary List environments(endpoints)
|
||||||
// @description List all environments(endpoints) based on the current user authorizations. Will
|
// @description List all environments(endpoints) based on the current user authorizations. Will
|
||||||
@@ -38,7 +49,7 @@ const (
|
|||||||
// @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 devices" Enum("all", "trusted", "untrusted")
|
// @param edgeDeviceFilter query string false "will return only these edge environments, none will return only regular edge environments" Enum("all", "trusted", "untrusted", "none")
|
||||||
// @success 200 {array} portainer.Endpoint "Endpoints"
|
// @success 200 {array} portainer.Endpoint "Endpoints"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /endpoints [get]
|
// @router /endpoints [get]
|
||||||
@@ -55,6 +66,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
|||||||
|
|
||||||
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
|
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
|
||||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||||
|
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||||
|
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||||
|
|
||||||
var endpointTypes []int
|
var endpointTypes []int
|
||||||
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
|
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
|
||||||
@@ -67,11 +80,23 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
|||||||
var endpointIDs []portainer.EndpointID
|
var endpointIDs []portainer.EndpointID
|
||||||
request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true)
|
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.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create endpoint groups as a map for more convenient access
|
||||||
|
endpointGroupNames = make(map[portainer.EndpointGroupID]string, 0)
|
||||||
|
for _, group := range endpointGroups {
|
||||||
|
endpointGroupNames[group.ID] = group.Name
|
||||||
|
}
|
||||||
|
|
||||||
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.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err}
|
||||||
@@ -90,12 +115,16 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
|||||||
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
||||||
totalAvailableEndpoints := len(filteredEndpoints)
|
totalAvailableEndpoints := len(filteredEndpoints)
|
||||||
|
|
||||||
|
if groupID != 0 {
|
||||||
|
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, []int{groupID})
|
||||||
|
}
|
||||||
|
|
||||||
if endpointIDs != nil {
|
if endpointIDs != nil {
|
||||||
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
|
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if groupID != 0 {
|
if len(groupIDs) > 0 {
|
||||||
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
|
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, groupIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false)
|
edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false)
|
||||||
@@ -103,6 +132,10 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
|||||||
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(statuses) > 0 {
|
||||||
|
filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, statuses, settings)
|
||||||
|
}
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
tags, err := handler.DataStore.Tag().Tags()
|
tags, err := handler.DataStore.Tag().Tags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,6 +156,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
|||||||
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch)
|
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort endpoints by field
|
||||||
|
sortEndpointsByField(filteredEndpoints, sortField, sortOrder == "desc")
|
||||||
|
|
||||||
filteredEndpointCount := len(filteredEndpoints)
|
filteredEndpointCount := len(filteredEndpoints)
|
||||||
|
|
||||||
paginatedEndpoints := paginateEndpoints(filteredEndpoints, start, limit)
|
paginatedEndpoints := paginateEndpoints(filteredEndpoints, start, limit)
|
||||||
@@ -160,11 +196,11 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
|
|||||||
return endpoints[start:end]
|
return endpoints[start:end]
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterEndpointsByGroupID(endpoints []portainer.Endpoint, endpointGroupID portainer.EndpointGroupID) []portainer.Endpoint {
|
func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []int) []portainer.Endpoint {
|
||||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
for _, endpoint := range endpoints {
|
||||||
if endpoint.GroupID == endpointGroupID {
|
if utils.Contains(endpointGroupIDs, int(endpoint.GroupID)) {
|
||||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +226,64 @@ func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGro
|
|||||||
return filteredEndpoints
|
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, sortField string, isSortDesc bool) {
|
||||||
|
|
||||||
|
switch sortField {
|
||||||
|
case "Name":
|
||||||
|
if isSortDesc {
|
||||||
|
sort.Stable(sort.Reverse(EndpointsByName(endpoints)))
|
||||||
|
} else {
|
||||||
|
sort.Stable(EndpointsByName(endpoints))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Group":
|
||||||
|
if isSortDesc {
|
||||||
|
sort.Stable(sort.Reverse(EndpointsByGroup(endpoints)))
|
||||||
|
} else {
|
||||||
|
sort.Stable(EndpointsByGroup(endpoints))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "Status":
|
||||||
|
if isSortDesc {
|
||||||
|
sort.Slice(endpoints, func(i, j int) bool {
|
||||||
|
return endpoints[i].Status > endpoints[j].Status
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
sort.Slice(endpoints, func(i, j int) bool {
|
||||||
|
return endpoints[i].Status < endpoints[j].Status
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
|
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
|
||||||
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
|
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
|
||||||
return true
|
return true
|
||||||
@@ -250,10 +344,6 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int)
|
|||||||
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter string) []portainer.Endpoint {
|
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter string) []portainer.Endpoint {
|
||||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||||
|
|
||||||
if edgeDeviceFilter != EdgeDeviceFilterAll && edgeDeviceFilter != EdgeDeviceFilterTrusted && edgeDeviceFilter != EdgeDeviceFilterUntrusted {
|
|
||||||
return endpoints
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
for _, endpoint := range endpoints {
|
||||||
if shouldReturnEdgeDevice(endpoint, edgeDeviceFilter) {
|
if shouldReturnEdgeDevice(endpoint, edgeDeviceFilter) {
|
||||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||||
@@ -263,7 +353,12 @@ func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceFilter string) bool {
|
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceFilter string) bool {
|
||||||
if !endpoint.IsEdgeDevice {
|
// none - return all endpoints that are not edge devices
|
||||||
|
if edgeDeviceFilter == EdgeDeviceFilterNone && !endpoint.IsEdgeDevice {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,27 +17,36 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type endpointListEdgeDeviceTest struct {
|
type endpointListEdgeDeviceTest struct {
|
||||||
|
title string
|
||||||
expected []portainer.EndpointID
|
expected []portainer.EndpointID
|
||||||
filter string
|
filter string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_endpointList(t *testing.T) {
|
func Test_endpointList(t *testing.T) {
|
||||||
|
var err error
|
||||||
is := assert.New(t)
|
is := assert.New(t)
|
||||||
|
|
||||||
_, store, teardown := datastore.MustNewTestStore(true, true)
|
_, store, teardown := datastore.MustNewTestStore(true, true)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
trustedEndpoint := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1}
|
trustedEndpoint := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||||
err := store.Endpoint().Create(&trustedEndpoint)
|
untrustedEndpoint := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||||
is.NoError(err, "error creating environment")
|
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, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.DockerEnvironment}
|
||||||
|
|
||||||
untrustedEndpoint := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1}
|
endpoints := []portainer.Endpoint{
|
||||||
err = store.Endpoint().Create(&untrustedEndpoint)
|
trustedEndpoint,
|
||||||
is.NoError(err, "error creating environment")
|
untrustedEndpoint,
|
||||||
|
regularUntrustedEdgeEndpoint,
|
||||||
|
regularTrustedEdgeEndpoint,
|
||||||
|
regularEndpoint,
|
||||||
|
}
|
||||||
|
|
||||||
regularEndpoint := portainer.Endpoint{ID: 3, IsEdgeDevice: false, GroupID: 1}
|
for _, endpoint := range endpoints {
|
||||||
err = store.Endpoint().Create(®ularEndpoint)
|
err = store.Endpoint().Create(&endpoint)
|
||||||
is.NoError(err, "error creating environment")
|
is.NoError(err, "error creating environment")
|
||||||
|
}
|
||||||
|
|
||||||
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||||
is.NoError(err, "error creating a user")
|
is.NoError(err, "error creating a user")
|
||||||
@@ -49,33 +58,45 @@ func Test_endpointList(t *testing.T) {
|
|||||||
|
|
||||||
tests := []endpointListEdgeDeviceTest{
|
tests := []endpointListEdgeDeviceTest{
|
||||||
{
|
{
|
||||||
[]portainer.EndpointID{trustedEndpoint.ID, untrustedEndpoint.ID},
|
"should show all edge endpoints",
|
||||||
|
[]portainer.EndpointID{trustedEndpoint.ID, untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||||
EdgeDeviceFilterAll,
|
EdgeDeviceFilterAll,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
[]portainer.EndpointID{trustedEndpoint.ID},
|
"should show only trusted edge devices",
|
||||||
|
[]portainer.EndpointID{trustedEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||||
EdgeDeviceFilterTrusted,
|
EdgeDeviceFilterTrusted,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
[]portainer.EndpointID{untrustedEndpoint.ID},
|
"should show only untrusted edge devices",
|
||||||
|
[]portainer.EndpointID{untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID},
|
||||||
EdgeDeviceFilterUntrusted,
|
EdgeDeviceFilterUntrusted,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"should show no edge devices",
|
||||||
|
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||||
|
EdgeDeviceFilterNone,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
req := buildEndpointListRequest(test.filter)
|
t.Run(test.title, func(t *testing.T) {
|
||||||
resp, err := doEndpointListRequest(req, h, is)
|
is := assert.New(t)
|
||||||
is.NoError(err)
|
|
||||||
|
|
||||||
is.Equal(len(test.expected), len(resp))
|
req := buildEndpointListRequest(test.filter)
|
||||||
|
resp, err := doEndpointListRequest(req, h, is)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
respIds := []portainer.EndpointID{}
|
is.Equal(len(test.expected), len(resp))
|
||||||
|
|
||||||
for _, endpoint := range resp {
|
respIds := []portainer.EndpointID{}
|
||||||
respIds = append(respIds, endpoint.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
is.Equal(test.expected, respIds, "response should contain all edge devices")
|
for _, endpoint := range resp {
|
||||||
|
respIds = append(respIds, endpoint.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
is.ElementsMatch(test.expected, respIds)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fvbommel/sortorder"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EndpointsByName []portainer.Endpoint
|
||||||
|
|
||||||
|
func (e EndpointsByName) Len() int {
|
||||||
|
return len(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EndpointsByName) Swap(i, j int) {
|
||||||
|
e[i], e[j] = e[j], e[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EndpointsByName) Less(i, j int) bool {
|
||||||
|
return sortorder.NaturalLess(strings.ToLower(e[i].Name), strings.ToLower(e[j].Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndpointsByGroup []portainer.Endpoint
|
||||||
|
|
||||||
|
func (e EndpointsByGroup) Len() int {
|
||||||
|
return len(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EndpointsByGroup) Swap(i, j int) {
|
||||||
|
e[i], e[j] = e[j], e[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e EndpointsByGroup) Less(i, j int) bool {
|
||||||
|
if e[i].GroupID == e[j].GroupID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
groupA := endpointGroupNames[e[i].GroupID]
|
||||||
|
groupB := endpointGroupNames[e[j].GroupID]
|
||||||
|
|
||||||
|
return sortorder.NaturalLess(strings.ToLower(groupA), strings.ToLower(groupB))
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ func ParseHostForEdge(portainerURL string) (string, error) {
|
|||||||
portainerHost = parsedURL.Host
|
portainerHost = parsedURL.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if portainerHost == "" {
|
||||||
|
return "", errors.New("hostname cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
if portainerHost == "localhost" {
|
if portainerHost == "localhost" {
|
||||||
return "", errors.New("cannot use localhost as environment URL")
|
return "", errors.New("cannot use localhost as environment URL")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
// Contains returns true if the given int is contained in the given slice of int.
|
||||||
|
func Contains(s []int, e int) bool {
|
||||||
|
for _, a := range s {
|
||||||
|
if a == e {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@ package jwt
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt"
|
"github.com/golang-jwt/jwt"
|
||||||
"github.com/gorilla/securecookie"
|
"github.com/gorilla/securecookie"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scope represents JWT scopes that are supported in JWT claims.
|
// scope represents JWT scopes that are supported in JWT claims.
|
||||||
@@ -164,6 +166,12 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
|
|||||||
return "", fmt.Errorf("invalid scope: %v", scope)
|
return "", fmt.Errorf("invalid scope: %v", scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, ok := os.LookupEnv("DOCKER_EXTENSION"); ok {
|
||||||
|
// Set expiration to 99 years for docker desktop extension.
|
||||||
|
log.Infof("[message: detected docker desktop extension mode]")
|
||||||
|
expiresAt = time.Now().Add(time.Hour * 8760 * 99).Unix()
|
||||||
|
}
|
||||||
|
|
||||||
cl := claims{
|
cl := claims{
|
||||||
UserID: int(data.ID),
|
UserID: int(data.ID),
|
||||||
Username: data.Username,
|
Username: data.Username,
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ html {
|
|||||||
--bg-app-datatable-tbody: var(--grey-24);
|
--bg-app-datatable-tbody: var(--grey-24);
|
||||||
--bg-stepper-item-active: var(--white-color);
|
--bg-stepper-item-active: var(--white-color);
|
||||||
--bg-stepper-item-counter: var(--grey-61);
|
--bg-stepper-item-counter: var(--grey-61);
|
||||||
|
--bg-sortbutton-color: var(--white-color);
|
||||||
|
|
||||||
--text-main-color: var(--grey-7);
|
--text-main-color: var(--grey-7);
|
||||||
--text-body-color: var(--grey-6);
|
--text-body-color: var(--grey-6);
|
||||||
@@ -242,6 +243,7 @@ html {
|
|||||||
--border-daterangepicker-after: var(--white-color);
|
--border-daterangepicker-after: var(--white-color);
|
||||||
--border-tooltip-color: var(--grey-47);
|
--border-tooltip-color: var(--grey-47);
|
||||||
--border-modal: 0px;
|
--border-modal: 0px;
|
||||||
|
--border-sortbutton: var(--grey-8);
|
||||||
|
|
||||||
--hover-sidebar-color: var(--grey-37);
|
--hover-sidebar-color: var(--grey-37);
|
||||||
--shadow-box-color: 0 3px 10px -2px var(--grey-50);
|
--shadow-box-color: 0 3px 10px -2px var(--grey-50);
|
||||||
@@ -334,6 +336,7 @@ html {
|
|||||||
--bg-app-datatable-tbody: var(--grey-1);
|
--bg-app-datatable-tbody: var(--grey-1);
|
||||||
--bg-stepper-item-active: var(--grey-1);
|
--bg-stepper-item-active: var(--grey-1);
|
||||||
--bg-stepper-item-counter: var(--grey-7);
|
--bg-stepper-item-counter: var(--grey-7);
|
||||||
|
--bg-sortbutton-color: var(--grey-1);
|
||||||
|
|
||||||
--text-main-color: var(--white-color);
|
--text-main-color: var(--white-color);
|
||||||
--text-body-color: var(--white-color);
|
--text-body-color: var(--white-color);
|
||||||
@@ -416,6 +419,7 @@ html {
|
|||||||
--border-daterangepicker-after: var(--grey-3);
|
--border-daterangepicker-after: var(--grey-3);
|
||||||
--border-tooltip-color: var(--grey-3);
|
--border-tooltip-color: var(--grey-3);
|
||||||
--border-modal: 0px;
|
--border-modal: 0px;
|
||||||
|
--border-sortbutton: var(--grey-3);
|
||||||
|
|
||||||
--hover-sidebar-color: var(--grey-3);
|
--hover-sidebar-color: var(--grey-3);
|
||||||
--blue-color: var(--blue-2);
|
--blue-color: var(--blue-2);
|
||||||
@@ -507,6 +511,7 @@ html {
|
|||||||
--bg-app-datatable-tbody: var(--black-color);
|
--bg-app-datatable-tbody: var(--black-color);
|
||||||
--bg-stepper-item-active: var(--black-color);
|
--bg-stepper-item-active: var(--black-color);
|
||||||
--bg-stepper-item-counter: var(--grey-3);
|
--bg-stepper-item-counter: var(--grey-3);
|
||||||
|
--bg-sortbutton-color: var(--grey-1);
|
||||||
|
|
||||||
--text-main-color: var(--white-color);
|
--text-main-color: var(--white-color);
|
||||||
--text-body-color: var(--white-color);
|
--text-body-color: var(--white-color);
|
||||||
@@ -578,6 +583,7 @@ html {
|
|||||||
--border-codemirror-cursor-color: var(--white-color);
|
--border-codemirror-cursor-color: var(--white-color);
|
||||||
--border-modal: 1px solid var(--white-color);
|
--border-modal: 1px solid var(--white-color);
|
||||||
--border-blocklist-color: var(--white-color);
|
--border-blocklist-color: var(--white-color);
|
||||||
|
--border-sortbutton: var(--black-color);
|
||||||
|
|
||||||
--hover-sidebar-color: var(--blue-9);
|
--hover-sidebar-color: var(--blue-9);
|
||||||
--hover-sidebar-color: var(--black-color);
|
--hover-sidebar-color: var(--black-color);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FormControl } from '@/portainer/components/form-components/FormControl'
|
|||||||
import { Input } from '@/portainer/components/form-components/Input';
|
import { Input } from '@/portainer/components/form-components/Input';
|
||||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||||
|
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||||
|
|
||||||
import { OsSelector } from './OsSelector';
|
import { OsSelector } from './OsSelector';
|
||||||
import { EdgeProperties } from './types';
|
import { EdgeProperties } from './types';
|
||||||
@@ -27,19 +28,26 @@ export function EdgePropertiesForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!hideIdGetter && (
|
{!hideIdGetter && (
|
||||||
<FormControl
|
<>
|
||||||
label="Edge ID Generator"
|
<FormControl
|
||||||
tooltip="A bash script one liner that will generate the edge id"
|
label="Edge ID Generator"
|
||||||
inputId="edge-id-generator-input"
|
tooltip="A bash script one liner that will generate the edge id and will be assigned to the PORTAINER_EDGE_ID environment variable"
|
||||||
>
|
inputId="edge-id-generator-input"
|
||||||
<Input
|
>
|
||||||
type="text"
|
<Input
|
||||||
name="edgeIdGenerator"
|
type="text"
|
||||||
value={values.edgeIdGenerator}
|
name="edgeIdGenerator"
|
||||||
id="edge-id-generator-input"
|
value={values.edgeIdGenerator}
|
||||||
onChange={(e) => setFieldValue(e.target.name, e.target.value)}
|
id="edge-id-generator-input"
|
||||||
/>
|
onChange={(e) => setFieldValue(e.target.name, e.target.value)}
|
||||||
</FormControl>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextTip color="blue">
|
||||||
|
<code>PORTAINER_EDGE_ID</code> environment variable is required to
|
||||||
|
successfully connect the edge agent to Portainer
|
||||||
|
</TextTip>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { r2a } from '@/react-tools/react2angular';
|
|||||||
import { useSettings } from '@/portainer/settings/settings.service';
|
import { useSettings } from '@/portainer/settings/settings.service';
|
||||||
|
|
||||||
import { EdgePropertiesForm } from './EdgePropertiesForm';
|
import { EdgePropertiesForm } from './EdgePropertiesForm';
|
||||||
import { Scripts } from './Scripts';
|
import { ScriptTabs } from './ScriptTabs';
|
||||||
import { EdgeProperties } from './types';
|
import { EdgeProperties } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -43,7 +43,7 @@ export function EdgeScriptForm({ edgeKey, edgeId }: Props) {
|
|||||||
hideIdGetter={edgeId !== undefined}
|
hideIdGetter={edgeId !== undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Scripts
|
<ScriptTabs
|
||||||
values={edgeProperties}
|
values={edgeProperties}
|
||||||
agentVersion={agentVersion}
|
agentVersion={agentVersion}
|
||||||
edgeKey={edgeKey}
|
edgeKey={edgeKey}
|
||||||
|
|||||||
+6
-8
@@ -49,7 +49,7 @@ interface Props {
|
|||||||
onPlatformChange(platform: Platform): void;
|
onPlatformChange(platform: Platform): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Scripts({
|
export function ScriptTabs({
|
||||||
agentVersion,
|
agentVersion,
|
||||||
values,
|
values,
|
||||||
edgeKey,
|
edgeKey,
|
||||||
@@ -134,7 +134,7 @@ function buildLinuxStandaloneCommand(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}
|
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}\
|
||||||
docker run -d \\
|
docker run -d \\
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \\
|
-v /var/run/docker.sock:/var/run/docker.sock \\
|
||||||
-v /var/lib/docker/volumes:/var/lib/docker/volumes \\
|
-v /var/lib/docker/volumes:/var/lib/docker/volumes \\
|
||||||
@@ -168,7 +168,7 @@ function buildWindowsStandaloneCommand(
|
|||||||
|
|
||||||
return `${
|
return `${
|
||||||
edgeIdScript ? `$Env:PORTAINER_EDGE_ID = "@(${edgeIdScript})" \n\n` : ''
|
edgeIdScript ? `$Env:PORTAINER_EDGE_ID = "@(${edgeIdScript})" \n\n` : ''
|
||||||
}
|
}\
|
||||||
docker run -d \\
|
docker run -d \\
|
||||||
--mount type=npipe,src=\\\\.\\pipe\\docker_engine,dst=\\\\.\\pipe\\docker_engine \\
|
--mount type=npipe,src=\\\\.\\pipe\\docker_engine,dst=\\\\.\\pipe\\docker_engine \\
|
||||||
--mount type=bind,src=C:\\ProgramData\\docker\\volumes,dst=C:\\ProgramData\\docker\\volumes \\
|
--mount type=bind,src=C:\\ProgramData\\docker\\volumes,dst=C:\\ProgramData\\docker\\volumes \\
|
||||||
@@ -199,7 +199,7 @@ function buildLinuxSwarmCommand(
|
|||||||
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
|
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}
|
return `${edgeIdScript ? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n` : ''}\
|
||||||
docker network create \\
|
docker network create \\
|
||||||
--driver overlay \\
|
--driver overlay \\
|
||||||
portainer_agent_network;
|
portainer_agent_network;
|
||||||
@@ -270,13 +270,11 @@ function buildKubernetesCommand(
|
|||||||
const idEnvVar = edgeIdScript
|
const idEnvVar = edgeIdScript
|
||||||
? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n`
|
? `PORTAINER_EDGE_ID=$(${edgeIdScript}) \n\n`
|
||||||
: '';
|
: '';
|
||||||
|
const envVarsTrimmed = envVars.trim();
|
||||||
const edgeIdVar = !edgeIdScript && edgeId ? edgeId : '$PORTAINER_EDGE_ID';
|
const edgeIdVar = !edgeIdScript && edgeId ? edgeId : '$PORTAINER_EDGE_ID';
|
||||||
const selfSigned = allowSelfSignedCerts ? '1' : '0';
|
const selfSigned = allowSelfSignedCerts ? '1' : '0';
|
||||||
|
|
||||||
return `${idEnvVar}curl https://downloads.portainer.io/ce${agentShortVersion}/portainer-edge-agent-setup.sh |
|
return `${idEnvVar}curl https://downloads.portainer.io/ce${agentShortVersion}/portainer-edge-agent-setup.sh | bash -s -- "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${agentSecret}" "${envVarsTrimmed}"`;
|
||||||
bash -s -- "${edgeIdVar}" \\
|
|
||||||
"${edgeKey}" \\
|
|
||||||
"${selfSigned}" "${agentSecret}" "${envVars}"`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaultEnvVars(
|
function buildDefaultEnvVars(
|
||||||
@@ -10,7 +10,12 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
|
|||||||
this.getEnvironments = function () {
|
this.getEnvironments = function () {
|
||||||
return $async(async () => {
|
return $async(async () => {
|
||||||
try {
|
try {
|
||||||
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, { edgeDeviceFilter: 'trusted' }), GroupService.groups()]);
|
const [endpointsResponse, groups] = await Promise.all([
|
||||||
|
getEndpoints(0, 100, {
|
||||||
|
edgeDeviceFilter: 'trusted',
|
||||||
|
}),
|
||||||
|
GroupService.groups(),
|
||||||
|
]);
|
||||||
ctrl.groups = groups;
|
ctrl.groups = groups;
|
||||||
ctrl.edgeDevices = endpointsResponse.value;
|
ctrl.edgeDevices = endpointsResponse.value;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -303,6 +303,7 @@
|
|||||||
</portainer-tooltip>
|
</portainer-tooltip>
|
||||||
<span
|
<span
|
||||||
class="label label-default interactive"
|
class="label label-default interactive"
|
||||||
|
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
|
||||||
style="margin-left: 10px"
|
style="margin-left: 10px"
|
||||||
ng-click="$ctrl.addRewriteAnnotation(ic)"
|
ng-click="$ctrl.addRewriteAnnotation(ic)"
|
||||||
data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}"
|
data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-text {
|
.display-text {
|
||||||
|
|||||||
@@ -21,13 +21,12 @@ export function useCopy(copyText: string, fadeDelay = 1000) {
|
|||||||
navigator.clipboard.writeText(copyText);
|
navigator.clipboard.writeText(copyText);
|
||||||
} else {
|
} else {
|
||||||
// https://stackoverflow.com/a/57192718
|
// https://stackoverflow.com/a/57192718
|
||||||
const inputEl = document.createElement('input');
|
const inputEl = document.createElement('textarea');
|
||||||
inputEl.value = copyText;
|
inputEl.value = copyText;
|
||||||
inputEl.type = 'text';
|
|
||||||
document.body.appendChild(inputEl);
|
document.body.appendChild(inputEl);
|
||||||
inputEl.select();
|
inputEl.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
inputEl.type = 'hidden';
|
inputEl.hidden = true;
|
||||||
document.body.removeChild(inputEl);
|
document.body.removeChild(inputEl);
|
||||||
}
|
}
|
||||||
setCopiedSuccessfully(true);
|
setCopiedSuccessfully(true);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
.code {
|
.code {
|
||||||
display: block;
|
display: block;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
padding: 16px 90px;
|
word-break: break-word;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
|||||||
-1
@@ -24,7 +24,6 @@
|
|||||||
ng-model="$ctrl.state.textFilter"
|
ng-model="$ctrl.state.textFilter"
|
||||||
ng-change="$ctrl.onTextFilterChange()"
|
ng-change="$ctrl.onTextFilterChange()"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
auto-focus
|
|
||||||
ng-model-options="{ debounce: 300 }"
|
ng-model-options="{ debounce: 300 }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,3 +8,8 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.searchBar .textSpan {
|
||||||
|
display: inline-block;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function FilterSearchBar({
|
|||||||
<span className={styles.iconSpan}>
|
<span className={styles.iconSpan}>
|
||||||
<i className="fa fa-search" aria-hidden="true" />
|
<i className="fa fa-search" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.iconSpan}>
|
<span className={styles.textSpan}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="searchInput"
|
className="searchInput"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.sort-by-container {
|
.sort-by-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
@@ -8,3 +7,12 @@
|
|||||||
.sort-by-element {
|
.sort-by-element {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-button {
|
||||||
|
background-color: var(--bg-sortbutton-color);
|
||||||
|
color: var(--text-ui-select-color);
|
||||||
|
border: 1px solid var(--border-sortbutton);
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||||
import { Button } from '@/portainer/components/Button';
|
|
||||||
import { Filter } from '@/portainer/home/types';
|
import { Filter } from '@/portainer/home/types';
|
||||||
|
|
||||||
import styles from './SortbySelector.module.css';
|
import styles from './SortbySelector.module.css';
|
||||||
@@ -13,6 +12,7 @@ interface Props {
|
|||||||
placeHolder: string;
|
placeHolder: string;
|
||||||
sortByDescending: boolean;
|
sortByDescending: boolean;
|
||||||
sortByButton: boolean;
|
sortByButton: boolean;
|
||||||
|
value?: Filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortbySelector({
|
export function SortbySelector({
|
||||||
@@ -22,16 +22,17 @@ export function SortbySelector({
|
|||||||
placeHolder,
|
placeHolder,
|
||||||
sortByDescending,
|
sortByDescending,
|
||||||
sortByButton,
|
sortByButton,
|
||||||
|
value,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const upIcon = 'fa fa-sort-alpha-up';
|
const upIcon = 'fa fa-sort-alpha-up';
|
||||||
const downIcon = 'fa fa-sort-alpha-down';
|
const downIcon = 'fa fa-sort-alpha-down';
|
||||||
const [iconStyle, setIconStyle] = useState(upIcon);
|
const [iconStyle, setIconStyle] = useState(downIcon);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sortByDescending) {
|
if (sortByDescending) {
|
||||||
setIconStyle(downIcon);
|
|
||||||
} else {
|
|
||||||
setIconStyle(upIcon);
|
setIconStyle(upIcon);
|
||||||
|
} else {
|
||||||
|
setIconStyle(downIcon);
|
||||||
}
|
}
|
||||||
}, [sortByDescending]);
|
}, [sortByDescending]);
|
||||||
|
|
||||||
@@ -43,11 +44,13 @@ export function SortbySelector({
|
|||||||
options={filterOptions}
|
options={filterOptions}
|
||||||
onChange={(option) => onChange(option as Filter)}
|
onChange={(option) => onChange(option as Filter)}
|
||||||
isClearable
|
isClearable
|
||||||
|
value={value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sortbyelement}>
|
<div className={styles.sortByElement}>
|
||||||
<Button
|
<button
|
||||||
size="medium"
|
className={styles.sortButton}
|
||||||
|
type="button"
|
||||||
disabled={!sortByButton}
|
disabled={!sortByButton}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -55,7 +58,7 @@ export function SortbySelector({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i className={iconStyle} />
|
<i className={iconStyle} />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,4 +45,19 @@
|
|||||||
:global :root[theme='dark'] :local .root :global .selector__option:active,
|
:global :root[theme='dark'] :local .root :global .selector__option:active,
|
||||||
:global :root[theme='dark'] :local .root :global .selector__option--is-focused {
|
:global :root[theme='dark'] :local .root :global .selector__option--is-focused {
|
||||||
background-color: var(--blue-2);
|
background-color: var(--blue-2);
|
||||||
|
color: var(--white-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global .selector__option--is-selected {
|
||||||
|
color: var(--grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global :root[theme='highcontrast'] :local .root :global .selector__single-value,
|
||||||
|
:global :root[theme='dark'] :local .root :global .selector__single-value {
|
||||||
|
color: var(--white-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global :root[theme='highcontrast'] :local .root :global .selector__input-container,
|
||||||
|
:global :root[theme='dark'] :local .root :global .selector__input-container {
|
||||||
|
color: var(--white-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
EnvironmentId,
|
EnvironmentId,
|
||||||
EnvironmentType,
|
EnvironmentType,
|
||||||
EnvironmentSettings,
|
EnvironmentSettings,
|
||||||
|
EnvironmentStatus,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import { arrayToJson, buildUrl } from './utils';
|
import { arrayToJson, buildUrl } from './utils';
|
||||||
@@ -19,14 +20,24 @@ export interface EnvironmentsQueryParams {
|
|||||||
tagIds?: TagId[];
|
tagIds?: TagId[];
|
||||||
endpointIds?: EnvironmentId[];
|
endpointIds?: EnvironmentId[];
|
||||||
tagsPartialMatch?: boolean;
|
tagsPartialMatch?: boolean;
|
||||||
groupId?: EnvironmentGroupId;
|
groupIds?: EnvironmentGroupId[];
|
||||||
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted';
|
status?: EnvironmentStatus[];
|
||||||
|
sort?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEndpoints(
|
export async function getEndpoints(
|
||||||
start: number,
|
start: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
{ types, tagIds, endpointIds, ...query }: EnvironmentsQueryParams = {}
|
{
|
||||||
|
types,
|
||||||
|
tagIds,
|
||||||
|
endpointIds,
|
||||||
|
status,
|
||||||
|
groupIds,
|
||||||
|
...query
|
||||||
|
}: EnvironmentsQueryParams = {}
|
||||||
) {
|
) {
|
||||||
if (tagIds && tagIds.length === 0) {
|
if (tagIds && tagIds.length === 0) {
|
||||||
return { totalCount: 0, value: <Environment[]>[] };
|
return { totalCount: 0, value: <Environment[]>[] };
|
||||||
@@ -48,6 +59,14 @@ export async function getEndpoints(
|
|||||||
params.endpointIds = arrayToJson(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 });
|
||||||
const totalCount = response.headers['x-total-count'];
|
const totalCount = response.headers['x-total-count'];
|
||||||
@@ -94,7 +113,7 @@ export async function endpointsByGroup(
|
|||||||
search: string,
|
search: string,
|
||||||
groupId: EnvironmentGroupId
|
groupId: EnvironmentGroupId
|
||||||
) {
|
) {
|
||||||
return getEndpoints(start, limit, { search, groupId });
|
return getEndpoints(start, limit, { search, groupIds: [groupId] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disassociateEndpoint(id: EnvironmentId) {
|
export async function disassociateEndpoint(id: EnvironmentId) {
|
||||||
|
|||||||
@@ -19,5 +19,5 @@
|
|||||||
.edit-button {
|
.edit-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 7px;
|
top: 5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{groupName && (
|
{groupName && (
|
||||||
<span className="small">
|
<span className="small space-right">
|
||||||
<span>Group: </span>
|
<span>Group: </span>
|
||||||
<span>{groupName}</span>
|
<span>{groupName}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
.filter-container {
|
.filter-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@
|
|||||||
|
|
||||||
.filter-right {
|
.filter-right {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
width: 15%;
|
width: 20%;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -33,3 +32,32 @@
|
|||||||
width: 5%;
|
width: 5%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
display: inline-block;
|
||||||
|
border: 0px;
|
||||||
|
padding: 10px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kubeconfig-button {
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSearchbar {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
EnvironmentType,
|
EnvironmentType,
|
||||||
EnvironmentStatus,
|
EnvironmentStatus,
|
||||||
} from '@/portainer/environments/types';
|
} from '@/portainer/environments/types';
|
||||||
|
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||||
import { Button } from '@/portainer/components/Button';
|
import { Button } from '@/portainer/components/Button';
|
||||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,10 @@ import {
|
|||||||
useSearchBarState,
|
useSearchBarState,
|
||||||
} from '@/portainer/components/datatables/components/FilterSearchBar';
|
} from '@/portainer/components/datatables/components/FilterSearchBar';
|
||||||
import { SortbySelector } from '@/portainer/components/datatables/components/SortbySelector';
|
import { SortbySelector } from '@/portainer/components/datatables/components/SortbySelector';
|
||||||
import { HomepageFilter } from '@/portainer/home/HomepageFilter';
|
import {
|
||||||
|
HomepageFilter,
|
||||||
|
useHomePageFilter,
|
||||||
|
} from '@/portainer/home/HomepageFilter';
|
||||||
import {
|
import {
|
||||||
TableActions,
|
TableActions,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
@@ -38,42 +42,99 @@ interface Props {
|
|||||||
onRefresh(): void;
|
onRefresh(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PlatformOptions = [
|
||||||
|
{ value: EnvironmentType.Docker, label: 'Docker' },
|
||||||
|
{ value: EnvironmentType.Azure, label: 'Azure' },
|
||||||
|
{ value: EnvironmentType.KubernetesLocal, label: 'Kubernetes' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const status = [
|
||||||
|
{ value: EnvironmentStatus.Up, label: 'Up' },
|
||||||
|
{ value: EnvironmentStatus.Down, label: 'Down' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SortByOptions = [
|
||||||
|
{ value: 1, label: 'Name' },
|
||||||
|
{ value: 2, label: 'Group' },
|
||||||
|
{ value: 3, label: 'Status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const storageKey = 'home_endpoints';
|
||||||
|
const allEnvironmentType = [
|
||||||
|
EnvironmentType.Docker,
|
||||||
|
EnvironmentType.AgentOnDocker,
|
||||||
|
EnvironmentType.Azure,
|
||||||
|
EnvironmentType.EdgeAgentOnDocker,
|
||||||
|
EnvironmentType.KubernetesLocal,
|
||||||
|
EnvironmentType.AgentOnKubernetes,
|
||||||
|
EnvironmentType.EdgeAgentOnKubernetes,
|
||||||
|
];
|
||||||
|
|
||||||
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const storageKey = 'home_endpoints';
|
|
||||||
const allEnvironmentType = [
|
|
||||||
EnvironmentType.Docker,
|
|
||||||
EnvironmentType.AgentOnDocker,
|
|
||||||
EnvironmentType.Azure,
|
|
||||||
EnvironmentType.EdgeAgentOnDocker,
|
|
||||||
EnvironmentType.KubernetesLocal,
|
|
||||||
EnvironmentType.AgentOnKubernetes,
|
|
||||||
EnvironmentType.EdgeAgentOnKubernetes,
|
|
||||||
];
|
|
||||||
|
|
||||||
const [platformType, setPlatformType] = useState(allEnvironmentType);
|
const [platformType, setPlatformType] = useHomePageFilter(
|
||||||
|
'platformType',
|
||||||
|
allEnvironmentType
|
||||||
|
);
|
||||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||||
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
|
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const debouncedTextFilter = useDebounce(searchBarValue);
|
const debouncedTextFilter = useDebounce(searchBarValue);
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
const [statusFilter, setStatusFilter] = useHomePageFilter<
|
||||||
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
EnvironmentStatus[]
|
||||||
const [groupFilter, setGroupFilter] = useState<number[]>([]);
|
>('status', []);
|
||||||
const [sortByFilter, setSortByFilter] = useState<string>('');
|
const [tagFilter, setTagFilter] = useHomePageFilter<number[]>('tag', []);
|
||||||
const [sortByDescending, setSortByDescending] = useState(false);
|
const [groupFilter, setGroupFilter] = useHomePageFilter<EnvironmentGroupId[]>(
|
||||||
const [sortByButton, setSortByButton] = useState(false);
|
'group',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [sortByFilter, setSortByFilter] = useSearchBarState('sortBy');
|
||||||
|
const [sortByDescending, setSortByDescending] = useHomePageFilter(
|
||||||
|
'sortOrder',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const [sortByButton, setSortByButton] = useHomePageFilter(
|
||||||
|
'sortByButton',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
const [platformState, setPlatformState] = useState<Filter[]>([]);
|
const [platformState, setPlatformState] = useHomePageFilter<Filter[]>(
|
||||||
const [statusState, setStatusState] = useState<Filter[]>([]);
|
'type_state',
|
||||||
const [tagState, setTagState] = useState<Filter[]>([]);
|
[]
|
||||||
const [groupState, setGroupState] = useState<Filter[]>([]);
|
);
|
||||||
|
const [statusState, setStatusState] = useHomePageFilter<Filter[]>(
|
||||||
|
'status_state',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [tagState, setTagState] = useHomePageFilter<Filter[]>('tag_state', []);
|
||||||
|
const [groupState, setGroupState] = useHomePageFilter<Filter[]>(
|
||||||
|
'group_state',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [sortByState, setSortByState] = useHomePageFilter<Filter | undefined>(
|
||||||
|
'sortby_state',
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
const groupsQuery = useGroups();
|
const groupsQuery = useGroups();
|
||||||
|
|
||||||
const { isLoading, environments, totalCount, totalAvailable } =
|
const { isLoading, environments, totalCount, totalAvailable } =
|
||||||
useEnvironmentList(
|
useEnvironmentList(
|
||||||
{ page, pageLimit, types: platformType, search: debouncedTextFilter },
|
{
|
||||||
|
page,
|
||||||
|
pageLimit,
|
||||||
|
types: platformType,
|
||||||
|
search: debouncedTextFilter,
|
||||||
|
status: statusFilter,
|
||||||
|
tagIds: tagFilter?.length ? tagFilter : undefined,
|
||||||
|
groupIds: groupFilter,
|
||||||
|
sort: sortByFilter,
|
||||||
|
order: sortByDescending ? 'desc' : 'asc',
|
||||||
|
edgeDeviceFilter: 'none',
|
||||||
|
tagsPartialMatch: true,
|
||||||
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,29 +142,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [searchBarValue]);
|
}, [searchBarValue]);
|
||||||
|
|
||||||
interface Collection {
|
|
||||||
Status: number[];
|
|
||||||
TagIds: number[];
|
|
||||||
GroupId: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlatformOptions = [
|
|
||||||
{ value: EnvironmentType.Docker, label: 'Docker' },
|
|
||||||
{ value: EnvironmentType.Azure, label: 'Azure' },
|
|
||||||
{ value: EnvironmentType.KubernetesLocal, label: 'Kubernetes' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const status = [
|
|
||||||
{ value: EnvironmentStatus.Up, label: 'Up' },
|
|
||||||
{ value: EnvironmentStatus.Down, label: 'Down' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SortByOptions = [
|
|
||||||
{ value: 1, label: 'Name' },
|
|
||||||
{ value: 2, label: 'Group' },
|
|
||||||
{ value: 3, label: 'Status' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const groupOptions = [...(groupsQuery.data || [])];
|
const groupOptions = [...(groupsQuery.data || [])];
|
||||||
const uniqueGroup = [
|
const uniqueGroup = [
|
||||||
...new Map(groupOptions.map((item) => [item.Id, item])).values(),
|
...new Map(groupOptions.map((item) => [item.Id, item])).values(),
|
||||||
@@ -121,64 +159,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
label,
|
label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const collection = {
|
|
||||||
Status: statusFilter,
|
|
||||||
TagIds: tagFilter,
|
|
||||||
GroupId: groupFilter,
|
|
||||||
};
|
|
||||||
|
|
||||||
function multiPropsFilter(
|
|
||||||
environments: Environment[],
|
|
||||||
collection: Collection,
|
|
||||||
sortByFilter: string,
|
|
||||||
sortByDescending: boolean
|
|
||||||
) {
|
|
||||||
const filterKeys = Object.keys(collection);
|
|
||||||
const filterResult = environments.filter((environment: Environment) =>
|
|
||||||
filterKeys.every((key) => {
|
|
||||||
if (!collection[key as keyof Collection].length) return true;
|
|
||||||
if (Array.isArray(environment[key as keyof Collection])) {
|
|
||||||
return (environment[key as keyof Collection] as number[]).some(
|
|
||||||
(keyEle) => collection[key as keyof Collection].includes(keyEle)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return collection[key as keyof Collection].includes(
|
|
||||||
environment[key as keyof Collection] as number
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (sortByFilter) {
|
|
||||||
case 'Name':
|
|
||||||
return sortByDescending
|
|
||||||
? filterResult.sort((a, b) =>
|
|
||||||
b.Name.toUpperCase() > a.Name.toUpperCase() ? 1 : -1
|
|
||||||
)
|
|
||||||
: filterResult.sort((a, b) =>
|
|
||||||
a.Name.toUpperCase() > b.Name.toUpperCase() ? 1 : -1
|
|
||||||
);
|
|
||||||
case 'Group':
|
|
||||||
return sortByDescending
|
|
||||||
? filterResult.sort((a, b) => b.GroupId - a.GroupId)
|
|
||||||
: filterResult.sort((a, b) => a.GroupId - b.GroupId);
|
|
||||||
case 'Status':
|
|
||||||
return sortByDescending
|
|
||||||
? filterResult.sort((a, b) => b.Status - a.Status)
|
|
||||||
: filterResult.sort((a, b) => a.Status - b.Status);
|
|
||||||
case 'None':
|
|
||||||
return filterResult;
|
|
||||||
default:
|
|
||||||
return filterResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredEnvironments: Environment[] = multiPropsFilter(
|
|
||||||
environments,
|
|
||||||
collection,
|
|
||||||
sortByFilter,
|
|
||||||
sortByDescending
|
|
||||||
);
|
|
||||||
|
|
||||||
function platformOnChange(filterOptions: Filter[]) {
|
function platformOnChange(filterOptions: Filter[]) {
|
||||||
setPlatformState(filterOptions);
|
setPlatformState(filterOptions);
|
||||||
const dockerBaseType = EnvironmentType.Docker;
|
const dockerBaseType = EnvironmentType.Docker;
|
||||||
@@ -192,24 +172,21 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
EnvironmentType.EdgeAgentOnKubernetes,
|
EnvironmentType.EdgeAgentOnKubernetes,
|
||||||
];
|
];
|
||||||
|
|
||||||
let finalFilterEnvironment: number[] = [];
|
|
||||||
|
|
||||||
if (filterOptions.length === 0) {
|
if (filterOptions.length === 0) {
|
||||||
setPlatformType(allEnvironmentType);
|
setPlatformType(allEnvironmentType);
|
||||||
} else {
|
} else {
|
||||||
const filteredEnvironment = [
|
let finalFilterEnvironment = filterOptions.map(
|
||||||
...new Set(
|
(filterOption) => filterOption.value
|
||||||
filterOptions.map(
|
);
|
||||||
(filterOptions: { value: number }) => filterOptions.value
|
if (finalFilterEnvironment.includes(dockerBaseType)) {
|
||||||
)
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (filteredEnvironment.includes(dockerBaseType)) {
|
|
||||||
finalFilterEnvironment = [...filteredEnvironment, ...dockerRelateType];
|
|
||||||
}
|
|
||||||
if (filteredEnvironment.includes(kubernetesBaseType)) {
|
|
||||||
finalFilterEnvironment = [
|
finalFilterEnvironment = [
|
||||||
...filteredEnvironment,
|
...finalFilterEnvironment,
|
||||||
|
...dockerRelateType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (finalFilterEnvironment.includes(kubernetesBaseType)) {
|
||||||
|
finalFilterEnvironment = [
|
||||||
|
...finalFilterEnvironment,
|
||||||
...kubernetesRelateType,
|
...kubernetesRelateType,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -266,7 +243,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearFilter() {
|
function clearFilter() {
|
||||||
setSearchBarValue('');
|
|
||||||
setPlatformState([]);
|
setPlatformState([]);
|
||||||
setPlatformType(allEnvironmentType);
|
setPlatformType(allEnvironmentType);
|
||||||
setStatusState([]);
|
setStatusState([]);
|
||||||
@@ -281,9 +257,11 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
if (filterOptions !== null) {
|
if (filterOptions !== null) {
|
||||||
setSortByFilter(filterOptions.label);
|
setSortByFilter(filterOptions.label);
|
||||||
setSortByButton(true);
|
setSortByButton(true);
|
||||||
|
setSortByState(filterOptions);
|
||||||
} else {
|
} else {
|
||||||
setSortByFilter('None');
|
setSortByFilter('');
|
||||||
setSortByButton(false);
|
setSortByButton(true);
|
||||||
|
setSortByState(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,19 +282,34 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
<i className="fa fa-exclamation-circle blue-icon space-right" />
|
<i className="fa fa-exclamation-circle blue-icon space-right" />
|
||||||
Click on an environment to manage
|
Click on an environment to manage
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.actionButton}>
|
||||||
{isAdmin && (
|
<div className={styles.refreshButton}>
|
||||||
<Button
|
{isAdmin && (
|
||||||
onClick={onRefresh}
|
<Button
|
||||||
data-cy="home-refreshEndpointsButton"
|
onClick={onRefresh}
|
||||||
className={clsx(styles.refreshEnvironmentsButton)}
|
data-cy="home-refreshEndpointsButton"
|
||||||
>
|
className={clsx(styles.refreshEnvironmentsButton)}
|
||||||
<i className="fa fa-sync space-right" aria-hidden="true" />
|
>
|
||||||
Refresh
|
<i
|
||||||
</Button>
|
className="fa fa-sync space-right"
|
||||||
)}
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<KubeconfigButton environments={environments} />
|
Refresh
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.kubeconfigButton}>
|
||||||
|
<KubeconfigButton environments={environments} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.filterSearchbar}>
|
||||||
|
<FilterSearchBar
|
||||||
|
value={searchBarValue}
|
||||||
|
onChange={setSearchBarValue}
|
||||||
|
placeholder="Search by name, group, tag, status, URL..."
|
||||||
|
data-cy="home-endpointsSearchInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableActions>
|
</TableActions>
|
||||||
<div className={styles.filterContainer}>
|
<div className={styles.filterContainer}>
|
||||||
<div className={styles.filterLeft}>
|
<div className={styles.filterLeft}>
|
||||||
@@ -351,19 +344,13 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
value={groupState}
|
value={groupState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.filterLeft}>
|
<button
|
||||||
<FilterSearchBar
|
type="button"
|
||||||
value={searchBarValue}
|
className={styles.clearButton}
|
||||||
onChange={setSearchBarValue}
|
onClick={clearFilter}
|
||||||
placeholder="Search...."
|
>
|
||||||
data-cy="home-endpointsSearchInput"
|
Clear all
|
||||||
/>
|
</button>
|
||||||
</div>
|
|
||||||
<div className={styles.filterButton}>
|
|
||||||
<Button size="medium" onClick={clearFilter}>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.filterRight}>
|
<div className={styles.filterRight}>
|
||||||
<SortbySelector
|
<SortbySelector
|
||||||
filterOptions={SortByOptions}
|
filterOptions={SortByOptions}
|
||||||
@@ -372,6 +359,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
placeHolder="Sort By"
|
placeHolder="Sort By"
|
||||||
sortByDescending={sortByDescending}
|
sortByDescending={sortByDescending}
|
||||||
sortByButton={sortByButton}
|
sortByButton={sortByButton}
|
||||||
|
value={sortByState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,7 +367,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||||||
{renderItems(
|
{renderItems(
|
||||||
isLoading,
|
isLoading,
|
||||||
totalCount,
|
totalCount,
|
||||||
filteredEnvironments.map((env) => (
|
environments.map((env) => (
|
||||||
<EnvironmentItem
|
<EnvironmentItem
|
||||||
key={env.Id}
|
key={env.Id}
|
||||||
environment={env}
|
environment={env}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { components, OptionProps } from 'react-select';
|
import { components, OptionProps } from 'react-select';
|
||||||
|
|
||||||
|
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
|
||||||
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||||
import { Filter } from '@/portainer/home/types';
|
import { Filter } from '@/portainer/home/types';
|
||||||
|
|
||||||
@@ -42,3 +43,33 @@ export function HomepageFilter({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useHomePageFilter<T>(
|
||||||
|
key: string,
|
||||||
|
defaultValue: T
|
||||||
|
): [T, (value: T) => void] {
|
||||||
|
const filterKey = keyBuilder(key);
|
||||||
|
const [storageValue, setStorageValue] = useLocalStorage(
|
||||||
|
filterKey,
|
||||||
|
JSON.stringify(defaultValue),
|
||||||
|
sessionStorage
|
||||||
|
);
|
||||||
|
const value = jsonParse(storageValue, defaultValue);
|
||||||
|
return [value, setValue];
|
||||||
|
|
||||||
|
function setValue(value?: T) {
|
||||||
|
setStorageValue(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyBuilder(key: string) {
|
||||||
|
return `datatable_home_filter_type_${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonParse<T>(value: string, defaultValue: T): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
-8
@@ -19,15 +19,24 @@ interface FormValues {
|
|||||||
TrustOnFirstConnect: boolean;
|
TrustOnFirstConnect: boolean;
|
||||||
}
|
}
|
||||||
const validation = yup.object({
|
const validation = yup.object({
|
||||||
TrustOnFirstConnect: yup.boolean().required('This field is required.'),
|
TrustOnFirstConnect: yup.boolean(),
|
||||||
EdgePortainerUrl: yup
|
EdgePortainerUrl: yup
|
||||||
.string()
|
.string()
|
||||||
.test(
|
.test(
|
||||||
'not-local',
|
'url',
|
||||||
'Cannot use localhost as environment URL',
|
'URL should be a valid URI and cannot include localhost',
|
||||||
(value) => !value?.includes('localhost')
|
(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return !!url.hostname && url.hostname !== 'localhost';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.url('URL should be a valid URI')
|
|
||||||
.required('URL is required'),
|
.required('URL is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,10 +72,10 @@ export function AutoEnvCreationSettingsForm({ settings }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url && validation.isValidSync({ url: defaultUrl })) {
|
if (!url && validation.isValidSync({ EdgePortainerUrl: defaultUrl })) {
|
||||||
handleSubmit({ EdgePortainerUrl: defaultUrl });
|
updateSettings({ EdgePortainerUrl: defaultUrl });
|
||||||
}
|
}
|
||||||
}, [handleSubmit, url]);
|
}, [updateSettings, url]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik<FormValues>
|
<Formik<FormValues>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ssl-certificate-settings ng-show="$ctrl.showHTTPS"></ssl-certificate-settings>
|
<ssl-certificate-settings ng-show="state.showHTTPS"></ssl-certificate-settings>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ services:
|
|||||||
portainer:
|
portainer:
|
||||||
image: ${DESKTOP_PLUGIN_IMAGE}
|
image: ${DESKTOP_PLUGIN_IMAGE}
|
||||||
command: ['--admin-password', '$$$$2y$$$$05$$$$bsb.XmF.r2DU6/9oVUaDxu3.Lxhmg1R8M0NMLK6JJKUiqUcaNjvdu']
|
command: ['--admin-password', '$$$$2y$$$$05$$$$bsb.XmF.r2DU6/9oVUaDxu3.Lxhmg1R8M0NMLK6JJKUiqUcaNjvdu']
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
|
environment:
|
||||||
|
- DOCKER_EXTENSION=1
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "Portainer",
|
|
||||||
"icon": "portainer.svg",
|
"icon": "portainer.svg",
|
||||||
"vm": {
|
"vm": {
|
||||||
"composefile": "docker-compose.yml",
|
"composefile": "docker-compose.yml",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
FROM portainer/base
|
FROM portainer/base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="Portainer" \
|
LABEL org.opencontainers.image.title="Portainer" \
|
||||||
org.opencontainers.image.description="Rich container management experience using Portainer." \
|
org.opencontainers.image.description="Docker container management made simple, with the world’s most popular GUI-based container management platform." \
|
||||||
org.opencontainers.image.vendor="Portainer.io" \
|
org.opencontainers.image.vendor="Portainer.io" \
|
||||||
com.docker.desktop.extension.api.version=">= 0.2.2" \
|
com.docker.desktop.extension.api.version=">= 0.2.2" \
|
||||||
com.docker.desktop.extension.icon=https://portainer-io-assets.sfo2.cdn.digitaloceanspaces.com/logos/portainer.png
|
com.docker.desktop.extension.icon="https://portainer-io-assets.sfo2.cdn.digitaloceanspaces.com/logos/portainer.png" \
|
||||||
|
com.docker.extension.screenshots="[{\"alt\": \"screenshot one\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-1.png\"},{\"alt\": \"screenshot two\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-2.png\"},{\"alt\": \"screenshot three\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-3.png\"},{\"alt\": \"screenshot four\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-4.png\"},{\"alt\": \"screenshot five\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-5.png\"},{\"alt\": \"screenshot six\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-6.png\"},{\"alt\": \"screenshot seven\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-7.png\"},{\"alt\": \"screenshot eight\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-8.png\"},{\"alt\": \"screenshot nine\", \"url\": \"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/docker-extension-9.png\"}]" \
|
||||||
|
com.docker.extension.detailed-description="<p data-renderer-start-pos=\"226\">Portainer’s Docker Desktop extension gives you access to all of Portainer’s rich management functionality within your docker desktop experience.</p><h2 data-renderer-start-pos=\"374\">With Portainer you can:</h2><ul><li>See all your running containers</li><li>Easily view all of your container logs</li><li>Console into containers</li><li>Easily deploy your code into containers using a simple form</li><li>Turn your YAML into custom templates for easy reuse</li></ul><h2 data-renderer-start-pos=\"660\">About Portainer </h2><p data-renderer-start-pos=\"680\">Portainer is the worlds’ most popular universal container management platform with more than 650,000 active monthly users. Portainer can be used to manage Docker Standalone, Kubernetes, Docker Swarm and Nomad environments through a single common interface. It includes a simple GitOps automation engine and a Kube API. </p><p data-renderer-start-pos=\"1006\">Portainer Business Edition is our fully supported commercial grade product for business-wide use. It includes all the functionality that businesses need to manage containers at scale. Visit <a class=\"sc-jKJlTe dPfAtb\" href=\"http://portainer.io/\" title=\"http://Portainer.io\" data-renderer-mark=\"true\">Portainer.io</a> to learn more about Portainer Business and <a class=\"sc-jKJlTe dPfAtb\" href=\"http://portainer.io/take5?utm_campaign=DockerCon&utm_source=Docker%20Desktop\" title=\"http://portainer.io/take5?utm_campaign=DockerCon&utm_source=Docker%20Desktop\" data-renderer-mark=\"true\">get 5 free nodes.</a></p>" \
|
||||||
|
com.docker.extension.publisher-url="https://www.portainer.io" \
|
||||||
|
com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]"
|
||||||
|
|
||||||
COPY dist /
|
COPY dist /
|
||||||
COPY build/docker-extension /
|
COPY build/docker-extension /
|
||||||
|
|||||||
Reference in New Issue
Block a user