mirror of https://github.com/portainer/portainer
feat(edge): show correct heartbeat and sync aeec changes [EE-2876] (#6769)
parent
76d1b70644
commit
e217ac7121
|
@ -587,7 +587,6 @@
|
||||||
"AllowVolumeBrowserForRegularUsers": false,
|
"AllowVolumeBrowserForRegularUsers": false,
|
||||||
"AuthenticationMethod": 1,
|
"AuthenticationMethod": 1,
|
||||||
"BlackListedLabels": [],
|
"BlackListedLabels": [],
|
||||||
"DisableTrustOnFirstConnect": false,
|
|
||||||
"DisplayDonationHeader": false,
|
"DisplayDonationHeader": false,
|
||||||
"DisplayExternalContributors": false,
|
"DisplayExternalContributors": false,
|
||||||
"EdgeAgentCheckinInterval": 5,
|
"EdgeAgentCheckinInterval": 5,
|
||||||
|
@ -642,6 +641,7 @@
|
||||||
},
|
},
|
||||||
"SnapshotInterval": "5m",
|
"SnapshotInterval": "5m",
|
||||||
"TemplatesURL": "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json",
|
"TemplatesURL": "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json",
|
||||||
|
"TrustOnFirstConnect": false,
|
||||||
"UserSessionTimeout": "8h",
|
"UserSessionTimeout": "8h",
|
||||||
"fdoConfiguration": {
|
"fdoConfiguration": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
|
@ -326,6 +326,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
|
||||||
EdgeCheckinInterval: payload.EdgeCheckinInterval,
|
EdgeCheckinInterval: payload.EdgeCheckinInterval,
|
||||||
Kubernetes: portainer.KubernetesDefault(),
|
Kubernetes: portainer.KubernetesDefault(),
|
||||||
IsEdgeDevice: payload.IsEdgeDevice,
|
IsEdgeDevice: payload.IsEdgeDevice,
|
||||||
|
UserTrusted: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
|
|
@ -14,6 +14,12 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EdgeDeviceFilterAll = "all"
|
||||||
|
EdgeDeviceFilterTrusted = "trusted"
|
||||||
|
EdgeDeviceFilterUntrusted = "untrusted"
|
||||||
|
)
|
||||||
|
|
||||||
// @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
|
||||||
|
@ -32,6 +38,7 @@ import (
|
||||||
// @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")
|
||||||
// @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]
|
||||||
|
@ -91,8 +98,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||||
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
|
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeDeviceFilter, edgeDeviceFilterErr := request.RetrieveBooleanQueryParameter(r, "edgeDeviceFilter", false)
|
edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false)
|
||||||
if edgeDeviceFilterErr == nil {
|
if edgeDeviceFilter != "" {
|
||||||
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -240,17 +247,38 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int)
|
||||||
return filteredEndpoints
|
return filteredEndpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter bool) []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 edgeDeviceFilter == endpoint.IsEdgeDevice {
|
if shouldReturnEdgeDevice(endpoint, edgeDeviceFilter) {
|
||||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filteredEndpoints
|
return filteredEndpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceFilter string) bool {
|
||||||
|
if !endpoint.IsEdgeDevice {
|
||||||
|
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 {
|
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
|
||||||
tags := make([]string, 0)
|
tags := make([]string, 0)
|
||||||
for _, tagID := range tagIDs {
|
for _, tagID := range tagIDs {
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type endpointListEdgeDeviceTest struct {
|
||||||
|
expected []portainer.EndpointID
|
||||||
|
filter string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_endpointList(t *testing.T) {
|
||||||
|
is := assert.New(t)
|
||||||
|
|
||||||
|
_, store, teardown := datastore.MustNewTestStore(true, true)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
trustedEndpoint := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1}
|
||||||
|
err := store.Endpoint().Create(&trustedEndpoint)
|
||||||
|
is.NoError(err, "error creating environment")
|
||||||
|
|
||||||
|
untrustedEndpoint := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1}
|
||||||
|
err = store.Endpoint().Create(&untrustedEndpoint)
|
||||||
|
is.NoError(err, "error creating environment")
|
||||||
|
|
||||||
|
regularEndpoint := portainer.Endpoint{ID: 3, IsEdgeDevice: false, GroupID: 1}
|
||||||
|
err = store.Endpoint().Create(®ularEndpoint)
|
||||||
|
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)
|
||||||
|
h.DataStore = store
|
||||||
|
h.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||||
|
|
||||||
|
tests := []endpointListEdgeDeviceTest{
|
||||||
|
{
|
||||||
|
[]portainer.EndpointID{trustedEndpoint.ID, untrustedEndpoint.ID},
|
||||||
|
EdgeDeviceFilterAll,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[]portainer.EndpointID{trustedEndpoint.ID},
|
||||||
|
EdgeDeviceFilterTrusted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[]portainer.EndpointID{untrustedEndpoint.ID},
|
||||||
|
EdgeDeviceFilterUntrusted,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
req := buildEndpointListRequest(test.filter)
|
||||||
|
resp, err := doEndpointListRequest(req, h, is)
|
||||||
|
is.NoError(err)
|
||||||
|
|
||||||
|
is.Equal(len(test.expected), len(resp))
|
||||||
|
|
||||||
|
respIds := []portainer.EndpointID{}
|
||||||
|
|
||||||
|
for _, endpoint := range resp {
|
||||||
|
respIds = append(respIds, endpoint.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
is.Equal(test.expected, respIds, "response should contain all edge devices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEndpointListRequest(filter string) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/endpoints?edgeDeviceFilter=%s", filter), nil)
|
||||||
|
|
||||||
|
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
|
||||||
|
req = req.WithContext(restrictedCtx)
|
||||||
|
|
||||||
|
req.Header.Add("Authorization", "Bearer dummytoken")
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func doEndpointListRequest(req *http.Request, h *Handler, is *assert.Assertions) ([]portainer.Endpoint, error) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||||
|
body, err := io.ReadAll(rr.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := []portainer.Endpoint{}
|
||||||
|
err = json.Unmarshal(body, &resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
|
@ -46,8 +46,6 @@ type endpointUpdatePayload struct {
|
||||||
EdgeCheckinInterval *int `example:"5"`
|
EdgeCheckinInterval *int `example:"5"`
|
||||||
// Associated Kubernetes data
|
// Associated Kubernetes data
|
||||||
Kubernetes *portainer.KubernetesData
|
Kubernetes *portainer.KubernetesData
|
||||||
// Whether the device has been trusted or not by the user
|
|
||||||
UserTrusted *bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
|
func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
@ -273,10 +271,6 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.UserTrusted != nil {
|
|
||||||
endpoint.UserTrusted = *payload.UserTrusted
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/proxy"
|
"github.com/portainer/portainer/api/http/proxy"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
|
|
||||||
|
@ -21,10 +20,21 @@ func hideFields(endpoint *portainer.Endpoint) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This requestBouncer exists because security.RequestBounder is a type and not an interface.
|
||||||
|
// Therefore we can not swit it out with a dummy bouncer for go tests. This interface works around it
|
||||||
|
type requestBouncer interface {
|
||||||
|
AuthenticatedAccess(h http.Handler) http.Handler
|
||||||
|
AdminAccess(h http.Handler) http.Handler
|
||||||
|
RestrictedAccess(h http.Handler) http.Handler
|
||||||
|
PublicAccess(h http.Handler) http.Handler
|
||||||
|
AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error
|
||||||
|
AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error
|
||||||
|
}
|
||||||
|
|
||||||
// Handler is the HTTP handler used to handle environment(endpoint) operations.
|
// Handler is the HTTP handler used to handle environment(endpoint) operations.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
requestBouncer *security.RequestBouncer
|
requestBouncer requestBouncer
|
||||||
DataStore dataservices.DataStore
|
DataStore dataservices.DataStore
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
|
@ -38,7 +48,7 @@ type Handler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
// NewHandler creates a handler to manage environment(endpoint) operations.
|
||||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
func NewHandler(bouncer requestBouncer) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
requestBouncer: bouncer,
|
requestBouncer: bouncer,
|
||||||
|
|
|
@ -43,8 +43,8 @@ type settingsUpdatePayload struct {
|
||||||
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
||||||
// Kubectl Shell Image
|
// Kubectl Shell Image
|
||||||
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
||||||
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
|
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||||
DisableTrustOnFirstConnect *bool `example:"false"`
|
TrustOnFirstConnect *bool `example:"false"`
|
||||||
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
||||||
EnforceEdgeID *bool `example:"false"`
|
EnforceEdgeID *bool `example:"false"`
|
||||||
// EdgePortainerURL is the URL that is exposed to edge agents
|
// EdgePortainerURL is the URL that is exposed to edge agents
|
||||||
|
@ -180,8 +180,8 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures
|
settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.DisableTrustOnFirstConnect != nil {
|
if payload.TrustOnFirstConnect != nil {
|
||||||
settings.DisableTrustOnFirstConnect = *payload.DisableTrustOnFirstConnect
|
settings.TrustOnFirstConnect = *payload.TrustOnFirstConnect
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.EnforceEdgeID != nil {
|
if payload.EnforceEdgeID != nil {
|
||||||
|
|
|
@ -144,7 +144,7 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
|
||||||
return fmt.Errorf("could not retrieve the settings: %w", err)
|
return fmt.Errorf("could not retrieve the settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.DisableTrustOnFirstConnect {
|
if !settings.TrustOnFirstConnect {
|
||||||
return errors.New("the device has not been trusted yet")
|
return errors.New("the device has not been trusted yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
package testhelpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type composeStackManager struct{}
|
||||||
|
|
||||||
|
func NewComposeStackManager() *composeStackManager {
|
||||||
|
return &composeStackManager{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *composeStackManager) ComposeSyntaxMaxVersion() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *composeStackManager) NormalizeStackName(name string) string {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *composeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, forceRereate bool) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *composeStackManager) Down(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -1,6 +1,10 @@
|
||||||
package testhelpers
|
package testhelpers
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
type testRequestBouncer struct {
|
type testRequestBouncer struct {
|
||||||
}
|
}
|
||||||
|
@ -13,3 +17,23 @@ func NewTestRequestBouncer() *testRequestBouncer {
|
||||||
func (testRequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
|
func (testRequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (testRequestBouncer) AdminAccess(h http.Handler) http.Handler {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testRequestBouncer) RestrictedAccess(h http.Handler) http.Handler {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testRequestBouncer) PublicAccess(h http.Handler) http.Handler {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testRequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testRequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -809,8 +809,8 @@ type (
|
||||||
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
||||||
// KubectlImage, defaults to portainer/kubectl-shell
|
// KubectlImage, defaults to portainer/kubectl-shell
|
||||||
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
||||||
// DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection
|
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||||
DisableTrustOnFirstConnect bool `json:"DisableTrustOnFirstConnect" example:"false"`
|
TrustOnFirstConnect bool `json:"TrustOnFirstConnect" example:"false"`
|
||||||
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
// EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone
|
||||||
EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"`
|
EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"`
|
||||||
// Container environment parameter AGENT_SECRET
|
// Container environment parameter AGENT_SECRET
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||||
|
import {
|
||||||
|
PaginationTableSettings,
|
||||||
|
RefreshableTableSettings,
|
||||||
|
SettableColumnsTableSettings,
|
||||||
|
SettableQuickActionsTableSettings,
|
||||||
|
SortableTableSettings,
|
||||||
|
} from '@/portainer/components/datatables/types';
|
||||||
|
|
||||||
export type DockerContainerStatus =
|
export type DockerContainerStatus =
|
||||||
| 'paused'
|
| 'paused'
|
||||||
|
@ -13,13 +20,13 @@ export type DockerContainerStatus =
|
||||||
|
|
||||||
export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats';
|
export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats';
|
||||||
|
|
||||||
export interface ContainersTableSettings {
|
export interface ContainersTableSettings
|
||||||
hiddenQuickActions: QuickAction[];
|
extends SortableTableSettings,
|
||||||
hiddenColumns: string[];
|
PaginationTableSettings,
|
||||||
|
SettableColumnsTableSettings,
|
||||||
|
SettableQuickActionsTableSettings<QuickAction>,
|
||||||
|
RefreshableTableSettings {
|
||||||
truncateContainerName: number;
|
truncateContainerName: number;
|
||||||
autoRefreshRate: number;
|
|
||||||
pageSize: number;
|
|
||||||
sortBy: { id: string; desc: boolean };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Port {
|
export interface Port {
|
||||||
|
|
|
@ -0,0 +1,214 @@
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
useGlobalFilter,
|
||||||
|
usePagination,
|
||||||
|
useRowSelect,
|
||||||
|
useSortBy,
|
||||||
|
useTable,
|
||||||
|
} from 'react-table';
|
||||||
|
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||||
|
|
||||||
|
import { Button } from '@/portainer/components/Button';
|
||||||
|
import { Table } from '@/portainer/components/datatables/components';
|
||||||
|
import {
|
||||||
|
SearchBar,
|
||||||
|
useSearchBarState,
|
||||||
|
} from '@/portainer/components/datatables/components/SearchBar';
|
||||||
|
import { SelectedRowsCount } from '@/portainer/components/datatables/components/SelectedRowsCount';
|
||||||
|
import { PaginationControls } from '@/portainer/components/pagination-controls';
|
||||||
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { useAssociateDeviceMutation } from '../queries';
|
||||||
|
|
||||||
|
import { TableSettings } from './types';
|
||||||
|
|
||||||
|
const columns: readonly Column<Environment>[] = [
|
||||||
|
{
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row) => row.Name,
|
||||||
|
id: 'name',
|
||||||
|
disableFilters: true,
|
||||||
|
Filter: () => null,
|
||||||
|
canHide: false,
|
||||||
|
sortType: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Edge ID',
|
||||||
|
accessor: (row) => row.EdgeID,
|
||||||
|
id: 'edge-id',
|
||||||
|
disableFilters: true,
|
||||||
|
Filter: () => null,
|
||||||
|
canHide: false,
|
||||||
|
sortType: 'string',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
devices: Environment[];
|
||||||
|
isLoading: boolean;
|
||||||
|
totalCount: number;
|
||||||
|
storageKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable({
|
||||||
|
devices,
|
||||||
|
storageKey,
|
||||||
|
isLoading,
|
||||||
|
totalCount,
|
||||||
|
}: Props) {
|
||||||
|
const associateMutation = useAssociateDeviceMutation();
|
||||||
|
|
||||||
|
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||||
|
const { settings, setTableSettings } = useTableSettings<TableSettings>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
page,
|
||||||
|
prepareRow,
|
||||||
|
selectedFlatRows,
|
||||||
|
|
||||||
|
gotoPage,
|
||||||
|
setPageSize,
|
||||||
|
|
||||||
|
setGlobalFilter,
|
||||||
|
state: { pageIndex, pageSize },
|
||||||
|
} = useTable<Environment>(
|
||||||
|
{
|
||||||
|
defaultCanFilter: false,
|
||||||
|
columns,
|
||||||
|
data: devices,
|
||||||
|
|
||||||
|
initialState: {
|
||||||
|
pageSize: settings.pageSize || 10,
|
||||||
|
sortBy: [settings.sortBy],
|
||||||
|
globalFilter: searchBarValue,
|
||||||
|
},
|
||||||
|
isRowSelectable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
autoResetSelectedRows: false,
|
||||||
|
getRowId(originalRow: Environment) {
|
||||||
|
return originalRow.Id.toString();
|
||||||
|
},
|
||||||
|
selectColumnWidth: 5,
|
||||||
|
},
|
||||||
|
useGlobalFilter,
|
||||||
|
useSortBy,
|
||||||
|
|
||||||
|
usePagination,
|
||||||
|
useRowSelect,
|
||||||
|
useRowSelectColumn
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableProps = getTableProps();
|
||||||
|
const tbodyProps = getTableBodyProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Table.Container>
|
||||||
|
<Table.Title label="Edge Devices Waiting Room" icon="" />
|
||||||
|
<Table.Actions>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleAssociateDevice(selectedFlatRows.map((r) => r.original))
|
||||||
|
}
|
||||||
|
disabled={selectedFlatRows.length === 0}
|
||||||
|
>
|
||||||
|
Associate Device
|
||||||
|
</Button>
|
||||||
|
</Table.Actions>
|
||||||
|
|
||||||
|
<SearchBar onChange={handleSearchBarChange} value={searchBarValue} />
|
||||||
|
|
||||||
|
<Table
|
||||||
|
className={tableProps.className}
|
||||||
|
role={tableProps.role}
|
||||||
|
style={tableProps.style}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, className, role, style } =
|
||||||
|
headerGroup.getHeaderGroupProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.HeaderRow<Environment>
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
headers={headerGroup.headers}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody
|
||||||
|
className={tbodyProps.className}
|
||||||
|
role={tbodyProps.role}
|
||||||
|
style={tbodyProps.style}
|
||||||
|
>
|
||||||
|
<Table.Content
|
||||||
|
emptyContent="No Edge Devices found"
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
rows={page}
|
||||||
|
isLoading={isLoading}
|
||||||
|
renderRow={(row, { key, className, role, style }) => (
|
||||||
|
<Table.Row
|
||||||
|
cells={row.cells}
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Table.Footer>
|
||||||
|
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||||
|
<PaginationControls
|
||||||
|
showAll
|
||||||
|
pageLimit={pageSize}
|
||||||
|
page={pageIndex + 1}
|
||||||
|
onPageChange={(p) => gotoPage(p - 1)}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageLimitChange={handlePageLimitChange}
|
||||||
|
/>
|
||||||
|
</Table.Footer>
|
||||||
|
</Table.Container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSortChange(colId: string, desc: boolean) {
|
||||||
|
setTableSettings({ sortBy: { id: colId, desc } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageLimitChange(pageSize: number) {
|
||||||
|
setPageSize(pageSize);
|
||||||
|
setTableSettings({ pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchBarChange(value: string) {
|
||||||
|
setGlobalFilter(value);
|
||||||
|
setSearchBarValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssociateDevice(devices: Environment[]) {
|
||||||
|
associateMutation.mutate(
|
||||||
|
devices.map((d) => d.Id),
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Edge devices associated successfully');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {
|
||||||
|
PaginationTableSettings,
|
||||||
|
SortableTableSettings,
|
||||||
|
} from '@/portainer/components/datatables/types';
|
||||||
|
|
||||||
|
export interface TableSettings
|
||||||
|
extends SortableTableSettings,
|
||||||
|
PaginationTableSettings {}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
|
||||||
|
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||||
|
import { useEnvironmentList } from '@/portainer/environments/queries';
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
|
||||||
|
import { DataTable } from './Datatable/Datatable';
|
||||||
|
import { TableSettings } from './Datatable/types';
|
||||||
|
|
||||||
|
export function WaitingRoomView() {
|
||||||
|
const storageKey = 'edge-devices-waiting-room';
|
||||||
|
const router = useRouter();
|
||||||
|
const { environments, isLoading, totalCount } = useEnvironmentList({
|
||||||
|
edgeDeviceFilter: 'untrusted',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.PORTAINER_EDITION !== 'BE') {
|
||||||
|
router.stateService.go('edge.devices');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Waiting Room"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Edge Devices', link: 'edge.devices' },
|
||||||
|
{ label: 'Waiting Room' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TableSettingsProvider<TableSettings>
|
||||||
|
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
|
||||||
|
storageKey={storageKey}
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
devices={environments}
|
||||||
|
totalCount={totalCount}
|
||||||
|
isLoading={isLoading}
|
||||||
|
storageKey={storageKey}
|
||||||
|
/>
|
||||||
|
</TableSettingsProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WaitingRoomViewAngular = r2a(WaitingRoomView, []);
|
|
@ -0,0 +1 @@
|
||||||
|
export { WaitingRoomView, WaitingRoomViewAngular } from './WaitingRoomView';
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/portainer/environments/types';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||||
|
|
||||||
|
export function useAssociateDeviceMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation(
|
||||||
|
(ids: EnvironmentId[]) =>
|
||||||
|
promiseSequence(ids.map((id) => () => associateDevice(id))),
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(['environments']);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
error: {
|
||||||
|
title: 'Failure',
|
||||||
|
message: 'Failed to associate devices',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function associateDevice(environmentId: EnvironmentId) {
|
||||||
|
try {
|
||||||
|
await axios.post(`/endpoints/${environmentId}/edge/trust`);
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Failed to associate device');
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,134 +3,150 @@ import angular from 'angular';
|
||||||
import edgeStackModule from './views/edge-stacks';
|
import edgeStackModule from './views/edge-stacks';
|
||||||
import edgeDevicesModule from './devices';
|
import edgeDevicesModule from './devices';
|
||||||
import { componentsModule } from './components';
|
import { componentsModule } from './components';
|
||||||
|
import { WaitingRoomViewAngular } from './EdgeDevices/WaitingRoomView';
|
||||||
|
|
||||||
angular.module('portainer.edge', [edgeStackModule, edgeDevicesModule, componentsModule]).config(function config($stateRegistryProvider) {
|
angular
|
||||||
const edge = {
|
.module('portainer.edge', [edgeStackModule, edgeDevicesModule, componentsModule])
|
||||||
name: 'edge',
|
.component('waitingRoomView', WaitingRoomViewAngular)
|
||||||
url: '/edge',
|
.config(function config($stateRegistryProvider) {
|
||||||
parent: 'root',
|
const edge = {
|
||||||
abstract: true,
|
name: 'edge',
|
||||||
};
|
url: '/edge',
|
||||||
|
parent: 'root',
|
||||||
|
abstract: true,
|
||||||
|
};
|
||||||
|
|
||||||
const groups = {
|
const groups = {
|
||||||
name: 'edge.groups',
|
name: 'edge.groups',
|
||||||
url: '/groups',
|
url: '/groups',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'edgeGroupsView',
|
component: 'edgeGroupsView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const groupsNew = {
|
const groupsNew = {
|
||||||
name: 'edge.groups.new',
|
name: 'edge.groups.new',
|
||||||
url: '/new',
|
url: '/new',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'createEdgeGroupView',
|
component: 'createEdgeGroupView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const groupsEdit = {
|
const groupsEdit = {
|
||||||
name: 'edge.groups.edit',
|
name: 'edge.groups.edit',
|
||||||
url: '/:groupId',
|
url: '/:groupId',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'editEdgeGroupView',
|
component: 'editEdgeGroupView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const stacks = {
|
const stacks = {
|
||||||
name: 'edge.stacks',
|
name: 'edge.stacks',
|
||||||
url: '/stacks',
|
url: '/stacks',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'edgeStacksView',
|
component: 'edgeStacksView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const stacksNew = {
|
const stacksNew = {
|
||||||
name: 'edge.stacks.new',
|
name: 'edge.stacks.new',
|
||||||
url: '/new',
|
url: '/new',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'createEdgeStackView',
|
component: 'createEdgeStackView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const stacksEdit = {
|
const stacksEdit = {
|
||||||
name: 'edge.stacks.edit',
|
name: 'edge.stacks.edit',
|
||||||
url: '/:stackId',
|
url: '/:stackId',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'editEdgeStackView',
|
component: 'editEdgeStackView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
params: {
|
||||||
params: {
|
tab: 0,
|
||||||
tab: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const edgeJobs = {
|
|
||||||
name: 'edge.jobs',
|
|
||||||
url: '/jobs',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
component: 'edgeJobsView',
|
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const edgeJob = {
|
const edgeJobs = {
|
||||||
name: 'edge.jobs.job',
|
name: 'edge.jobs',
|
||||||
url: '/:id',
|
url: '/jobs',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'edgeJobView',
|
component: 'edgeJobsView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
params: {
|
|
||||||
tab: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const edgeJobCreation = {
|
const edgeJob = {
|
||||||
name: 'edge.jobs.new',
|
name: 'edge.jobs.job',
|
||||||
url: '/new',
|
url: '/:id',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
component: 'createEdgeJobView',
|
component: 'edgeJobView',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
params: {
|
||||||
};
|
tab: 0,
|
||||||
|
|
||||||
const edgeDevices = {
|
|
||||||
name: 'edge.devices',
|
|
||||||
url: '/devices',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
component: 'edgeDevicesView',
|
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
$stateRegistryProvider.register(edge);
|
const edgeJobCreation = {
|
||||||
|
name: 'edge.jobs.new',
|
||||||
|
url: '/new',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'createEdgeJobView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
$stateRegistryProvider.register(groups);
|
const edgeDevices = {
|
||||||
$stateRegistryProvider.register(groupsNew);
|
name: 'edge.devices',
|
||||||
$stateRegistryProvider.register(groupsEdit);
|
url: '/devices',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'edgeDevicesView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
$stateRegistryProvider.register(stacks);
|
if (process.env.PORTAINER_EDITION === 'BE') {
|
||||||
$stateRegistryProvider.register(stacksNew);
|
$stateRegistryProvider.register({
|
||||||
$stateRegistryProvider.register(stacksEdit);
|
name: 'edge.devices.waiting-room',
|
||||||
|
url: '/waiting-room',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'waitingRoomView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$stateRegistryProvider.register(edgeJobs);
|
$stateRegistryProvider.register(edge);
|
||||||
$stateRegistryProvider.register(edgeJob);
|
|
||||||
$stateRegistryProvider.register(edgeJobCreation);
|
|
||||||
|
|
||||||
$stateRegistryProvider.register(edgeDevices);
|
$stateRegistryProvider.register(groups);
|
||||||
});
|
$stateRegistryProvider.register(groupsNew);
|
||||||
|
$stateRegistryProvider.register(groupsEdit);
|
||||||
|
|
||||||
|
$stateRegistryProvider.register(stacks);
|
||||||
|
$stateRegistryProvider.register(stacksNew);
|
||||||
|
$stateRegistryProvider.register(stacksEdit);
|
||||||
|
|
||||||
|
$stateRegistryProvider.register(edgeJobs);
|
||||||
|
$stateRegistryProvider.register(edgeJob);
|
||||||
|
$stateRegistryProvider.register(edgeJobCreation);
|
||||||
|
|
||||||
|
$stateRegistryProvider.register(edgeDevices);
|
||||||
|
});
|
||||||
|
|
|
@ -19,7 +19,7 @@ export function EdgePropertiesForm({
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<form className="form-horizontal">
|
<form className="form-horizontal">
|
||||||
<FormSectionTitle>Edge script settings</FormSectionTitle>
|
<FormSectionTitle>Edge agent deployment script</FormSectionTitle>
|
||||||
|
|
||||||
<OsSelector
|
<OsSelector
|
||||||
value={values.os}
|
value={values.os}
|
||||||
|
|
|
@ -50,7 +50,7 @@ export interface EdgeDevicesTableProps {
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
isFdoEnabled: boolean;
|
isFdoEnabled: boolean;
|
||||||
isOpenAmtEnabled: boolean;
|
isOpenAmtEnabled: boolean;
|
||||||
disableTrustOnFirstConnect: boolean;
|
showWaitingRoomLink: boolean;
|
||||||
mpsServer: string;
|
mpsServer: string;
|
||||||
dataset: Environment[];
|
dataset: Environment[];
|
||||||
groups: EnvironmentGroup[];
|
groups: EnvironmentGroup[];
|
||||||
|
@ -62,7 +62,7 @@ export function EdgeDevicesDatatable({
|
||||||
storageKey,
|
storageKey,
|
||||||
isFdoEnabled,
|
isFdoEnabled,
|
||||||
isOpenAmtEnabled,
|
isOpenAmtEnabled,
|
||||||
disableTrustOnFirstConnect,
|
showWaitingRoomLink,
|
||||||
mpsServer,
|
mpsServer,
|
||||||
dataset,
|
dataset,
|
||||||
groups,
|
groups,
|
||||||
|
@ -164,6 +164,7 @@ export function EdgeDevicesDatatable({
|
||||||
isFDOEnabled={isFdoEnabled}
|
isFDOEnabled={isFdoEnabled}
|
||||||
isOpenAMTEnabled={isOpenAmtEnabled}
|
isOpenAMTEnabled={isOpenAmtEnabled}
|
||||||
setLoadingMessage={setLoadingMessage}
|
setLoadingMessage={setLoadingMessage}
|
||||||
|
showWaitingRoomLink={showWaitingRoomLink}
|
||||||
/>
|
/>
|
||||||
</TableActions>
|
</TableActions>
|
||||||
|
|
||||||
|
@ -216,7 +217,6 @@ export function EdgeDevicesDatatable({
|
||||||
return (
|
return (
|
||||||
<RowProvider
|
<RowProvider
|
||||||
key={key}
|
key={key}
|
||||||
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
|
|
||||||
isOpenAmtEnabled={isOpenAmtEnabled}
|
isOpenAmtEnabled={isOpenAmtEnabled}
|
||||||
groupName={group[0]?.Name}
|
groupName={group[0]?.Name}
|
||||||
>
|
>
|
||||||
|
|
|
@ -7,12 +7,14 @@ import { promptAsync } from '@/portainer/services/modal.service/prompt';
|
||||||
import * as notifications from '@/portainer/services/notifications';
|
import * as notifications from '@/portainer/services/notifications';
|
||||||
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
|
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
|
||||||
import { deleteEndpoint } from '@/portainer/environments/environment.service';
|
import { deleteEndpoint } from '@/portainer/environments/environment.service';
|
||||||
|
import { Link } from '@/portainer/components/Link';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedItems: Environment[];
|
selectedItems: Environment[];
|
||||||
isFDOEnabled: boolean;
|
isFDOEnabled: boolean;
|
||||||
isOpenAMTEnabled: boolean;
|
isOpenAMTEnabled: boolean;
|
||||||
setLoadingMessage(message: string): void;
|
setLoadingMessage(message: string): void;
|
||||||
|
showWaitingRoomLink: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EdgeDevicesDatatableActions({
|
export function EdgeDevicesDatatableActions({
|
||||||
|
@ -20,6 +22,7 @@ export function EdgeDevicesDatatableActions({
|
||||||
isOpenAMTEnabled,
|
isOpenAMTEnabled,
|
||||||
isFDOEnabled,
|
isFDOEnabled,
|
||||||
setLoadingMessage,
|
setLoadingMessage,
|
||||||
|
showWaitingRoomLink,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
@ -48,6 +51,12 @@ export function EdgeDevicesDatatableActions({
|
||||||
Associate with OpenAMT
|
Associate with OpenAMT
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showWaitingRoomLink && (
|
||||||
|
<Link to="edge.devices.waiting-room">
|
||||||
|
<Button>Waiting Room</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ export const EdgeDevicesDatatableAngular = react2angular(
|
||||||
'onRefresh',
|
'onRefresh',
|
||||||
'setLoadingMessage',
|
'setLoadingMessage',
|
||||||
'isFdoEnabled',
|
'isFdoEnabled',
|
||||||
'disableTrustOnFirstConnect',
|
'showWaitingRoomLink',
|
||||||
'isOpenAmtEnabled',
|
'isOpenAmtEnabled',
|
||||||
'mpsServer',
|
'mpsServer',
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
|
import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
interface RowContextState {
|
interface RowContextState {
|
||||||
disableTrustOnFirstConnect: boolean;
|
|
||||||
isOpenAmtEnabled: boolean;
|
isOpenAmtEnabled: boolean;
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
}
|
}
|
||||||
|
@ -9,20 +8,18 @@ interface RowContextState {
|
||||||
const RowContext = createContext<RowContextState | null>(null);
|
const RowContext = createContext<RowContextState | null>(null);
|
||||||
|
|
||||||
export interface RowProviderProps {
|
export interface RowProviderProps {
|
||||||
disableTrustOnFirstConnect: boolean;
|
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
isOpenAmtEnabled: boolean;
|
isOpenAmtEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RowProvider({
|
export function RowProvider({
|
||||||
disableTrustOnFirstConnect,
|
|
||||||
groupName,
|
groupName,
|
||||||
isOpenAmtEnabled,
|
isOpenAmtEnabled,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<RowProviderProps>) {
|
}: PropsWithChildren<RowProviderProps>) {
|
||||||
const state = useMemo(
|
const state = useMemo(
|
||||||
() => ({ disableTrustOnFirstConnect, groupName, isOpenAmtEnabled }),
|
() => ({ groupName, isOpenAmtEnabled }),
|
||||||
[disableTrustOnFirstConnect, groupName, isOpenAmtEnabled]
|
[groupName, isOpenAmtEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
|
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
|
||||||
|
|
|
@ -4,14 +4,9 @@ import { useRouter, useSref } from '@uirouter/react';
|
||||||
|
|
||||||
import { Environment } from '@/portainer/environments/types';
|
import { Environment } from '@/portainer/environments/types';
|
||||||
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
|
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
|
||||||
import {
|
import { snapshotEndpoint } from '@/portainer/environments/environment.service';
|
||||||
snapshotEndpoint,
|
|
||||||
trustEndpoint,
|
|
||||||
} from '@/portainer/environments/environment.service';
|
|
||||||
import * as notifications from '@/portainer/services/notifications';
|
import * as notifications from '@/portainer/services/notifications';
|
||||||
import { getRoute } from '@/portainer/environments/utils';
|
import { getRoute } from '@/portainer/environments/utils';
|
||||||
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
|
||||||
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
|
|
||||||
|
|
||||||
export const actions: Column<Environment> = {
|
export const actions: Column<Environment> = {
|
||||||
Header: 'Actions',
|
Header: 'Actions',
|
||||||
|
@ -39,8 +34,6 @@ export function ActionsCell({
|
||||||
|
|
||||||
const showRefreshSnapshot = false; // remove and show MenuItem when feature is available
|
const showRefreshSnapshot = false; // remove and show MenuItem when feature is available
|
||||||
|
|
||||||
const { disableTrustOnFirstConnect } = useRowContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionsMenu>
|
<ActionsMenu>
|
||||||
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
|
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
|
||||||
|
@ -51,9 +44,6 @@ export function ActionsCell({
|
||||||
Refresh Snapshot
|
Refresh Snapshot
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{disableTrustOnFirstConnect && !environment.UserTrusted && (
|
|
||||||
<MenuLink onClick={trustDevice}>Trust</MenuLink>
|
|
||||||
)}
|
|
||||||
</ActionsMenu>
|
</ActionsMenu>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -71,37 +61,4 @@ export function ActionsCell({
|
||||||
await router.stateService.reload();
|
await router.stateService.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trustDevice() {
|
|
||||||
const confirmed = await confirmAsync({
|
|
||||||
title: '',
|
|
||||||
message: `Mark ${environment.Name} as trusted?`,
|
|
||||||
buttons: {
|
|
||||||
cancel: {
|
|
||||||
label: 'Cancel',
|
|
||||||
className: 'btn-default',
|
|
||||||
},
|
|
||||||
confirm: {
|
|
||||||
label: 'Trust',
|
|
||||||
className: 'btn-primary',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await trustEndpoint(environment.Id);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.error(
|
|
||||||
'Failure',
|
|
||||||
err as Error,
|
|
||||||
'An error occurred while trusting the environment'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await router.stateService.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { CellProps, Column } from 'react-table';
|
import { CellProps, Column } from 'react-table';
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import { Environment, EnvironmentStatus } from '@/portainer/environments/types';
|
import { Environment } from '@/portainer/environments/types';
|
||||||
import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext';
|
import { EdgeIndicator } from '@/portainer/home/EnvironmentList/EnvironmentItem/EdgeIndicator';
|
||||||
|
|
||||||
export const heartbeat: Column<Environment> = {
|
export const heartbeat: Column<Environment> = {
|
||||||
Header: 'Heartbeat',
|
Header: 'Heartbeat',
|
||||||
|
@ -16,35 +15,12 @@ export const heartbeat: Column<Environment> = {
|
||||||
export function StatusCell({
|
export function StatusCell({
|
||||||
row: { original: environment },
|
row: { original: environment },
|
||||||
}: CellProps<Environment>) {
|
}: CellProps<Environment>) {
|
||||||
const { disableTrustOnFirstConnect } = useRowContext();
|
|
||||||
|
|
||||||
if (disableTrustOnFirstConnect && !environment.UserTrusted) {
|
|
||||||
return <span className="label label-default">untrusted</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!environment.LastCheckInDate) {
|
|
||||||
return (
|
|
||||||
<span className="label label-default">
|
|
||||||
<s>associated</s>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<i
|
<EdgeIndicator
|
||||||
className={clsx(
|
checkInInterval={environment.EdgeCheckinInterval}
|
||||||
'fa',
|
edgeId={environment.EdgeID}
|
||||||
'fa-heartbeat',
|
lastCheckInDate={environment.LastCheckInDate}
|
||||||
environmentStatusLabel(environment.Status)
|
queryDate={environment.QueryDate}
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
function environmentStatusLabel(status: EnvironmentStatus) {
|
|
||||||
if (status === EnvironmentStatus.Up) {
|
|
||||||
return 'green-icon';
|
|
||||||
}
|
|
||||||
return 'orange-icon';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
export interface EdgeDeviceTableSettings {
|
import {
|
||||||
hiddenColumns: string[];
|
PaginationTableSettings,
|
||||||
autoRefreshRate: number;
|
RefreshableTableSettings,
|
||||||
pageSize: number;
|
SettableColumnsTableSettings,
|
||||||
sortBy: { id: string; desc: boolean };
|
SortableTableSettings,
|
||||||
}
|
} from '@/portainer/components/datatables/types';
|
||||||
|
|
||||||
export interface FDOProfilesTableSettings {
|
export interface EdgeDeviceTableSettings
|
||||||
pageSize: number;
|
extends SortableTableSettings,
|
||||||
sortBy: { id: string; desc: boolean };
|
PaginationTableSettings,
|
||||||
}
|
SettableColumnsTableSettings,
|
||||||
|
RefreshableTableSettings {}
|
||||||
|
|
||||||
|
export interface FDOProfilesTableSettings
|
||||||
|
extends SortableTableSettings,
|
||||||
|
PaginationTableSettings {}
|
||||||
|
|
||||||
export enum DeviceAction {
|
export enum DeviceAction {
|
||||||
PowerOn = 'power on',
|
PowerOn = 'power on',
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
groups="($ctrl.groups)"
|
groups="($ctrl.groups)"
|
||||||
is-fdo-enabled="($ctrl.isFDOEnabled)"
|
is-fdo-enabled="($ctrl.isFDOEnabled)"
|
||||||
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
|
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
|
||||||
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"
|
show-waiting-room-link="($ctrl.showWaitingRoomLink)"
|
||||||
mps-server="($ctrl.mpsServer)"
|
mps-server="($ctrl.mpsServer)"
|
||||||
on-refresh="($ctrl.getEnvironments)"
|
on-refresh="($ctrl.getEnvironments)"
|
||||||
set-loading-message="($ctrl.setLoadingMessage)"
|
set-loading-message="($ctrl.setLoadingMessage)"
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { getEndpoints } from 'Portainer/environments/environment.service';
|
import { getEndpoints } from 'Portainer/environments/environment.service';
|
||||||
import { EnvironmentType } from 'Portainer/environments/types';
|
|
||||||
|
|
||||||
angular.module('portainer.edge').controller('EdgeDevicesViewController', EdgeDevicesViewController);
|
angular.module('portainer.edge').controller('EdgeDevicesViewController', EdgeDevicesViewController);
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
@ -11,7 +10,7 @@ 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, { types: [EnvironmentType.EdgeAgentOnDocker] }), 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) {
|
||||||
|
@ -27,7 +26,7 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
|
||||||
const settings = await SettingsService.settings();
|
const settings = await SettingsService.settings();
|
||||||
|
|
||||||
ctrl.isFDOEnabled = settings && settings.EnableEdgeComputeFeatures && settings.fdoConfiguration && settings.fdoConfiguration.enabled;
|
ctrl.isFDOEnabled = settings && settings.EnableEdgeComputeFeatures && settings.fdoConfiguration && settings.fdoConfiguration.enabled;
|
||||||
ctrl.disableTrustOnFirstConnect = settings && settings.EnableEdgeComputeFeatures && settings.DisableTrustOnFirstConnect;
|
ctrl.showWaitingRoomLink = process.env.PORTAINER_EDITION === 'BE' && settings && settings.EnableEdgeComputeFeatures && !settings.TrustOnFirstConnect;
|
||||||
ctrl.isOpenAMTEnabled = settings && settings.EnableEdgeComputeFeatures && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
|
ctrl.isOpenAMTEnabled = settings && settings.EnableEdgeComputeFeatures && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
|
||||||
ctrl.mpsServer = ctrl.isOpenAMTEnabled ? settings.openAMTConfiguration.mpsServer : '';
|
ctrl.mpsServer = ctrl.isOpenAMTEnabled ? settings.openAMTConfiguration.mpsServer : '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { Row, TableRowProps } from 'react-table';
|
||||||
|
|
||||||
|
interface Props<T extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
|
isLoading?: boolean;
|
||||||
|
rows: Row<T>[];
|
||||||
|
emptyContent?: string;
|
||||||
|
prepareRow(row: Row<T>): void;
|
||||||
|
renderRow(row: Row<T>, rowProps: TableRowProps): React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableContent<
|
||||||
|
T extends Record<string, unknown> = Record<string, unknown>
|
||||||
|
>({
|
||||||
|
isLoading = false,
|
||||||
|
rows,
|
||||||
|
emptyContent = 'No items available',
|
||||||
|
prepareRow,
|
||||||
|
renderRow,
|
||||||
|
}: Props<T>) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <TableContentOneColumn>Loading...</TableContentOneColumn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return <TableContentOneColumn>{emptyContent}</TableContentOneColumn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rows.map((row) => {
|
||||||
|
prepareRow(row);
|
||||||
|
const { key, className, role, style } = row.getRowProps();
|
||||||
|
return renderRow(row, { key, className, role, style });
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableContentOneColumn({ children }: PropsWithChildren<unknown>) {
|
||||||
|
// using MAX_SAFE_INTEGER to make sure the single column will be the size of the table
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={Number.MAX_SAFE_INTEGER} className="text-center text-muted">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,9 +1,50 @@
|
||||||
export { Table } from './Table';
|
import { Table as MainComponent } from './Table';
|
||||||
export { TableActions } from './TableActions';
|
import { TableActions } from './TableActions';
|
||||||
export { TableTitleActions } from './TableTitleActions';
|
import { TableTitleActions } from './TableTitleActions';
|
||||||
export { TableHeaderCell } from './TableHeaderCell';
|
import { TableHeaderCell } from './TableHeaderCell';
|
||||||
export { TableSettingsMenu } from './TableSettingsMenu';
|
import { TableSettingsMenu } from './TableSettingsMenu';
|
||||||
export { TableTitle } from './TableTitle';
|
import { TableTitle } from './TableTitle';
|
||||||
export { TableContainer } from './TableContainer';
|
import { TableContainer } from './TableContainer';
|
||||||
export { TableHeaderRow } from './TableHeaderRow';
|
import { TableHeaderRow } from './TableHeaderRow';
|
||||||
export { TableRow } from './TableRow';
|
import { TableRow } from './TableRow';
|
||||||
|
import { TableContent } from './TableContent';
|
||||||
|
import { TableFooter } from './TableFooter';
|
||||||
|
|
||||||
|
interface SubComponents {
|
||||||
|
Container: typeof TableContainer;
|
||||||
|
Actions: typeof TableActions;
|
||||||
|
TitleActions: typeof TableTitleActions;
|
||||||
|
HeaderCell: typeof TableHeaderCell;
|
||||||
|
SettingsMenu: typeof TableSettingsMenu;
|
||||||
|
Title: typeof TableTitle;
|
||||||
|
Row: typeof TableRow;
|
||||||
|
HeaderRow: typeof TableHeaderRow;
|
||||||
|
Content: typeof TableContent;
|
||||||
|
Footer: typeof TableFooter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Table: typeof MainComponent & SubComponents =
|
||||||
|
MainComponent as typeof MainComponent & SubComponents;
|
||||||
|
|
||||||
|
Table.Actions = TableActions;
|
||||||
|
Table.TitleActions = TableTitleActions;
|
||||||
|
Table.Container = TableContainer;
|
||||||
|
Table.HeaderCell = TableHeaderCell;
|
||||||
|
Table.SettingsMenu = TableSettingsMenu;
|
||||||
|
Table.Title = TableTitle;
|
||||||
|
Table.Row = TableRow;
|
||||||
|
Table.HeaderRow = TableHeaderRow;
|
||||||
|
Table.Content = TableContent;
|
||||||
|
Table.Footer = TableFooter;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableActions,
|
||||||
|
TableTitleActions,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableSettingsMenu,
|
||||||
|
TableTitle,
|
||||||
|
TableContainer,
|
||||||
|
TableHeaderRow,
|
||||||
|
TableRow,
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
export interface PaginationTableSettings {
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortableTableSettings {
|
||||||
|
sortBy: { id: string; desc: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettableColumnsTableSettings {
|
||||||
|
hiddenColumns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettableQuickActionsTableSettings<TAction> {
|
||||||
|
hiddenQuickActions: TAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshableTableSettings {
|
||||||
|
autoRefreshRate: number;
|
||||||
|
}
|
|
@ -13,20 +13,20 @@ import type {
|
||||||
|
|
||||||
import { arrayToJson, buildUrl } from './utils';
|
import { arrayToJson, buildUrl } from './utils';
|
||||||
|
|
||||||
interface EndpointsQuery {
|
export interface EnvironmentsQueryParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
types?: EnvironmentType[];
|
types?: EnvironmentType[];
|
||||||
tagIds?: TagId[];
|
tagIds?: TagId[];
|
||||||
endpointIds?: EnvironmentId[];
|
endpointIds?: EnvironmentId[];
|
||||||
tagsPartialMatch?: boolean;
|
tagsPartialMatch?: boolean;
|
||||||
groupId?: EnvironmentGroupId;
|
groupId?: EnvironmentGroupId;
|
||||||
edgeDeviceFilter?: boolean;
|
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEndpoints(
|
export async function getEndpoints(
|
||||||
start: number,
|
start: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
{ types, tagIds, endpointIds, ...query }: EndpointsQuery = {}
|
{ types, tagIds, endpointIds, ...query }: EnvironmentsQueryParams = {}
|
||||||
) {
|
) {
|
||||||
if (tagIds && tagIds.length === 0) {
|
if (tagIds && tagIds.length === 0) {
|
||||||
return { totalCount: 0, value: <Environment[]>[] };
|
return { totalCount: 0, value: <Environment[]>[] };
|
||||||
|
|
|
@ -1,22 +1,27 @@
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
import { getEndpoints } from '@/portainer/environments/environment.service';
|
import {
|
||||||
|
EnvironmentsQueryParams,
|
||||||
|
getEndpoints,
|
||||||
|
} from '@/portainer/environments/environment.service';
|
||||||
import { EnvironmentStatus } from '@/portainer/environments/types';
|
import { EnvironmentStatus } from '@/portainer/environments/types';
|
||||||
import { error as notifyError } from '@/portainer/services/notifications';
|
import { error as notifyError } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
|
||||||
|
|
||||||
export function useEnvironmentList(
|
interface Query extends EnvironmentsQueryParams {
|
||||||
page: number,
|
page?: number;
|
||||||
pageLimit: number,
|
pageLimit?: number;
|
||||||
textFilter: string,
|
}
|
||||||
refetchOffline = false
|
|
||||||
) {
|
export function useEnvironmentList(query: Query = {}, refetchOffline = false) {
|
||||||
|
const { page = 1, pageLimit = 100 } = query;
|
||||||
|
|
||||||
const { isLoading, data } = useQuery(
|
const { isLoading, data } = useQuery(
|
||||||
['environments', page, pageLimit, textFilter],
|
['environments', { page, pageLimit, ...query }],
|
||||||
async () => {
|
async () => {
|
||||||
const start = (page - 1) * pageLimit + 1;
|
const start = (page - 1) * pageLimit + 1;
|
||||||
return getEndpoints(start, pageLimit, { search: textFilter });
|
return getEndpoints(start, pageLimit, query);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { promiseSequence } from './promise-utils';
|
||||||
|
|
||||||
|
describe('promiseSequence', () => {
|
||||||
|
it('should run successfully for an empty list', async () => {
|
||||||
|
await expect(promiseSequence([])).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provided two promise functions, the second should run after the first', async () => {
|
||||||
|
const callback = jest.fn();
|
||||||
|
|
||||||
|
function first() {
|
||||||
|
return Promise.resolve(callback(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function second() {
|
||||||
|
return Promise.resolve(callback(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
await promiseSequence([first, second]);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(2);
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(1, 1);
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(2, 2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,11 @@
|
||||||
|
/**
|
||||||
|
* runs the provided promises in a sequence, and returns a promise that resolves when all promises have resolved
|
||||||
|
*
|
||||||
|
* @param promises a list of functions that return promises
|
||||||
|
*/
|
||||||
|
export function promiseSequence<T>(promises: (() => Promise<T>)[]) {
|
||||||
|
return promises.reduce(
|
||||||
|
(promise, nextPromise) => promise.then(() => nextPromise()),
|
||||||
|
Promise.resolve<T>(undefined as unknown as T)
|
||||||
|
);
|
||||||
|
}
|
|
@ -47,7 +47,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||||
const groupsQuery = useGroups();
|
const groupsQuery = useGroups();
|
||||||
|
|
||||||
const { isLoading, environments, totalCount, totalAvailable } =
|
const { isLoading, environments, totalCount, totalAvailable } =
|
||||||
useEnvironmentList(page, pageLimit, debouncedTextFilter, true);
|
useEnvironmentList({ page, pageLimit, search: debouncedTextFilter }, true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -15,7 +15,7 @@ export function SettingsViewModel(data) {
|
||||||
this.EnableTelemetry = data.EnableTelemetry;
|
this.EnableTelemetry = data.EnableTelemetry;
|
||||||
this.KubeconfigExpiry = data.KubeconfigExpiry;
|
this.KubeconfigExpiry = data.KubeconfigExpiry;
|
||||||
this.HelmRepositoryURL = data.HelmRepositoryURL;
|
this.HelmRepositoryURL = data.HelmRepositoryURL;
|
||||||
this.DisableTrustOnFirstConnect = data.DisableTrustOnFirstConnect;
|
this.TrustOnFirstConnect = data.TrustOnFirstConnect;
|
||||||
this.EnforceEdgeID = data.EnforceEdgeID;
|
this.EnforceEdgeID = data.EnforceEdgeID;
|
||||||
this.AgentSecret = data.AgentSecret;
|
this.AgentSecret = data.AgentSecret;
|
||||||
this.EdgePortainerUrl = data.EdgePortainerUrl;
|
this.EdgePortainerUrl = data.EdgePortainerUrl;
|
||||||
|
@ -24,7 +24,6 @@ export function SettingsViewModel(data) {
|
||||||
export function PublicSettingsViewModel(settings) {
|
export function PublicSettingsViewModel(settings) {
|
||||||
this.AuthenticationMethod = settings.AuthenticationMethod;
|
this.AuthenticationMethod = settings.AuthenticationMethod;
|
||||||
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
||||||
this.DisableTrustOnFirstConnect = settings.DisableTrustOnFirstConnect;
|
|
||||||
this.EnforceEdgeID = settings.EnforceEdgeID;
|
this.EnforceEdgeID = settings.EnforceEdgeID;
|
||||||
this.FeatureFlagSettings = settings.FeatureFlagSettings;
|
this.FeatureFlagSettings = settings.FeatureFlagSettings;
|
||||||
this.LogoURL = settings.LogoURL;
|
this.LogoURL = settings.LogoURL;
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||||
|
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||||
|
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||||
|
import { Input } from '@/portainer/components/form-components/Input';
|
||||||
|
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useUpdateSettingsMutation } from '@/portainer/settings/settings.service';
|
||||||
|
|
||||||
|
import { Settings } from '../types';
|
||||||
|
|
||||||
|
import { EnabledWaitingRoomSwitch } from './EnableWaitingRoomSwitch';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
EdgePortainerUrl: string;
|
||||||
|
TrustOnFirstConnect: boolean;
|
||||||
|
}
|
||||||
|
const validation = yup.object({
|
||||||
|
TrustOnFirstConnect: yup.boolean().required('This field is required.'),
|
||||||
|
EdgePortainerUrl: yup
|
||||||
|
.string()
|
||||||
|
.test(
|
||||||
|
'not-local',
|
||||||
|
'Cannot use localhost as environment URL',
|
||||||
|
(value) => !value?.includes('localhost')
|
||||||
|
)
|
||||||
|
.url('URL should be a valid URI')
|
||||||
|
.required('URL is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultUrl = buildDefaultUrl();
|
||||||
|
|
||||||
|
export function AutoEnvCreationSettingsForm({ settings }: Props) {
|
||||||
|
const url = settings.EdgePortainerUrl;
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
EdgePortainerUrl: url || defaultUrl,
|
||||||
|
TrustOnFirstConnect: settings.TrustOnFirstConnect,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutation = useUpdateSettingsMutation();
|
||||||
|
|
||||||
|
const { mutate: updateSettings } = mutation;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(variables: Partial<FormValues>) => {
|
||||||
|
updateSettings(variables, {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess(
|
||||||
|
'Successfully updated Automatic Environment Creation settings'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url && validation.isValidSync({ url: defaultUrl })) {
|
||||||
|
handleSubmit({ EdgePortainerUrl: defaultUrl });
|
||||||
|
}
|
||||||
|
}, [handleSubmit, url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik<FormValues>
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
validationSchema={validation}
|
||||||
|
validateOnMount
|
||||||
|
enableReinitialize
|
||||||
|
>
|
||||||
|
{({ errors, isValid, dirty }) => (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<FormSectionTitle>Configuration</FormSectionTitle>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
label="Portainer URL"
|
||||||
|
tooltip="URL of the Portainer instance that the agent will use to initiate the communications."
|
||||||
|
inputId="url-input"
|
||||||
|
errors={errors.EdgePortainerUrl}
|
||||||
|
>
|
||||||
|
<Field as={Input} id="url-input" name="EdgePortainerUrl" />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<EnabledWaitingRoomSwitch />
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<LoadingButton
|
||||||
|
loadingText="generating..."
|
||||||
|
isLoading={mutation.isLoading}
|
||||||
|
disabled={!isValid || !dirty}
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultUrl() {
|
||||||
|
const baseHREF = baseHref();
|
||||||
|
return window.location.origin + (baseHREF !== '/' ? baseHREF : '');
|
||||||
|
}
|
|
@ -4,15 +4,27 @@ import { useEffect } from 'react';
|
||||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||||
import { EdgeScriptForm } from '@/edge/components/EdgeScriptForm';
|
import { EdgeScriptForm } from '@/edge/components/EdgeScriptForm';
|
||||||
import { generateKey } from '@/portainer/environments/environment.service/edge';
|
import { generateKey } from '@/portainer/environments/environment.service/edge';
|
||||||
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
|
||||||
|
import { useSettings } from '../../settings.service';
|
||||||
|
|
||||||
|
import { AutoEnvCreationSettingsForm } from './AutoEnvCreationSettingsForm';
|
||||||
|
|
||||||
export function AutomaticEdgeEnvCreation() {
|
export function AutomaticEdgeEnvCreation() {
|
||||||
const edgeKeyMutation = useGenerateKeyMutation();
|
const edgeKeyMutation = useGenerateKeyMutation();
|
||||||
const { mutate } = edgeKeyMutation;
|
const { mutate: generateKey } = edgeKeyMutation;
|
||||||
|
const settingsQuery = useSettings();
|
||||||
|
|
||||||
|
const url = settingsQuery.data?.EdgePortainerUrl;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mutate();
|
if (url) {
|
||||||
}, [mutate]);
|
generateKey();
|
||||||
|
}
|
||||||
|
}, [generateKey, url]);
|
||||||
|
|
||||||
|
if (!settingsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const edgeKey = edgeKeyMutation.data;
|
const edgeKey = edgeKeyMutation.data;
|
||||||
|
|
||||||
|
@ -23,17 +35,19 @@ export function AutomaticEdgeEnvCreation() {
|
||||||
title="Automatic Edge Environment Creation"
|
title="Automatic Edge Environment Creation"
|
||||||
/>
|
/>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
{edgeKey ? (
|
<AutoEnvCreationSettingsForm settings={settingsQuery.data} />
|
||||||
<EdgeScriptForm edgeKey={edgeKey} />
|
|
||||||
|
{edgeKeyMutation.isLoading ? (
|
||||||
|
<div>Generating key for {url} ... </div>
|
||||||
) : (
|
) : (
|
||||||
<TextTip>Please choose a valid edge portainer URL</TextTip>
|
edgeKey && <EdgeScriptForm edgeKey={edgeKey} />
|
||||||
)}
|
)}
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// using mutation because this action generates an object (although it's not saved in db)
|
// using mutation because we want this action to run only when required
|
||||||
function useGenerateKeyMutation() {
|
function useGenerateKeyMutation() {
|
||||||
return useMutation(generateKey);
|
return useMutation(generateKey);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||||
|
import { Switch } from '@/portainer/components/form-components/SwitchField/Switch';
|
||||||
|
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
|
||||||
|
export function EnabledWaitingRoomSwitch() {
|
||||||
|
const [inputProps, meta, helpers] = useField<boolean>('TrustOnFirstConnect');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
inputId="edge_waiting_room"
|
||||||
|
label="Disable Edge Environment Waiting Room"
|
||||||
|
errors={meta.error}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="edge_waiting_room"
|
||||||
|
name="TrustOnFirstConnect"
|
||||||
|
className="space-right"
|
||||||
|
checked={inputProps.value}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleChange(trust: boolean) {
|
||||||
|
if (!trust) {
|
||||||
|
helpers.setValue(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await confirmAsync({
|
||||||
|
title: 'Disable Edge Environment Waiting Room',
|
||||||
|
message:
|
||||||
|
'By disabling the waiting room feature, all devices requesting association will be automatically associated and could pose a security risk. Are you sure?',
|
||||||
|
buttons: {
|
||||||
|
cancel: {
|
||||||
|
label: 'Cancel',
|
||||||
|
className: 'btn-default',
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
label: 'Confirm',
|
||||||
|
className: 'btn-danger',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
helpers.setValue(!!confirmed);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { Formik, Form, Field } from 'formik';
|
import { Formik, Form } from 'formik';
|
||||||
|
|
||||||
import { Switch } from '@/portainer/components/form-components/SwitchField/Switch';
|
import { Switch } from '@/portainer/components/form-components/SwitchField/Switch';
|
||||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||||
|
@ -6,8 +6,6 @@ import { Select } from '@/portainer/components/form-components/Input/Select';
|
||||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||||
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||||
import { Input } from '@/portainer/components/form-components/Input';
|
|
||||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
|
||||||
|
|
||||||
import { Settings } from '../types';
|
import { Settings } from '../types';
|
||||||
|
|
||||||
|
@ -17,9 +15,7 @@ import { validationSchema } from './EdgeComputeSettings.validation';
|
||||||
export interface FormValues {
|
export interface FormValues {
|
||||||
EdgeAgentCheckinInterval: number;
|
EdgeAgentCheckinInterval: number;
|
||||||
EnableEdgeComputeFeatures: boolean;
|
EnableEdgeComputeFeatures: boolean;
|
||||||
DisableTrustOnFirstConnect: boolean;
|
|
||||||
EnforceEdgeID: boolean;
|
EnforceEdgeID: boolean;
|
||||||
EdgePortainerUrl: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -50,9 +46,7 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
|
||||||
const initialValues: FormValues = {
|
const initialValues: FormValues = {
|
||||||
EdgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
|
EdgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
|
||||||
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
|
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
|
||||||
DisableTrustOnFirstConnect: settings.DisableTrustOnFirstConnect,
|
|
||||||
EnforceEdgeID: settings.EnforceEdgeID,
|
EnforceEdgeID: settings.EnforceEdgeID,
|
||||||
EdgePortainerUrl: settings.EdgePortainerUrl || buildDefaultUrl(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -124,8 +118,9 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
|
||||||
|
|
||||||
<FormControl
|
<FormControl
|
||||||
inputId="edge_enforce_id"
|
inputId="edge_enforce_id"
|
||||||
label="Enforce use of Portainer generated Edge ID’s"
|
label="Enforce use of Portainer generated Edge ID"
|
||||||
size="medium"
|
size="medium"
|
||||||
|
tooltip="This setting only applies to manually created environments."
|
||||||
errors={errors.EnforceEdgeID}
|
errors={errors.EnforceEdgeID}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
|
@ -139,16 +134,6 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl
|
|
||||||
label="Portainer URL"
|
|
||||||
tooltip="URL of the Portainer instance that the agent will use to initiate the communications."
|
|
||||||
inputId="url-input"
|
|
||||||
errors={errors.EdgePortainerUrl}
|
|
||||||
size="medium"
|
|
||||||
>
|
|
||||||
<Field as={Input} id="url-input" name="EdgePortainerUrl" />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
|
@ -170,8 +155,3 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaultUrl() {
|
|
||||||
const base = baseHref();
|
|
||||||
return window.location.origin + (base !== '/' ? base : '');
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,18 +1,9 @@
|
||||||
import { boolean, number, object, string } from 'yup';
|
import { boolean, number, object } from 'yup';
|
||||||
|
|
||||||
export function validationSchema() {
|
export function validationSchema() {
|
||||||
return object().shape({
|
return object().shape({
|
||||||
EdgeAgentCheckinInterval: number().required('This field is required.'),
|
EdgeAgentCheckinInterval: number().required('This field is required.'),
|
||||||
EnableEdgeComputeFeatures: boolean().required('This field is required.'),
|
EnableEdgeComputeFeatures: boolean().required('This field is required.'),
|
||||||
DisableTrustOnFirstConnect: boolean().required('This field is required.'),
|
|
||||||
EnforceEdgeID: boolean().required('This field is required.'),
|
EnforceEdgeID: boolean().required('This field is required.'),
|
||||||
EdgePortainerUrl: string()
|
|
||||||
.test(
|
|
||||||
'notlocal',
|
|
||||||
'Cannot use localhost as environment URL',
|
|
||||||
(value) => !value?.includes('localhost')
|
|
||||||
)
|
|
||||||
.url('URL should be a valid URI')
|
|
||||||
.required('URL is required'),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
EdgeAgentCheckinInterval: number;
|
EdgeAgentCheckinInterval: number;
|
||||||
EnableEdgeComputeFeatures: boolean;
|
EnableEdgeComputeFeatures: boolean;
|
||||||
DisableTrustOnFirstConnect: boolean;
|
TrustOnFirstConnect: boolean;
|
||||||
EnforceEdgeID: boolean;
|
EnforceEdgeID: boolean;
|
||||||
EdgePortainerUrl: string;
|
EdgePortainerUrl: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useQuery } from 'react-query';
|
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ enum AuthenticationMethod {
|
||||||
AuthenticationOAuth,
|
AuthenticationOAuth,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsResponse {
|
export interface Settings {
|
||||||
LogoURL: string;
|
LogoURL: string;
|
||||||
BlackListedLabels: { name: string; value: string }[];
|
BlackListedLabels: { name: string; value: string }[];
|
||||||
AuthenticationMethod: AuthenticationMethod;
|
AuthenticationMethod: AuthenticationMethod;
|
||||||
|
@ -38,14 +38,15 @@ interface SettingsResponse {
|
||||||
EnableTelemetry: boolean;
|
EnableTelemetry: boolean;
|
||||||
HelmRepositoryURL: string;
|
HelmRepositoryURL: string;
|
||||||
KubectlShellImage: string;
|
KubectlShellImage: string;
|
||||||
DisableTrustOnFirstConnect: boolean;
|
TrustOnFirstConnect: boolean;
|
||||||
EnforceEdgeID: boolean;
|
EnforceEdgeID: boolean;
|
||||||
AgentSecret: string;
|
AgentSecret: string;
|
||||||
|
EdgePortainerUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSettings() {
|
export async function getSettings() {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<SettingsResponse>(buildUrl());
|
const { data } = await axios.get<Settings>(buildUrl());
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(
|
throw parseAxiosError(
|
||||||
|
@ -55,9 +56,31 @@ export async function getSettings() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSettings<T = SettingsResponse>(
|
async function updateSettings(settings: Partial<Settings>) {
|
||||||
select?: (settings: SettingsResponse) => T
|
try {
|
||||||
) {
|
await axios.put(buildUrl(), settings);
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to update application settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSettingsMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation(updateSettings, {
|
||||||
|
onSuccess() {
|
||||||
|
return queryClient.invalidateQueries(['settings']);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
error: {
|
||||||
|
title: 'Failure',
|
||||||
|
message: 'Unable to update settings',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings<T = Settings>(select?: (settings: Settings) => T) {
|
||||||
return useQuery(['settings'], getSettings, { select });
|
return useQuery(['settings'], getSettings, { select });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue