From e217ac71211d6cf346dc8c0b0a75b249ef527011 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 19 Apr 2022 21:43:36 +0300 Subject: [PATCH] feat(edge): show correct heartbeat and sync aeec changes [EE-2876] (#6769) --- api/datastore/test_data/output_35.json | 2 +- api/http/handler/endpoints/endpoint_create.go | 1 + api/http/handler/endpoints/endpoint_list.go | 36 ++- .../handler/endpoints/endpoint_list_test.go | 113 +++++++++ api/http/handler/endpoints/endpoint_update.go | 6 - api/http/handler/endpoints/handler.go | 16 +- api/http/handler/settings/settings_update.go | 8 +- api/http/security/bouncer.go | 2 +- .../testhelpers/compose_stack_manager.go | 33 +++ api/internal/testhelpers/request_bouncer.go | 26 +- api/portainer.go | 4 +- app/docker/containers/types.ts | 19 +- .../WaitingRoomView/Datatable/Datatable.tsx | 214 ++++++++++++++++ .../WaitingRoomView/Datatable/types.ts | 8 + .../WaitingRoomView/WaitingRoomView.tsx | 47 ++++ app/edge/EdgeDevices/WaitingRoomView/index.ts | 1 + .../EdgeDevices/WaitingRoomView/queries.ts | 33 +++ app/edge/__module.js | 230 ++++++++++-------- .../EdgeScriptForm/EdgePropertiesForm.tsx | 2 +- .../EdgeDevicesDatatable.tsx | 6 +- .../EdgeDevicesDatatableActions.tsx | 9 + .../EdgeDevicesDatatableContainer.tsx | 2 +- .../columns/RowContext.tsx | 7 +- .../EdgeDevicesDatatable/columns/actions.tsx | 45 +--- .../columns/heartbeat.tsx | 38 +-- app/edge/devices/types.ts | 25 +- .../edgeDevicesView/edgeDevicesView.html | 2 +- .../edgeDevicesViewController.js | 5 +- .../datatables/components/TableContent.tsx | 49 ++++ .../datatables/components/index.tsx | 59 ++++- app/portainer/components/datatables/types.ts | 19 ++ .../environments/environment.service/index.ts | 6 +- app/portainer/environments/queries.ts | 23 +- app/portainer/helpers/promise-utils.test.ts | 24 ++ app/portainer/helpers/promise-utils.ts | 11 + .../home/EnvironmentList/EnvironmentList.tsx | 2 +- app/portainer/models/settings.js | 3 +- .../AutoEnvCreationSettingsForm.tsx | 114 +++++++++ .../AutomaticEdgeEnvCreation.tsx | 30 ++- .../EnableWaitingRoomSwitch.tsx | 50 ++++ .../EdgeComputeSettings.tsx | 26 +- .../EdgeComputeSettings.validation.ts | 11 +- app/portainer/settings/edge-compute/types.ts | 2 +- app/portainer/settings/settings.service.ts | 37 ++- 44 files changed, 1099 insertions(+), 307 deletions(-) create mode 100644 api/http/handler/endpoints/endpoint_list_test.go create mode 100644 api/internal/testhelpers/compose_stack_manager.go create mode 100644 app/edge/EdgeDevices/WaitingRoomView/Datatable/Datatable.tsx create mode 100644 app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts create mode 100644 app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx create mode 100644 app/edge/EdgeDevices/WaitingRoomView/index.ts create mode 100644 app/edge/EdgeDevices/WaitingRoomView/queries.ts create mode 100644 app/portainer/components/datatables/components/TableContent.tsx create mode 100644 app/portainer/components/datatables/types.ts create mode 100644 app/portainer/helpers/promise-utils.test.ts create mode 100644 app/portainer/helpers/promise-utils.ts create mode 100644 app/portainer/settings/edge-compute/AutomaticEdgeEnvCreation/AutoEnvCreationSettingsForm.tsx create mode 100644 app/portainer/settings/edge-compute/AutomaticEdgeEnvCreation/EnableWaitingRoomSwitch.tsx diff --git a/api/datastore/test_data/output_35.json b/api/datastore/test_data/output_35.json index 669310cdf..b8ce03ef4 100644 --- a/api/datastore/test_data/output_35.json +++ b/api/datastore/test_data/output_35.json @@ -587,7 +587,6 @@ "AllowVolumeBrowserForRegularUsers": false, "AuthenticationMethod": 1, "BlackListedLabels": [], - "DisableTrustOnFirstConnect": false, "DisplayDonationHeader": false, "DisplayExternalContributors": false, "EdgeAgentCheckinInterval": 5, @@ -642,6 +641,7 @@ }, "SnapshotInterval": "5m", "TemplatesURL": "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json", + "TrustOnFirstConnect": false, "UserSessionTimeout": "8h", "fdoConfiguration": { "enabled": false, diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 9a87a2804..90bcf3cc6 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -326,6 +326,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) EdgeCheckinInterval: payload.EdgeCheckinInterval, Kubernetes: portainer.KubernetesDefault(), IsEdgeDevice: payload.IsEdgeDevice, + UserTrusted: true, } settings, err := handler.DataStore.Settings().Settings() diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 66ecc7e62..0411af6ba 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -14,6 +14,12 @@ import ( "github.com/portainer/portainer/api/http/security" ) +const ( + EdgeDeviceFilterAll = "all" + EdgeDeviceFilterTrusted = "trusted" + EdgeDeviceFilterUntrusted = "untrusted" +) + // @id EndpointList // @summary List environments(endpoints) // @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 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 edgeDeviceFilter query string false "will return only these edge devices" Enum("all", "trusted", "untrusted") // @success 200 {array} portainer.Endpoint "Endpoints" // @failure 500 "Server error" // @router /endpoints [get] @@ -91,8 +98,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID)) } - edgeDeviceFilter, edgeDeviceFilterErr := request.RetrieveBooleanQueryParameter(r, "edgeDeviceFilter", false) - if edgeDeviceFilterErr == nil { + edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false) + if edgeDeviceFilter != "" { filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter) } @@ -240,17 +247,38 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int) 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) + if edgeDeviceFilter != EdgeDeviceFilterAll && edgeDeviceFilter != EdgeDeviceFilterTrusted && edgeDeviceFilter != EdgeDeviceFilterUntrusted { + return endpoints + } + for _, endpoint := range endpoints { - if edgeDeviceFilter == endpoint.IsEdgeDevice { + if shouldReturnEdgeDevice(endpoint, edgeDeviceFilter) { filteredEndpoints = append(filteredEndpoints, endpoint) } } 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 { tags := make([]string, 0) for _, tagID := range tagIDs { diff --git a/api/http/handler/endpoints/endpoint_list_test.go b/api/http/handler/endpoints/endpoint_list_test.go new file mode 100644 index 000000000..183f47976 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_list_test.go @@ -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 +} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 0657fefc7..f11746fb6 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -46,8 +46,6 @@ type endpointUpdatePayload struct { EdgeCheckinInterval *int `example:"5"` // Associated Kubernetes data Kubernetes *portainer.KubernetesData - // Whether the device has been trusted or not by the user - UserTrusted *bool } 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) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 7a0175168..e4e075e07 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -5,7 +5,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "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/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. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer + requestBouncer requestBouncer DataStore dataservices.DataStore FileService portainer.FileService ProxyManager *proxy.Manager @@ -38,7 +48,7 @@ type Handler struct { } // NewHandler creates a handler to manage environment(endpoint) operations. -func NewHandler(bouncer *security.RequestBouncer) *Handler { +func NewHandler(bouncer requestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), requestBouncer: bouncer, diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 895cf0128..eb10a3a3c 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -43,8 +43,8 @@ type settingsUpdatePayload struct { HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"` // Kubectl Shell Image KubectlShellImage *string `example:"portainer/kubectl-shell:latest"` - // DisableTrustOnFirstConnect makes Portainer require explicit user trust of the edge agent before accepting the connection - DisableTrustOnFirstConnect *bool `example:"false"` + // TrustOnFirstConnect makes Portainer accepting edge agent connection by default + TrustOnFirstConnect *bool `example:"false"` // EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone EnforceEdgeID *bool `example:"false"` // 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 } - if payload.DisableTrustOnFirstConnect != nil { - settings.DisableTrustOnFirstConnect = *payload.DisableTrustOnFirstConnect + if payload.TrustOnFirstConnect != nil { + settings.TrustOnFirstConnect = *payload.TrustOnFirstConnect } if payload.EnforceEdgeID != nil { diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index c827f17be..dfd3d2dc0 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -144,7 +144,7 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, 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") } diff --git a/api/internal/testhelpers/compose_stack_manager.go b/api/internal/testhelpers/compose_stack_manager.go new file mode 100644 index 000000000..336a28b85 --- /dev/null +++ b/api/internal/testhelpers/compose_stack_manager.go @@ -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 +} diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go index 39d7bab39..a3926fe80 100644 --- a/api/internal/testhelpers/request_bouncer.go +++ b/api/internal/testhelpers/request_bouncer.go @@ -1,6 +1,10 @@ package testhelpers -import "net/http" +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" +) type testRequestBouncer struct { } @@ -13,3 +17,23 @@ func NewTestRequestBouncer() *testRequestBouncer { func (testRequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { 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 +} diff --git a/api/portainer.go b/api/portainer.go index ca5711247..7d02ea4be 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -809,8 +809,8 @@ type ( HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"` // KubectlImage, defaults to 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 - DisableTrustOnFirstConnect bool `json:"DisableTrustOnFirstConnect" example:"false"` + // TrustOnFirstConnect makes Portainer accepting edge agent connection by default + TrustOnFirstConnect bool `json:"TrustOnFirstConnect" example:"false"` // EnforceEdgeID makes Portainer store the Edge ID instead of accepting anyone EnforceEdgeID bool `json:"EnforceEdgeID" example:"false"` // Container environment parameter AGENT_SECRET diff --git a/app/docker/containers/types.ts b/app/docker/containers/types.ts index 37b2caadd..ef876d5dc 100644 --- a/app/docker/containers/types.ts +++ b/app/docker/containers/types.ts @@ -1,4 +1,11 @@ import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel'; +import { + PaginationTableSettings, + RefreshableTableSettings, + SettableColumnsTableSettings, + SettableQuickActionsTableSettings, + SortableTableSettings, +} from '@/portainer/components/datatables/types'; export type DockerContainerStatus = | 'paused' @@ -13,13 +20,13 @@ export type DockerContainerStatus = export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats'; -export interface ContainersTableSettings { - hiddenQuickActions: QuickAction[]; - hiddenColumns: string[]; +export interface ContainersTableSettings + extends SortableTableSettings, + PaginationTableSettings, + SettableColumnsTableSettings, + SettableQuickActionsTableSettings, + RefreshableTableSettings { truncateContainerName: number; - autoRefreshRate: number; - pageSize: number; - sortBy: { id: string; desc: boolean }; } export interface Port { diff --git a/app/edge/EdgeDevices/WaitingRoomView/Datatable/Datatable.tsx b/app/edge/EdgeDevices/WaitingRoomView/Datatable/Datatable.tsx new file mode 100644 index 000000000..c2904f9e5 --- /dev/null +++ b/app/edge/EdgeDevices/WaitingRoomView/Datatable/Datatable.tsx @@ -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[] = [ + { + 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(); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + page, + prepareRow, + selectedFlatRows, + + gotoPage, + setPageSize, + + setGlobalFilter, + state: { pageIndex, pageSize }, + } = useTable( + { + 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 ( +
+
+ + + + + + + + + + + {headerGroups.map((headerGroup) => { + const { key, className, role, style } = + headerGroup.getHeaderGroupProps(); + + return ( + + key={key} + className={className} + role={role} + style={style} + headers={headerGroup.headers} + onSortChange={handleSortChange} + /> + ); + })} + + + + ( + + )} + /> + +
+ + + + gotoPage(p - 1)} + totalCount={totalCount} + onPageLimitChange={handlePageLimitChange} + /> + +
+
+
+ ); + + 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'); + }, + } + ); + } +} diff --git a/app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts b/app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts new file mode 100644 index 000000000..1d319b77d --- /dev/null +++ b/app/edge/EdgeDevices/WaitingRoomView/Datatable/types.ts @@ -0,0 +1,8 @@ +import { + PaginationTableSettings, + SortableTableSettings, +} from '@/portainer/components/datatables/types'; + +export interface TableSettings + extends SortableTableSettings, + PaginationTableSettings {} diff --git a/app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx b/app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx new file mode 100644 index 000000000..9e5d44ce1 --- /dev/null +++ b/app/edge/EdgeDevices/WaitingRoomView/WaitingRoomView.tsx @@ -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 ( + <> + + + defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }} + storageKey={storageKey} + > + + + + ); +} + +export const WaitingRoomViewAngular = r2a(WaitingRoomView, []); diff --git a/app/edge/EdgeDevices/WaitingRoomView/index.ts b/app/edge/EdgeDevices/WaitingRoomView/index.ts new file mode 100644 index 000000000..a28e2c575 --- /dev/null +++ b/app/edge/EdgeDevices/WaitingRoomView/index.ts @@ -0,0 +1 @@ +export { WaitingRoomView, WaitingRoomViewAngular } from './WaitingRoomView'; diff --git a/app/edge/EdgeDevices/WaitingRoomView/queries.ts b/app/edge/EdgeDevices/WaitingRoomView/queries.ts new file mode 100644 index 000000000..a7dd8e5e0 --- /dev/null +++ b/app/edge/EdgeDevices/WaitingRoomView/queries.ts @@ -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'); + } +} diff --git a/app/edge/__module.js b/app/edge/__module.js index 2f1561757..27191eaed 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -3,134 +3,150 @@ import angular from 'angular'; import edgeStackModule from './views/edge-stacks'; import edgeDevicesModule from './devices'; import { componentsModule } from './components'; +import { WaitingRoomViewAngular } from './EdgeDevices/WaitingRoomView'; -angular.module('portainer.edge', [edgeStackModule, edgeDevicesModule, componentsModule]).config(function config($stateRegistryProvider) { - const edge = { - name: 'edge', - url: '/edge', - parent: 'root', - abstract: true, - }; +angular + .module('portainer.edge', [edgeStackModule, edgeDevicesModule, componentsModule]) + .component('waitingRoomView', WaitingRoomViewAngular) + .config(function config($stateRegistryProvider) { + const edge = { + name: 'edge', + url: '/edge', + parent: 'root', + abstract: true, + }; - const groups = { - name: 'edge.groups', - url: '/groups', - views: { - 'content@': { - component: 'edgeGroupsView', + const groups = { + name: 'edge.groups', + url: '/groups', + views: { + 'content@': { + component: 'edgeGroupsView', + }, }, - }, - }; + }; - const groupsNew = { - name: 'edge.groups.new', - url: '/new', - views: { - 'content@': { - component: 'createEdgeGroupView', + const groupsNew = { + name: 'edge.groups.new', + url: '/new', + views: { + 'content@': { + component: 'createEdgeGroupView', + }, }, - }, - }; + }; - const groupsEdit = { - name: 'edge.groups.edit', - url: '/:groupId', - views: { - 'content@': { - component: 'editEdgeGroupView', + const groupsEdit = { + name: 'edge.groups.edit', + url: '/:groupId', + views: { + 'content@': { + component: 'editEdgeGroupView', + }, }, - }, - }; + }; - const stacks = { - name: 'edge.stacks', - url: '/stacks', - views: { - 'content@': { - component: 'edgeStacksView', + const stacks = { + name: 'edge.stacks', + url: '/stacks', + views: { + 'content@': { + component: 'edgeStacksView', + }, }, - }, - }; + }; - const stacksNew = { - name: 'edge.stacks.new', - url: '/new', - views: { - 'content@': { - component: 'createEdgeStackView', + const stacksNew = { + name: 'edge.stacks.new', + url: '/new', + views: { + 'content@': { + component: 'createEdgeStackView', + }, }, - }, - }; + }; - const stacksEdit = { - name: 'edge.stacks.edit', - url: '/:stackId', - views: { - 'content@': { - component: 'editEdgeStackView', + const stacksEdit = { + name: 'edge.stacks.edit', + url: '/:stackId', + views: { + 'content@': { + component: 'editEdgeStackView', + }, }, - }, - params: { - tab: 0, - }, - }; - - const edgeJobs = { - name: 'edge.jobs', - url: '/jobs', - views: { - 'content@': { - component: 'edgeJobsView', + params: { + tab: 0, }, - }, - }; + }; - const edgeJob = { - name: 'edge.jobs.job', - url: '/:id', - views: { - 'content@': { - component: 'edgeJobView', + const edgeJobs = { + name: 'edge.jobs', + url: '/jobs', + views: { + 'content@': { + component: 'edgeJobsView', + }, }, - }, - params: { - tab: 0, - }, - }; + }; - const edgeJobCreation = { - name: 'edge.jobs.new', - url: '/new', - views: { - 'content@': { - component: 'createEdgeJobView', + const edgeJob = { + name: 'edge.jobs.job', + url: '/:id', + views: { + 'content@': { + component: 'edgeJobView', + }, }, - }, - }; - - const edgeDevices = { - name: 'edge.devices', - url: '/devices', - views: { - 'content@': { - component: 'edgeDevicesView', + params: { + tab: 0, }, - }, - }; + }; - $stateRegistryProvider.register(edge); + const edgeJobCreation = { + name: 'edge.jobs.new', + url: '/new', + views: { + 'content@': { + component: 'createEdgeJobView', + }, + }, + }; - $stateRegistryProvider.register(groups); - $stateRegistryProvider.register(groupsNew); - $stateRegistryProvider.register(groupsEdit); + const edgeDevices = { + name: 'edge.devices', + url: '/devices', + views: { + 'content@': { + component: 'edgeDevicesView', + }, + }, + }; - $stateRegistryProvider.register(stacks); - $stateRegistryProvider.register(stacksNew); - $stateRegistryProvider.register(stacksEdit); + if (process.env.PORTAINER_EDITION === 'BE') { + $stateRegistryProvider.register({ + name: 'edge.devices.waiting-room', + url: '/waiting-room', + views: { + 'content@': { + component: 'waitingRoomView', + }, + }, + }); + } - $stateRegistryProvider.register(edgeJobs); - $stateRegistryProvider.register(edgeJob); - $stateRegistryProvider.register(edgeJobCreation); + $stateRegistryProvider.register(edge); - $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); + }); diff --git a/app/edge/components/EdgeScriptForm/EdgePropertiesForm.tsx b/app/edge/components/EdgeScriptForm/EdgePropertiesForm.tsx index 6c82fae0c..a09eabe86 100644 --- a/app/edge/components/EdgeScriptForm/EdgePropertiesForm.tsx +++ b/app/edge/components/EdgeScriptForm/EdgePropertiesForm.tsx @@ -19,7 +19,7 @@ export function EdgePropertiesForm({ }: Props) { return (
- Edge script settings + Edge agent deployment script @@ -216,7 +217,6 @@ export function EdgeDevicesDatatable({ return ( diff --git a/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions.tsx b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions.tsx index c62081d83..809d6d868 100644 --- a/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions.tsx +++ b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions.tsx @@ -7,12 +7,14 @@ import { promptAsync } from '@/portainer/services/modal.service/prompt'; import * as notifications from '@/portainer/services/notifications'; import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service'; import { deleteEndpoint } from '@/portainer/environments/environment.service'; +import { Link } from '@/portainer/components/Link'; interface Props { selectedItems: Environment[]; isFDOEnabled: boolean; isOpenAMTEnabled: boolean; setLoadingMessage(message: string): void; + showWaitingRoomLink: boolean; } export function EdgeDevicesDatatableActions({ @@ -20,6 +22,7 @@ export function EdgeDevicesDatatableActions({ isOpenAMTEnabled, isFDOEnabled, setLoadingMessage, + showWaitingRoomLink, }: Props) { const router = useRouter(); @@ -48,6 +51,12 @@ export function EdgeDevicesDatatableActions({ Associate with OpenAMT )} + + {showWaitingRoomLink && ( + + + + )} ); diff --git a/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx index 0fde89a25..9427ae0e8 100644 --- a/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx +++ b/app/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx @@ -35,7 +35,7 @@ export const EdgeDevicesDatatableAngular = react2angular( 'onRefresh', 'setLoadingMessage', 'isFdoEnabled', - 'disableTrustOnFirstConnect', + 'showWaitingRoomLink', 'isOpenAmtEnabled', 'mpsServer', ] diff --git a/app/edge/devices/components/EdgeDevicesDatatable/columns/RowContext.tsx b/app/edge/devices/components/EdgeDevicesDatatable/columns/RowContext.tsx index 808a1bb6c..f587d311d 100644 --- a/app/edge/devices/components/EdgeDevicesDatatable/columns/RowContext.tsx +++ b/app/edge/devices/components/EdgeDevicesDatatable/columns/RowContext.tsx @@ -1,7 +1,6 @@ import { createContext, useContext, useMemo, PropsWithChildren } from 'react'; interface RowContextState { - disableTrustOnFirstConnect: boolean; isOpenAmtEnabled: boolean; groupName?: string; } @@ -9,20 +8,18 @@ interface RowContextState { const RowContext = createContext(null); export interface RowProviderProps { - disableTrustOnFirstConnect: boolean; groupName?: string; isOpenAmtEnabled: boolean; } export function RowProvider({ - disableTrustOnFirstConnect, groupName, isOpenAmtEnabled, children, }: PropsWithChildren) { const state = useMemo( - () => ({ disableTrustOnFirstConnect, groupName, isOpenAmtEnabled }), - [disableTrustOnFirstConnect, groupName, isOpenAmtEnabled] + () => ({ groupName, isOpenAmtEnabled }), + [groupName, isOpenAmtEnabled] ); return {children}; diff --git a/app/edge/devices/components/EdgeDevicesDatatable/columns/actions.tsx b/app/edge/devices/components/EdgeDevicesDatatable/columns/actions.tsx index 590628f66..d647abfa4 100644 --- a/app/edge/devices/components/EdgeDevicesDatatable/columns/actions.tsx +++ b/app/edge/devices/components/EdgeDevicesDatatable/columns/actions.tsx @@ -4,14 +4,9 @@ import { useRouter, useSref } from '@uirouter/react'; import { Environment } from '@/portainer/environments/types'; import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu'; -import { - snapshotEndpoint, - trustEndpoint, -} from '@/portainer/environments/environment.service'; +import { snapshotEndpoint } from '@/portainer/environments/environment.service'; import * as notifications from '@/portainer/services/notifications'; 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 = { Header: 'Actions', @@ -39,8 +34,6 @@ export function ActionsCell({ const showRefreshSnapshot = false; // remove and show MenuItem when feature is available - const { disableTrustOnFirstConnect } = useRowContext(); - return ( @@ -51,9 +44,6 @@ export function ActionsCell({ Refresh Snapshot )} - {disableTrustOnFirstConnect && !environment.UserTrusted && ( - Trust - )} ); @@ -71,37 +61,4 @@ export function ActionsCell({ 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(); - } - } } diff --git a/app/edge/devices/components/EdgeDevicesDatatable/columns/heartbeat.tsx b/app/edge/devices/components/EdgeDevicesDatatable/columns/heartbeat.tsx index bcf0fe56b..5cb0941b6 100644 --- a/app/edge/devices/components/EdgeDevicesDatatable/columns/heartbeat.tsx +++ b/app/edge/devices/components/EdgeDevicesDatatable/columns/heartbeat.tsx @@ -1,8 +1,7 @@ import { CellProps, Column } from 'react-table'; -import clsx from 'clsx'; -import { Environment, EnvironmentStatus } from '@/portainer/environments/types'; -import { useRowContext } from '@/edge/devices/components/EdgeDevicesDatatable/columns/RowContext'; +import { Environment } from '@/portainer/environments/types'; +import { EdgeIndicator } from '@/portainer/home/EnvironmentList/EnvironmentItem/EdgeIndicator'; export const heartbeat: Column = { Header: 'Heartbeat', @@ -16,35 +15,12 @@ export const heartbeat: Column = { export function StatusCell({ row: { original: environment }, }: CellProps) { - const { disableTrustOnFirstConnect } = useRowContext(); - - if (disableTrustOnFirstConnect && !environment.UserTrusted) { - return untrusted; - } - - if (!environment.LastCheckInDate) { - return ( - - associated - - ); - } - return ( -