feat(edge): show correct heartbeat and sync aeec changes [EE-2876] (#6769)

pull/6762/head
Chaim Lev-Ari 2022-04-19 21:43:36 +03:00 committed by GitHub
parent 76d1b70644
commit e217ac7121
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1099 additions and 307 deletions

View File

@ -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,

View File

@ -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()

View File

@ -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 {

View File

@ -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(&regularEndpoint)
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
}

View File

@ -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}

View File

@ -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,

View File

@ -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 {

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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<QuickAction>,
RefreshableTableSettings {
truncateContainerName: number;
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}
export interface Port {

View File

@ -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');
},
}
);
}
}

View File

@ -0,0 +1,8 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@/portainer/components/datatables/types';
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}

View File

@ -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, []);

View File

@ -0,0 +1 @@
export { WaitingRoomView, WaitingRoomViewAngular } from './WaitingRoomView';

View File

@ -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');
}
}

View File

@ -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);
});

View File

@ -19,7 +19,7 @@ export function EdgePropertiesForm({
}: Props) {
return (
<form className="form-horizontal">
<FormSectionTitle>Edge script settings</FormSectionTitle>
<FormSectionTitle>Edge agent deployment script</FormSectionTitle>
<OsSelector
value={values.os}

View File

@ -50,7 +50,7 @@ export interface EdgeDevicesTableProps {
isEnabled: boolean;
isFdoEnabled: boolean;
isOpenAmtEnabled: boolean;
disableTrustOnFirstConnect: boolean;
showWaitingRoomLink: boolean;
mpsServer: string;
dataset: Environment[];
groups: EnvironmentGroup[];
@ -62,7 +62,7 @@ export function EdgeDevicesDatatable({
storageKey,
isFdoEnabled,
isOpenAmtEnabled,
disableTrustOnFirstConnect,
showWaitingRoomLink,
mpsServer,
dataset,
groups,
@ -164,6 +164,7 @@ export function EdgeDevicesDatatable({
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
setLoadingMessage={setLoadingMessage}
showWaitingRoomLink={showWaitingRoomLink}
/>
</TableActions>
@ -216,7 +217,6 @@ export function EdgeDevicesDatatable({
return (
<RowProvider
key={key}
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
isOpenAmtEnabled={isOpenAmtEnabled}
groupName={group[0]?.Name}
>

View File

@ -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
</Button>
)}
{showWaitingRoomLink && (
<Link to="edge.devices.waiting-room">
<Button>Waiting Room</Button>
</Link>
)}
</div>
);

View File

@ -35,7 +35,7 @@ export const EdgeDevicesDatatableAngular = react2angular(
'onRefresh',
'setLoadingMessage',
'isFdoEnabled',
'disableTrustOnFirstConnect',
'showWaitingRoomLink',
'isOpenAmtEnabled',
'mpsServer',
]

View File

@ -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<RowContextState | null>(null);
export interface RowProviderProps {
disableTrustOnFirstConnect: boolean;
groupName?: string;
isOpenAmtEnabled: boolean;
}
export function RowProvider({
disableTrustOnFirstConnect,
groupName,
isOpenAmtEnabled,
children,
}: PropsWithChildren<RowProviderProps>) {
const state = useMemo(
() => ({ disableTrustOnFirstConnect, groupName, isOpenAmtEnabled }),
[disableTrustOnFirstConnect, groupName, isOpenAmtEnabled]
() => ({ groupName, isOpenAmtEnabled }),
[groupName, isOpenAmtEnabled]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;

View File

@ -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<Environment> = {
Header: 'Actions',
@ -39,8 +34,6 @@ export function ActionsCell({
const showRefreshSnapshot = false; // remove and show MenuItem when feature is available
const { disableTrustOnFirstConnect } = useRowContext();
return (
<ActionsMenu>
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
@ -51,9 +44,6 @@ export function ActionsCell({
Refresh Snapshot
</MenuItem>
)}
{disableTrustOnFirstConnect && !environment.UserTrusted && (
<MenuLink onClick={trustDevice}>Trust</MenuLink>
)}
</ActionsMenu>
);
@ -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();
}
}
}

View File

@ -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<Environment> = {
Header: 'Heartbeat',
@ -16,35 +15,12 @@ export const heartbeat: Column<Environment> = {
export function StatusCell({
row: { original: 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 (
<i
className={clsx(
'fa',
'fa-heartbeat',
environmentStatusLabel(environment.Status)
)}
aria-hidden="true"
<EdgeIndicator
checkInInterval={environment.EdgeCheckinInterval}
edgeId={environment.EdgeID}
lastCheckInDate={environment.LastCheckInDate}
queryDate={environment.QueryDate}
/>
);
function environmentStatusLabel(status: EnvironmentStatus) {
if (status === EnvironmentStatus.Up) {
return 'green-icon';
}
return 'orange-icon';
}
}

View File

@ -1,14 +1,19 @@
export interface EdgeDeviceTableSettings {
hiddenColumns: string[];
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}
import {
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/portainer/components/datatables/types';
export interface FDOProfilesTableSettings {
pageSize: number;
sortBy: { id: string; desc: boolean };
}
export interface EdgeDeviceTableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export interface FDOProfilesTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export enum DeviceAction {
PowerOn = 'power on',

View File

@ -31,7 +31,7 @@
groups="($ctrl.groups)"
is-fdo-enabled="($ctrl.isFDOEnabled)"
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
disable-trust-on-first-connect="($ctrl.disableTrustOnFirstConnect)"
show-waiting-room-link="($ctrl.showWaitingRoomLink)"
mps-server="($ctrl.mpsServer)"
on-refresh="($ctrl.getEnvironments)"
set-loading-message="($ctrl.setLoadingMessage)"

View File

@ -1,5 +1,4 @@
import { getEndpoints } from 'Portainer/environments/environment.service';
import { EnvironmentType } from 'Portainer/environments/types';
angular.module('portainer.edge').controller('EdgeDevicesViewController', EdgeDevicesViewController);
/* @ngInject */
@ -11,7 +10,7 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
this.getEnvironments = function () {
return $async(async () => {
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.edgeDevices = endpointsResponse.value;
} catch (err) {
@ -27,7 +26,7 @@ export function EdgeDevicesViewController($q, $async, EndpointService, GroupServ
const settings = await SettingsService.settings();
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.mpsServer = ctrl.isOpenAMTEnabled ? settings.openAMTConfiguration.mpsServer : '';
} catch (err) {

View File

@ -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>
);
}

View File

@ -1,9 +1,50 @@
export { Table } from './Table';
export { TableActions } from './TableActions';
export { TableTitleActions } from './TableTitleActions';
export { TableHeaderCell } from './TableHeaderCell';
export { TableSettingsMenu } from './TableSettingsMenu';
export { TableTitle } from './TableTitle';
export { TableContainer } from './TableContainer';
export { TableHeaderRow } from './TableHeaderRow';
export { TableRow } from './TableRow';
import { Table as MainComponent } from './Table';
import { TableActions } from './TableActions';
import { TableTitleActions } from './TableTitleActions';
import { TableHeaderCell } from './TableHeaderCell';
import { TableSettingsMenu } from './TableSettingsMenu';
import { TableTitle } from './TableTitle';
import { TableContainer } from './TableContainer';
import { TableHeaderRow } from './TableHeaderRow';
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,
};

View File

@ -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;
}

View File

@ -13,20 +13,20 @@ import type {
import { arrayToJson, buildUrl } from './utils';
interface EndpointsQuery {
export interface EnvironmentsQueryParams {
search?: string;
types?: EnvironmentType[];
tagIds?: TagId[];
endpointIds?: EnvironmentId[];
tagsPartialMatch?: boolean;
groupId?: EnvironmentGroupId;
edgeDeviceFilter?: boolean;
edgeDeviceFilter?: 'all' | 'trusted' | 'untrusted';
}
export async function getEndpoints(
start: number,
limit: number,
{ types, tagIds, endpointIds, ...query }: EndpointsQuery = {}
{ types, tagIds, endpointIds, ...query }: EnvironmentsQueryParams = {}
) {
if (tagIds && tagIds.length === 0) {
return { totalCount: 0, value: <Environment[]>[] };

View File

@ -1,22 +1,27 @@
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 { error as notifyError } from '@/portainer/services/notifications';
const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
export function useEnvironmentList(
page: number,
pageLimit: number,
textFilter: string,
refetchOffline = false
) {
interface Query extends EnvironmentsQueryParams {
page?: number;
pageLimit?: number;
}
export function useEnvironmentList(query: Query = {}, refetchOffline = false) {
const { page = 1, pageLimit = 100 } = query;
const { isLoading, data } = useQuery(
['environments', page, pageLimit, textFilter],
['environments', { page, pageLimit, ...query }],
async () => {
const start = (page - 1) * pageLimit + 1;
return getEndpoints(start, pageLimit, { search: textFilter });
return getEndpoints(start, pageLimit, query);
},
{
keepPreviousData: true,

View File

@ -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);
});
});

View File

@ -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)
);
}

View File

@ -47,7 +47,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
const groupsQuery = useGroups();
const { isLoading, environments, totalCount, totalAvailable } =
useEnvironmentList(page, pageLimit, debouncedTextFilter, true);
useEnvironmentList({ page, pageLimit, search: debouncedTextFilter }, true);
return (
<>

View File

@ -15,7 +15,7 @@ export function SettingsViewModel(data) {
this.EnableTelemetry = data.EnableTelemetry;
this.KubeconfigExpiry = data.KubeconfigExpiry;
this.HelmRepositoryURL = data.HelmRepositoryURL;
this.DisableTrustOnFirstConnect = data.DisableTrustOnFirstConnect;
this.TrustOnFirstConnect = data.TrustOnFirstConnect;
this.EnforceEdgeID = data.EnforceEdgeID;
this.AgentSecret = data.AgentSecret;
this.EdgePortainerUrl = data.EdgePortainerUrl;
@ -24,7 +24,6 @@ export function SettingsViewModel(data) {
export function PublicSettingsViewModel(settings) {
this.AuthenticationMethod = settings.AuthenticationMethod;
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
this.DisableTrustOnFirstConnect = settings.DisableTrustOnFirstConnect;
this.EnforceEdgeID = settings.EnforceEdgeID;
this.FeatureFlagSettings = settings.FeatureFlagSettings;
this.LogoURL = settings.LogoURL;

View File

@ -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 : '');
}

View File

@ -4,15 +4,27 @@ import { useEffect } from 'react';
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
import { EdgeScriptForm } from '@/edge/components/EdgeScriptForm';
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() {
const edgeKeyMutation = useGenerateKeyMutation();
const { mutate } = edgeKeyMutation;
const { mutate: generateKey } = edgeKeyMutation;
const settingsQuery = useSettings();
const url = settingsQuery.data?.EdgePortainerUrl;
useEffect(() => {
mutate();
}, [mutate]);
if (url) {
generateKey();
}
}, [generateKey, url]);
if (!settingsQuery.data) {
return null;
}
const edgeKey = edgeKeyMutation.data;
@ -23,17 +35,19 @@ export function AutomaticEdgeEnvCreation() {
title="Automatic Edge Environment Creation"
/>
<WidgetBody>
{edgeKey ? (
<EdgeScriptForm edgeKey={edgeKey} />
<AutoEnvCreationSettingsForm settings={settingsQuery.data} />
{edgeKeyMutation.isLoading ? (
<div>Generating key for {url} ... </div>
) : (
<TextTip>Please choose a valid edge portainer URL</TextTip>
edgeKey && <EdgeScriptForm edgeKey={edgeKey} />
)}
</WidgetBody>
</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() {
return useMutation(generateKey);
}

View File

@ -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);
}
}

View File

@ -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 { 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 { LoadingButton } from '@/portainer/components/Button/LoadingButton';
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';
@ -17,9 +15,7 @@ import { validationSchema } from './EdgeComputeSettings.validation';
export interface FormValues {
EdgeAgentCheckinInterval: number;
EnableEdgeComputeFeatures: boolean;
DisableTrustOnFirstConnect: boolean;
EnforceEdgeID: boolean;
EdgePortainerUrl: string;
}
interface Props {
@ -50,9 +46,7 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
const initialValues: FormValues = {
EdgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
DisableTrustOnFirstConnect: settings.DisableTrustOnFirstConnect,
EnforceEdgeID: settings.EnforceEdgeID,
EdgePortainerUrl: settings.EdgePortainerUrl || buildDefaultUrl(),
};
return (
@ -124,8 +118,9 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
<FormControl
inputId="edge_enforce_id"
label="Enforce use of Portainer generated Edge IDs"
label="Enforce use of Portainer generated Edge ID"
size="medium"
tooltip="This setting only applies to manually created environments."
errors={errors.EnforceEdgeID}
>
<Switch
@ -139,16 +134,6 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
/>
</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="col-sm-12">
<LoadingButton
@ -170,8 +155,3 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
</div>
);
}
function buildDefaultUrl() {
const base = baseHref();
return window.location.origin + (base !== '/' ? base : '');
}

View File

@ -1,18 +1,9 @@
import { boolean, number, object, string } from 'yup';
import { boolean, number, object } from 'yup';
export function validationSchema() {
return object().shape({
EdgeAgentCheckinInterval: number().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.'),
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'),
});
}

View File

@ -1,7 +1,7 @@
export interface Settings {
EdgeAgentCheckinInterval: number;
EnableEdgeComputeFeatures: boolean;
DisableTrustOnFirstConnect: boolean;
TrustOnFirstConnect: boolean;
EnforceEdgeID: boolean;
EdgePortainerUrl: string;
}

View File

@ -1,4 +1,4 @@
import { useQuery } from 'react-query';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { PublicSettingsViewModel } from '@/portainer/models/settings';
@ -25,7 +25,7 @@ enum AuthenticationMethod {
AuthenticationOAuth,
}
interface SettingsResponse {
export interface Settings {
LogoURL: string;
BlackListedLabels: { name: string; value: string }[];
AuthenticationMethod: AuthenticationMethod;
@ -38,14 +38,15 @@ interface SettingsResponse {
EnableTelemetry: boolean;
HelmRepositoryURL: string;
KubectlShellImage: string;
DisableTrustOnFirstConnect: boolean;
TrustOnFirstConnect: boolean;
EnforceEdgeID: boolean;
AgentSecret: string;
EdgePortainerUrl: string;
}
export async function getSettings() {
try {
const { data } = await axios.get<SettingsResponse>(buildUrl());
const { data } = await axios.get<Settings>(buildUrl());
return data;
} catch (e) {
throw parseAxiosError(
@ -55,9 +56,31 @@ export async function getSettings() {
}
}
export function useSettings<T = SettingsResponse>(
select?: (settings: SettingsResponse) => T
) {
async function updateSettings(settings: Partial<Settings>) {
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 });
}