feat(environments): create async edge [EE-4480] (#8527)

pull/8137/head
Chaim Lev-Ari 2023-03-01 20:33:05 +02:00 committed by GitHub
parent bc6a667a6b
commit c819d4e7f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 880 additions and 586 deletions

View File

@ -164,9 +164,6 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true)
payload.EdgeCheckinInterval = checkinInterval
isEdgeDevice, _ := request.RetrieveBooleanMultiPartFormValue(r, "IsEdgeDevice", true)
payload.IsEdgeDevice = isEdgeDevice
return nil
}
@ -196,7 +193,6 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
// @param TagIDs formData []int false "List of tag identifiers to which this environment(endpoint) is associated"
// @param EdgeCheckinInterval formData int false "The check in interval for edge agent (in seconds)"
// @param EdgeTunnelServerAddress formData string true "URL or IP address that will be used to establish a reverse tunnel"
// @param IsEdgeDevice formData bool false "Is Edge Device"
// @param Gpus formData array false "List of GPUs"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"

View File

@ -42,8 +42,8 @@ const (
// @param endpointIds query []int false "will return only these environments(endpoints)"
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
// @param agentVersions query []string false "will return only environments with on of these agent versions"
// @param edgeDevice query bool false "if exists true show only edge devices, false show only regular edge endpoints. if missing, will show both types (relevant only for edge endpoints)"
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted endpoints, if false show only trusted (relevant only for edge devices, and if edgeDevice is true)"
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
// @param name query string false "will return only environments(endpoints) with this name"
// @success 200 {array} portainer.Endpoint "Endpoints"
// @failure 500 "Server error"

View File

@ -13,7 +13,6 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
@ -104,59 +103,58 @@ func Test_EndpointList_AgentVersion(t *testing.T) {
}
}
func Test_endpointList_edgeDeviceFilter(t *testing.T) {
func Test_endpointList_edgeFilter(t *testing.T) {
trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
untrustedEdgeDevice := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularEndpoint := portainer.Endpoint{ID: 5, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.DockerEnvironment}
trustedEdgeAsync := portainer.Endpoint{ID: 1, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
untrustedEdgeAsync := portainer.Endpoint{ID: 2, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularUntrustedEdgeStandard := portainer.Endpoint{ID: 3, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularTrustedEdgeStandard := portainer.Endpoint{ID: 4, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment}
handler, teardown := setup(t, []portainer.Endpoint{
trustedEdgeDevice,
untrustedEdgeDevice,
regularUntrustedEdgeEndpoint,
regularTrustedEdgeEndpoint,
trustedEdgeAsync,
untrustedEdgeAsync,
regularUntrustedEdgeStandard,
regularTrustedEdgeStandard,
regularEndpoint,
})
defer teardown()
type endpointListEdgeDeviceTest struct {
type endpointListEdgeTest struct {
endpointListTest
edgeDevice *bool
edgeAsync *bool
edgeDeviceUntrusted bool
}
tests := []endpointListEdgeDeviceTest{
tests := []endpointListEdgeTest{
{
endpointListTest: endpointListTest{
"should show all endpoints except of the untrusted devices",
[]portainer.EndpointID{trustedEdgeDevice.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID, regularEndpoint.ID},
"should show all endpoints expect of the untrusted devices",
[]portainer.EndpointID{trustedEdgeAsync.ID, regularTrustedEdgeStandard.ID, regularEndpoint.ID},
},
edgeDevice: nil,
},
{
endpointListTest: endpointListTest{
"should show only trusted edge devices and regular endpoints",
[]portainer.EndpointID{trustedEdgeDevice.ID, regularEndpoint.ID},
"should show only trusted edge async agents and regular endpoints",
[]portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID},
},
edgeDevice: BoolAddr(true),
edgeAsync: BoolAddr(true),
},
{
endpointListTest: endpointListTest{
"should show only untrusted edge devices and regular endpoints",
[]portainer.EndpointID{untrustedEdgeDevice.ID, regularEndpoint.ID},
[]portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID},
},
edgeDevice: BoolAddr(true),
edgeAsync: BoolAddr(true),
edgeDeviceUntrusted: true,
},
{
endpointListTest: endpointListTest{
"should show no edge devices",
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
[]portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID},
},
edgeDevice: BoolAddr(false),
edgeAsync: BoolAddr(false),
},
}
@ -165,8 +163,8 @@ func Test_endpointList_edgeDeviceFilter(t *testing.T) {
is := assert.New(t)
query := fmt.Sprintf("edgeDeviceUntrusted=%v&", test.edgeDeviceUntrusted)
if test.edgeDevice != nil {
query += fmt.Sprintf("edgeDevice=%v&", *test.edgeDevice)
if test.edgeAsync != nil {
query += fmt.Sprintf("edgeAsync=%v&", *test.edgeAsync)
}
req := buildEndpointListRequest(query)
@ -198,7 +196,7 @@ func setup(t *testing.T, endpoints []portainer.Endpoint) (handler *Handler, tear
err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
bouncer := helper.NewTestRequestBouncer()
bouncer := testhelpers.NewTestRequestBouncer()
handler = NewHandler(bouncer, nil)
handler.DataStore = store
handler.ComposeStackManager = testhelpers.NewComposeStackManager()

View File

@ -15,14 +15,15 @@ import (
)
type EnvironmentsQuery struct {
search string
types []portainer.EndpointType
tagIds []portainer.TagID
endpointIds []portainer.EndpointID
tagsPartialMatch bool
groupIds []portainer.EndpointGroupID
status []portainer.EndpointStatus
edgeDevice *bool
search string
types []portainer.EndpointType
tagIds []portainer.TagID
endpointIds []portainer.EndpointID
tagsPartialMatch bool
groupIds []portainer.EndpointGroupID
status []portainer.EndpointStatus
// if edgeAsync not nil, will filter edge endpoints based on this value
edgeAsync *bool
edgeDeviceUntrusted bool
excludeSnapshots bool
name string
@ -66,11 +67,10 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
name, _ := request.RetrieveQueryParameter(r, "name", true)
edgeDeviceParam, _ := request.RetrieveQueryParameter(r, "edgeDevice", true)
var edgeDevice *bool
if edgeDeviceParam != "" {
edgeDevice = BoolAddr(edgeDeviceParam == "true")
var edgeAsync *bool
edgeAsyncParam, _ := request.RetrieveQueryParameter(r, "edgeAsync", true)
if edgeAsyncParam != "" {
edgeAsync = BoolAddr(edgeAsyncParam == "true")
}
edgeDeviceUntrusted, _ := request.RetrieveBooleanQueryParameter(r, "edgeDeviceUntrusted", true)
@ -85,7 +85,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs,
status: status,
edgeDevice: edgeDevice,
edgeAsync: edgeAsync,
edgeDeviceUntrusted: edgeDeviceUntrusted,
excludeSnapshots: excludeSnapshots,
name: name,
@ -108,15 +108,26 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
filteredEndpoints = filterEndpointsByName(filteredEndpoints, query.name)
}
if query.edgeDevice != nil {
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, *query.edgeDevice, query.edgeDeviceUntrusted)
} else {
// If the edgeDevice parameter is not set, we need to filter out the untrusted edge devices
// filter async edge environments
if query.edgeAsync != nil {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !endpoint.IsEdgeDevice || endpoint.UserTrusted
if !endpointutils.IsEdgeEndpoint(&endpoint) {
return true
}
return endpoint.Edge.AsyncMode == *query.edgeAsync
})
}
// filter edge environments by trusted/untrusted
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
if !endpointutils.IsEdgeEndpoint(&endpoint) {
return true
}
return endpoint.UserTrusted == !query.edgeDeviceUntrusted
})
if len(query.status) > 0 {
filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, query.status, settings)
}
@ -274,30 +285,6 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []port
return endpoints[:n]
}
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDevice bool, untrusted bool) []portainer.Endpoint {
n := 0
for _, endpoint := range endpoints {
if shouldReturnEdgeDevice(endpoint, edgeDevice, untrusted) {
endpoints[n] = endpoint
n++
}
}
return endpoints[:n]
}
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceParam bool, untrustedParam bool) bool {
if !endpointutils.IsEdgeEndpoint(&endpoint) {
return true
}
if !edgeDeviceParam {
return !endpoint.IsEdgeDevice
}
return endpoint.IsEdgeDevice && endpoint.UserTrusted == !untrustedParam
}
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
tags := make([]string, 0, len(tagIDs))

View File

@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
@ -74,19 +73,19 @@ func Test_Filter_AgentVersion(t *testing.T) {
runTests(tests, t, handler, endpoints)
}
func Test_Filter_edgeDeviceFilter(t *testing.T) {
func Test_Filter_edgeFilter(t *testing.T) {
trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
untrustedEdgeDevice := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
trustedEdgeAsync := portainer.Endpoint{ID: 1, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
untrustedEdgeAsync := portainer.Endpoint{ID: 2, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularUntrustedEdgeStandard := portainer.Endpoint{ID: 3, UserTrusted: false, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularTrustedEdgeStandard := portainer.Endpoint{ID: 4, UserTrusted: true, Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment}
endpoints := []portainer.Endpoint{
trustedEdgeDevice,
untrustedEdgeDevice,
regularUntrustedEdgeEndpoint,
regularTrustedEdgeEndpoint,
trustedEdgeAsync,
untrustedEdgeAsync,
regularUntrustedEdgeStandard,
regularTrustedEdgeStandard,
regularEndpoint,
}
@ -96,32 +95,32 @@ func Test_Filter_edgeDeviceFilter(t *testing.T) {
tests := []filterTest{
{
"should show all edge endpoints except of the untrusted devices",
[]portainer.EndpointID{trustedEdgeDevice.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
"should show all edge endpoints except of the untrusted edge",
[]portainer.EndpointID{trustedEdgeAsync.ID, regularTrustedEdgeStandard.ID},
EnvironmentsQuery{
types: []portainer.EndpointType{portainer.EdgeAgentOnDockerEnvironment, portainer.EdgeAgentOnKubernetesEnvironment},
},
},
{
"should show only trusted edge devices and other regular endpoints",
[]portainer.EndpointID{trustedEdgeDevice.ID, regularEndpoint.ID},
[]portainer.EndpointID{trustedEdgeAsync.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeDevice: BoolAddr(true),
edgeAsync: BoolAddr(true),
},
},
{
"should show only untrusted edge devices and other regular endpoints",
[]portainer.EndpointID{untrustedEdgeDevice.ID, regularEndpoint.ID},
[]portainer.EndpointID{untrustedEdgeAsync.ID, regularEndpoint.ID},
EnvironmentsQuery{
edgeDevice: BoolAddr(true),
edgeAsync: BoolAddr(true),
edgeDeviceUntrusted: true,
},
},
{
"should show no edge devices",
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
[]portainer.EndpointID{regularEndpoint.ID, regularTrustedEdgeStandard.ID},
EnvironmentsQuery{
edgeDevice: BoolAddr(false),
edgeAsync: BoolAddr(false),
},
},
}
@ -168,7 +167,7 @@ func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) (handler *Han
err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
bouncer := helper.NewTestRequestBouncer()
bouncer := testhelpers.NewTestRequestBouncer()
handler = NewHandler(bouncer, nil)
handler.DataStore = store
handler.ComposeStackManager = testhelpers.NewComposeStackManager()

View File

@ -40,8 +40,6 @@ type publicSettingsResponse struct {
IsAMTEnabled bool
Edge struct {
// Whether the device has been started in edge async mode
AsyncMode bool
// The ping interval for edge agent - used in edge async mode [seconds]
PingInterval int `json:"PingInterval" example:"60"`
// The snapshot interval for edge agent - used in edge async mode [seconds]
@ -86,7 +84,6 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
IsAMTEnabled: appSettings.EnableEdgeComputeFeatures && appSettings.OpenAMTConfiguration.Enabled,
}
publicSettings.Edge.AsyncMode = appSettings.Edge.AsyncMode
publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval
publicSettings.Edge.SnapshotInterval = appSettings.Edge.SnapshotInterval
publicSettings.Edge.CommandInterval = appSettings.Edge.CommandInterval

View File

@ -382,16 +382,7 @@ type (
// Whether we need to run any "post init migrations".
PostInitMigrations EndpointPostInitMigrations `json:"PostInitMigrations"`
Edge struct {
// Whether the device has been started in edge async mode
AsyncMode bool
// The ping interval for edge agent - used in edge async mode [seconds]
PingInterval int `json:"PingInterval" example:"60"`
// The snapshot interval for edge agent - used in edge async mode [seconds]
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
// The command list interval for edge agent - used in edge async mode [seconds]
CommandInterval int `json:"CommandInterval" example:"60"`
}
Edge EnvironmentEdgeSettings
Agent struct {
Version string `example:"1.0.0"`
@ -412,6 +403,17 @@ type (
Tags []string `json:"Tags"`
}
EnvironmentEdgeSettings struct {
// Whether the device has been started in edge async mode
AsyncMode bool
// The ping interval for edge agent - used in edge async mode [seconds]
PingInterval int `json:"PingInterval" example:"60"`
// The snapshot interval for edge agent - used in edge async mode [seconds]
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
// The command list interval for edge agent - used in edge async mode [seconds]
CommandInterval int `json:"CommandInterval" example:"60"`
}
// EndpointAuthorizations represents the authorizations associated to a set of environments(endpoints)
EndpointAuthorizations map[EndpointID]Authorizations
@ -917,7 +919,8 @@ type (
PingInterval int `json:"PingInterval" example:"5"`
// The snapshot interval for edge agent - used in edge async mode (in seconds)
SnapshotInterval int `json:"SnapshotInterval" example:"5"`
// EdgeAsyncMode enables edge async mode by default
// Deprecated 2.18
AsyncMode bool
}

View File

@ -20,7 +20,7 @@ export const componentsModule = angular
'edgeInfo',
'commands',
'isNomadTokenVisible',
'hideAsyncMode',
'asyncMode',
])
)
.component(

View File

@ -148,6 +148,7 @@ angular
'sidebar@': {},
},
};
const logout = {
name: 'portainer.logout',
url: '/logout',
@ -200,6 +201,16 @@ angular
},
};
const edgeAutoCreateScript = {
name: 'portainer.endpoints.edgeAutoCreateScript',
url: '/aeec',
views: {
'content@': {
component: 'edgeAutoCreateScriptView',
},
},
};
var addFDOProfile = {
name: 'portainer.endpoints.profile',
url: '/profile',
@ -424,6 +435,7 @@ angular
$stateRegistryProvider.register(endpoint);
$stateRegistryProvider.register(endpointAccess);
$stateRegistryProvider.register(endpointKVM);
$stateRegistryProvider.register(edgeAutoCreateScript);
$stateRegistryProvider.register(deviceImport);
$stateRegistryProvider.register(addFDOProfile);
$stateRegistryProvider.register(editFDOProfile);

View File

@ -33,7 +33,21 @@
<pr-icon icon="'trash-2'" class-name="'icon-white'"></pr-icon>
<span>Remove</span>
</button>
<button type="button" class="btn btn-sm btn-primary h-fit" ng-click="$ctrl.setReferrer()" ui-sref="portainer.wizard.endpoints" data-cy="endpoint-addEndpointButton">
<import-fdo-device-button></import-fdo-device-button>
<button ng-if="$ctrl.isBE" type="button" class="btn btn-sm btn-secondary vertical-center" ui-sref="portainer.endpoints.edgeAutoCreateScript">
<pr-icon icon="'plus'"></pr-icon>
<span>Generate AEEC script</span>
</button>
<button
type="button"
class="btn btn-sm btn-primary vertical-center"
ng-click="$ctrl.setReferrer()"
ui-sref="portainer.wizard.endpoints"
data-cy="endpoint-addEndpointButton"
>
<pr-icon icon="'plus'" class-name="'icon-white'"></pr-icon>
<span>Add environment</span>
</button>

View File

@ -1,4 +1,5 @@
import _ from 'lodash-es';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
angular.module('portainer.app').controller('EndpointsDatatableController', [
'$scope',
@ -8,6 +9,8 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
function ($scope, $controller, DatatableService, PaginationService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.isBE = isBE;
this.state = Object.assign(this.state, {
orderBy: this.orderBy,
loading: true,

View File

@ -85,5 +85,4 @@ export function EdgeSettingsViewModel(data = {}) {
this.PingInterval = data.PingInterval;
this.SnapshotInterval = data.SnapshotInterval;
this.CommandInterval = data.CommandInterval;
this.AsyncMode = data.AsyncMode;
}

View File

@ -0,0 +1,13 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { ImportFdoDeviceButton } from '@/react/portainer/environments/ListView/ImportFdoDeviceButton';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withReactQuery } from '@/react-tools/withReactQuery';
export const envListModule = angular
.module('portainer.app.react.components.environments.list-view', [])
.component(
'importFdoDeviceButton',
r2a(withUIRouter(withReactQuery(ImportFdoDeviceButton)), [])
).name;

View File

@ -32,6 +32,7 @@ import { customTemplatesModule } from './custom-templates';
import { gitFormModule } from './git-form';
import { settingsModule } from './settings';
import { accessControlModule } from './access-control';
import { envListModule } from './enviroments-list-view-components';
export const componentsModule = angular
.module('portainer.app.react.components', [
@ -39,6 +40,7 @@ export const componentsModule = angular
gitFormModule,
settingsModule,
accessControlModule,
envListModule,
])
.component(
'tagSelector',

View File

@ -8,6 +8,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { CreateAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
import { withI18nSuspense } from '@/react-tools/withI18nSuspense';
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
import { wizardModule } from './wizard';
import { teamsModule } from './teams';
@ -23,6 +24,13 @@ export const viewsModule = angular
'homeView',
r2a(withUIRouter(withReactQuery(withCurrentUser(HomeView))), [])
)
.component(
'edgeAutoCreateScriptView',
r2a(
withUIRouter(withReactQuery(withCurrentUser(EdgeAutoCreateScriptView))),
[]
)
)
.component(
'createAccessToken',
r2a(withI18nSuspense(withUIRouter(CreateAccessToken)), [

View File

@ -50,7 +50,7 @@
edge-info="{ key: endpoint.EdgeKey, id: endpoint.EdgeID }"
commands="state.edgeScriptCommands"
is-nomad-token-visible="state.showNomad"
hide-async-mode="!endpoint.IsEdgeDevice"
async-mode="endpoint.Edge.AsyncMode"
></edge-script-form>
<edge-key-display edge-key="endpoint.EdgeKey"> </edge-key-display>

View File

@ -29,7 +29,7 @@ export function BoxSelector<T extends Value>({
...props
}: Props<T>) {
return (
<div className="form-group">
<div className='form-group after:clear-both after:table after:content-[""]'>
<div className="col-sm-12">
<div className={styles.root} role="radiogroup">
{options

View File

@ -60,6 +60,7 @@ export function TagSelector({ value, allowCreate = false, onChange }: Props) {
<FormControl label="Selected tags">
{selectedTags.map((tag) => (
<TagButton
key={tag.value}
title="Remove tag"
value={tag.value}
label={tag.label}

View File

@ -1,4 +1,5 @@
import { Formik } from 'formik';
import { PropsWithChildren } from 'react';
import { OsSelector } from './OsSelector';
import { CommandTab } from './scripts';
@ -21,15 +22,16 @@ interface Props {
edgeInfo: EdgeInfo;
commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>;
isNomadTokenVisible?: boolean;
hideAsyncMode?: boolean;
asyncMode?: boolean;
}
export function EdgeScriptForm({
edgeInfo,
commands,
isNomadTokenVisible,
hideAsyncMode,
}: Props) {
asyncMode,
children,
}: PropsWithChildren<Props>) {
const showOsSelector = !(commands instanceof Array);
return (
@ -41,6 +43,8 @@ export function EdgeScriptForm({
>
{({ values, setFieldValue }) => (
<>
{children}
<EdgeScriptSettingsFieldset
isNomadTokenVisible={
isNomadTokenVisible && values.platform === 'nomad'
@ -63,7 +67,7 @@ export function EdgeScriptForm({
onPlatformChange={(platform) =>
setFieldValue('platform', platform)
}
hideAsyncMode={hideAsyncMode}
asyncMode={asyncMode}
/>
</div>
</>

View File

@ -16,7 +16,7 @@ interface Props {
commands: CommandTab[];
platform?: Platform;
onPlatformChange?(platform: Platform): void;
hideAsyncMode?: boolean;
asyncMode?: boolean;
}
export function ScriptTabs({
@ -25,7 +25,7 @@ export function ScriptTabs({
edgeId,
commands,
platform,
hideAsyncMode = false,
asyncMode = false,
onPlatformChange = () => {},
}: Props) {
const agentDetails = useAgentDetails();
@ -40,14 +40,14 @@ export function ScriptTabs({
return null;
}
const { agentSecret, agentVersion, useEdgeAsyncMode } = agentDetails;
const { agentSecret, agentVersion } = agentDetails;
const options = commands.map((c) => {
const cmd = c.command(
agentVersion,
edgeKey,
values,
!hideAsyncMode && useEdgeAsyncMode,
asyncMode,
edgeId,
agentSecret
);
@ -67,14 +67,10 @@ export function ScriptTabs({
});
return (
<div className="row">
<div className="col-sm-12">
<NavTabs
selectedId={platform}
options={options}
onSelect={(id: Platform) => onPlatformChange(id)}
/>
</div>
</div>
<NavTabs
selectedId={platform}
options={options}
onSelect={(id: Platform) => onPlatformChange(id)}
/>
);
}

View File

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_566_495341)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.8878 1.14478C17.8878 1.02052 17.9886 0.919784 18.1128 0.919784L21.1025 0.919784L20.6034 0.420659C20.5155 0.332791 20.5155 0.190329 20.6034 0.102461C20.6913 0.0145931 20.8337 0.0145931 20.9216 0.102461L21.8048 0.985685C21.847 1.02788 21.8707 1.08511 21.8707 1.14478C21.8707 1.20446 21.847 1.26169 21.8048 1.30388L20.9216 2.18711C20.8337 2.27498 20.6913 2.27498 20.6034 2.18711C20.5155 2.09924 20.5155 1.95678 20.6034 1.86891L21.1025 1.36978L18.1128 1.36978C17.9886 1.36978 17.8878 1.26905 17.8878 1.14478ZM21.8719 3.75998C21.8719 3.88424 21.7712 3.98498 21.6469 3.98498L18.6572 3.98498L19.1563 4.48411C19.2442 4.57197 19.2442 4.71444 19.1563 4.8023C19.0684 4.89017 18.926 4.89017 18.8381 4.8023L17.9549 3.91908C17.9127 3.87688 17.889 3.81965 17.889 3.75998C17.889 3.70031 17.9127 3.64308 17.9549 3.60088L18.8381 2.71766C18.926 2.62979 19.0684 2.62979 19.1563 2.71766C19.2442 2.80552 19.2442 2.94799 19.1563 3.03585L18.6572 3.53498L21.6469 3.53498C21.7712 3.53498 21.8719 3.63572 21.8719 3.75998ZM12.0029 2.5332C9.42495 2.5332 7.25537 4.27581 6.60571 6.64735C4.65797 7.09904 3.20686 8.84425 3.20686 10.9291C3.20686 13.3567 5.17475 15.3245 7.60226 15.3245H7.96694V19.0776C7.96694 19.2239 7.84831 19.3426 7.70198 19.3426H6.09254C5.90054 18.3678 5.0412 17.6326 4.01015 17.6326C2.83795 17.6326 1.8877 18.5829 1.8877 19.7551C1.8877 20.9273 2.83795 21.8775 4.01015 21.8775C4.95661 21.8775 5.75837 21.258 6.03208 20.4024H7.70198C8.43366 20.4024 9.02681 19.8093 9.02681 19.0776V15.3245H11.4749V19.8209C10.5573 20.055 9.87878 20.887 9.87878 21.8776C9.87878 23.0498 10.829 24 12.0012 24C13.1734 24 14.1237 23.0498 14.1237 21.8776C14.1237 20.8897 13.4487 20.0594 12.5348 19.8228V15.3245H14.9261V19.0776C14.9261 19.8093 15.5193 20.4024 16.251 20.4024H17.9681C18.2418 21.258 19.0436 21.8775 19.99 21.8775C21.1622 21.8775 22.1125 20.9273 22.1125 19.7551C22.1125 18.5829 21.1622 17.6326 19.99 17.6326C18.959 17.6326 18.0997 18.3678 17.9077 19.3426H16.251C16.1046 19.3426 15.986 19.2239 15.986 19.0776V15.3245H16.4035C18.831 15.3245 20.7989 13.3567 20.7989 10.9291C20.7989 8.84425 19.3478 7.09904 17.4 6.64736C16.7504 4.27582 14.5808 2.5332 12.0029 2.5332ZM8.05038 7.4731C8.36278 5.57283 10.0143 4.123 12.0029 4.123C13.9915 4.123 15.643 5.57283 15.9554 7.4731C16.014 7.83001 16.3063 8.10231 16.6664 8.13566C18.0922 8.26771 19.2091 9.46846 19.2091 10.9291C19.2091 12.4786 17.953 13.7347 16.4035 13.7347H7.60226C6.05278 13.7347 4.79667 12.4786 4.79667 10.9291C4.79667 9.46846 5.91353 8.26771 7.33932 8.13566C7.69948 8.10231 7.99171 7.83001 8.05038 7.4731ZM12.002 8.28697C11.0562 8.28697 10.1435 8.63556 9.43858 9.2661C9.30224 9.38805 9.09285 9.37638 8.9709 9.24004C8.84896 9.1037 8.86062 8.89431 8.99697 8.77237C9.82338 8.0332 10.8932 7.62455 12.002 7.62455C13.1108 7.62455 14.1806 8.0332 15.007 8.77237C15.1434 8.89431 15.155 9.1037 15.0331 9.24004C14.9111 9.37638 14.7018 9.38805 14.5654 9.2661C13.8605 8.63556 12.9478 8.28697 12.002 8.28697ZM12.002 9.71836C11.3599 9.71836 10.7434 9.97011 10.2849 10.4196C10.1543 10.5476 9.94456 10.5455 9.81651 10.4149C9.68847 10.2843 9.69056 10.0746 9.82119 9.94651C10.4036 9.37568 11.1865 9.05594 12.002 9.05594C12.8175 9.05594 13.6004 9.37568 14.1828 9.94651C14.3134 10.0746 14.3155 10.2843 14.1875 10.4149C14.0594 10.5455 13.8497 10.5476 13.7191 10.4196C13.2606 9.97011 12.6441 9.71836 12.002 9.71836ZM12.002 11.0908C11.7243 11.0908 11.4577 11.1997 11.2594 11.3941C11.1287 11.5221 10.919 11.52 10.791 11.3894C10.6629 11.2587 10.665 11.049 10.7957 10.921C11.1178 10.6052 11.5509 10.4284 12.002 10.4284C12.4531 10.4284 12.8862 10.6052 13.2083 10.921C13.339 11.049 13.3411 11.2587 13.213 11.3894C13.085 11.52 12.8753 11.5221 12.7446 11.3941C12.5463 11.1997 12.2797 11.0908 12.002 11.0908ZM11.6708 12.132C11.6708 11.9491 11.8191 11.8008 12.002 11.8008H12.0048C12.1877 11.8008 12.336 11.9491 12.336 12.132C12.336 12.3149 12.1877 12.4632 12.0048 12.4632H12.002C11.8191 12.4632 11.6708 12.3149 11.6708 12.132ZM4.01015 20.8177C4.597 20.8177 5.07274 20.3419 5.07274 19.7551C5.07274 19.1682 4.597 18.6925 4.01015 18.6925C3.4233 18.6925 2.94756 19.1682 2.94756 19.7551C2.94756 20.3419 3.4233 20.8177 4.01015 20.8177ZM13.0638 21.8776C13.0638 22.4644 12.5881 22.9402 12.0012 22.9402C11.4144 22.9402 10.9386 22.4644 10.9386 21.8776C10.9386 21.2907 11.4144 20.815 12.0012 20.815C12.5881 20.815 13.0638 21.2907 13.0638 21.8776ZM19.99 20.8177C20.5769 20.8177 21.0526 20.3419 21.0526 19.7551C21.0526 19.1682 20.5769 18.6925 19.99 18.6925C19.4032 18.6925 18.9275 19.1682 18.9275 19.7551C18.9275 20.3419 19.4032 20.8177 19.99 20.8177Z" fill="#0086C9"/>
</g>
<defs>
<clipPath id="clip0_566_495341">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_566_495340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.60571 6.64736C7.25537 4.27582 9.42495 2.5332 12.0029 2.5332C14.5808 2.5332 16.7504 4.27582 17.4 6.64736C19.3478 7.09905 20.7989 8.84425 20.7989 10.9292C20.7989 13.3567 18.831 15.3246 16.4035 15.3246H15.986V19.0776C15.986 19.2239 16.1046 19.3426 16.251 19.3426H17.9077C18.0997 18.3678 18.959 17.6326 19.99 17.6326C21.1622 17.6326 22.1125 18.5829 22.1125 19.7551C22.1125 20.9273 21.1622 21.8775 19.99 21.8775C19.0436 21.8775 18.2418 21.258 17.9681 20.4024H16.251C15.5193 20.4024 14.9261 19.8093 14.9261 19.0776V15.3246H12.5348V19.8228C13.4487 20.0594 14.1237 20.8897 14.1237 21.8776C14.1237 23.0498 13.1734 24 12.0012 24C10.829 24 9.87878 23.0498 9.87878 21.8776C9.87878 20.8871 10.5573 20.055 11.4749 19.8209V15.3246H9.02681V19.0776C9.02681 19.8093 8.43366 20.4024 7.70198 20.4024H6.03208C5.75837 21.258 4.95661 21.8775 4.01015 21.8775C2.83795 21.8775 1.8877 20.9273 1.8877 19.7551C1.8877 18.5829 2.83795 17.6326 4.01015 17.6326C5.0412 17.6326 5.90054 18.3678 6.09254 19.3426H7.70198C7.84831 19.3426 7.96694 19.2239 7.96694 19.0776V15.3246H7.60226C5.17475 15.3246 3.20686 13.3567 3.20686 10.9292C3.20686 8.84426 4.65797 7.09905 6.60571 6.64736ZM12.0029 4.12301C10.0143 4.12301 8.36278 5.57284 8.05038 7.4731C7.99171 7.83001 7.69948 8.10231 7.33932 8.13567C5.91353 8.26772 4.79667 9.46847 4.79667 10.9292C4.79667 12.4786 6.05278 13.7347 7.60226 13.7347H16.4035C17.953 13.7347 19.2091 12.4786 19.2091 10.9292C19.2091 9.46847 18.0922 8.26772 16.6664 8.13567C16.3063 8.10231 16.014 7.83001 15.9554 7.4731C15.643 5.57284 13.9915 4.12301 12.0029 4.12301ZM9.43858 9.26611C10.1435 8.63557 11.0562 8.28697 12.002 8.28697C12.9478 8.28697 13.8605 8.63557 14.5654 9.26611C14.7018 9.38806 14.9111 9.37639 15.0331 9.24005C15.155 9.1037 15.1434 8.89432 15.007 8.77237C14.1806 8.0332 13.1108 7.62455 12.002 7.62455C10.8932 7.62455 9.82338 8.0332 8.99697 8.77237C8.86062 8.89432 8.84896 9.1037 8.9709 9.24005C9.09285 9.37639 9.30224 9.38806 9.43858 9.26611ZM10.2849 10.4196C10.7434 9.97012 11.3599 9.71837 12.002 9.71837C12.6441 9.71837 13.2606 9.97012 13.7191 10.4196C13.8497 10.5476 14.0594 10.5455 14.1875 10.4149C14.3155 10.2843 14.3134 10.0746 14.1828 9.94652C13.6004 9.37569 12.8175 9.05595 12.002 9.05595C11.1865 9.05595 10.4036 9.37569 9.82119 9.94652C9.69056 10.0746 9.68847 10.2843 9.81651 10.4149C9.94456 10.5455 10.1543 10.5476 10.2849 10.4196ZM11.2594 11.3941C11.4577 11.1997 11.7243 11.0908 12.002 11.0908C12.2797 11.0908 12.5463 11.1997 12.7446 11.3941C12.8753 11.5221 13.085 11.52 13.213 11.3894C13.3411 11.2587 13.339 11.049 13.2083 10.921C12.8862 10.6052 12.4531 10.4284 12.002 10.4284C11.5509 10.4284 11.1178 10.6052 10.7957 10.921C10.665 11.049 10.6629 11.2587 10.791 11.3894C10.919 11.52 11.1287 11.5221 11.2594 11.3941ZM12.002 11.8008C11.8191 11.8008 11.6708 11.9491 11.6708 12.132C11.6708 12.3149 11.8191 12.4632 12.002 12.4632H12.0048C12.1877 12.4632 12.336 12.3149 12.336 12.132C12.336 11.9491 12.1877 11.8008 12.0048 11.8008H12.002ZM5.07274 19.7551C5.07274 20.3419 4.597 20.8177 4.01015 20.8177C3.4233 20.8177 2.94756 20.3419 2.94756 19.7551C2.94756 19.1682 3.4233 18.6925 4.01015 18.6925C4.597 18.6925 5.07274 19.1682 5.07274 19.7551ZM12.0012 22.9402C12.5881 22.9402 13.0638 22.4644 13.0638 21.8776C13.0638 21.2907 12.5881 20.815 12.0012 20.815C11.4144 20.815 10.9386 21.2907 10.9386 21.8776C10.9386 22.4644 11.4144 22.9402 12.0012 22.9402ZM21.0526 19.7551C21.0526 20.3419 20.5769 20.8177 19.99 20.8177C19.4032 20.8177 18.9275 20.3419 18.9275 19.7551C18.9275 19.1682 19.4032 18.6925 19.99 18.6925C20.5769 18.6925 21.0526 19.1682 21.0526 19.7551Z" fill="#0086C9"/>
</g>
<defs>
<clipPath id="clip0_566_495340">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -12,7 +12,6 @@ export default withLimitToBE(WaitingRoomView);
function WaitingRoomView() {
const { environments, isLoading, totalCount } = useEnvironmentList({
edgeDevice: true,
edgeDeviceUntrusted: true,
excludeSnapshots: true,
types: EdgeTypes,

View File

@ -1,5 +1,3 @@
import { Globe } from 'lucide-react';
import { Environment } from '@/react/portainer/environments/types';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
@ -8,15 +6,5 @@ export function AgentDetails({ environment }: { environment: Environment }) {
return null;
}
return (
<>
<span>{environment.Agent.Version}</span>
{environment.Edge.AsyncMode && (
<span className="vertical-center gap-1">
<Globe className="icon icon-sm space-right" aria-hidden="true" />
Async Environment
</span>
)}
</>
);
return <span>{environment.Agent.Version}</span>;
}

View File

@ -31,12 +31,12 @@ export function EnvironmentTypeTag({
}
function getTypeLabel(environment: Environment) {
if (environment.IsEdgeDevice) {
return 'Edge Device';
}
if (isEdgeEnvironment(environment.Type)) {
return 'Edge Agent';
if (environment.Edge.AsyncMode) {
return 'Edge Agent Async';
}
return 'Edge Agent Standard';
}
if (isLocalEnvironment(environment)) {

View File

@ -110,6 +110,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
tagsPartialMatch: true,
agentVersions: agentVersions.map((a) => a.value),
updateInformation: isBE,
edgeAsync: getEdgeAsyncValue(connectionTypes),
};
const queryWithSort = {
@ -282,8 +283,8 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
EnvironmentType.AgentOnDocker,
EnvironmentType.AgentOnKubernetes,
],
[ConnectionType.EdgeAgent]: EdgeTypes,
[ConnectionType.EdgeDevice]: EdgeTypes,
[ConnectionType.EdgeAgentStandard]: EdgeTypes,
[ConnectionType.EdgeAgentAsync]: EdgeTypes,
};
const selectedTypesByPlatform = platformTypes.flatMap(
@ -405,3 +406,21 @@ function renderItems(
return items;
}
function getEdgeAsyncValue(connectionTypes: Filter<ConnectionType>[]) {
const hasEdgeAsync = connectionTypes.some(
(connectionType) => connectionType.value === ConnectionType.EdgeAgentAsync
);
const hasEdgeStandard = connectionTypes.some(
(connectionType) =>
connectionType.value === ConnectionType.EdgeAgentStandard
);
// If both are selected, we don't want to filter on either, and same for if both are not selected
if (hasEdgeAsync === hasEdgeStandard) {
return undefined;
}
return hasEdgeAsync;
}

View File

@ -176,22 +176,26 @@ function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) {
[PlatformType.Docker]: [
ConnectionType.API,
ConnectionType.Agent,
ConnectionType.EdgeAgent,
ConnectionType.EdgeDevice,
ConnectionType.EdgeAgentStandard,
ConnectionType.EdgeAgentAsync,
],
[PlatformType.Azure]: [ConnectionType.API],
[PlatformType.Kubernetes]: [
ConnectionType.Agent,
ConnectionType.EdgeAgent,
ConnectionType.EdgeDevice,
ConnectionType.EdgeAgentStandard,
ConnectionType.EdgeAgentAsync,
],
[PlatformType.Nomad]: [
ConnectionType.EdgeAgentStandard,
ConnectionType.EdgeAgentAsync,
],
[PlatformType.Nomad]: [ConnectionType.EdgeAgent, ConnectionType.EdgeDevice],
};
const connectionTypesDefaultOptions = [
{ value: ConnectionType.API, label: 'API' },
{ value: ConnectionType.Agent, label: 'Agent' },
{ value: ConnectionType.EdgeAgent, label: 'Edge Agent' },
{ value: ConnectionType.EdgeAgentStandard, label: 'Edge Agent Standard' },
{ value: ConnectionType.EdgeAgentAsync, label: 'Edge Agent Async' },
];
if (platformTypes.length === 0) {
@ -226,12 +230,12 @@ function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) {
const connectionTypePlatformType = {
[ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure],
[ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes],
[ConnectionType.EdgeAgent]: [
[ConnectionType.EdgeAgentStandard]: [
PlatformType.Kubernetes,
PlatformType.Nomad,
PlatformType.Docker,
],
[ConnectionType.EdgeDevice]: [
[ConnectionType.EdgeAgentAsync]: [
PlatformType.Nomad,
PlatformType.Docker,
PlatformType.Kubernetes,

View File

@ -6,6 +6,6 @@ export interface Filter<T = number> {
export enum ConnectionType {
API,
Agent,
EdgeAgent,
EdgeDevice,
EdgeAgentStandard,
EdgeAgentAsync,
}

View File

@ -0,0 +1,65 @@
import { Field, useField } from 'formik';
import { string } from 'yup';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
interface Props {
fieldName: string;
readonly?: boolean;
required?: boolean;
}
export function PortainerTunnelAddrField({
fieldName,
readonly,
required,
}: Props) {
const [, metaProps] = useField(fieldName);
const id = `${fieldName}-input`;
return (
<FormControl
label="Portainer tunnel server address"
tooltip="Address of this Portainer instance that will be used by Edge agents to establish a reverse tunnel."
required
errors={metaProps.error}
inputId={id}
>
<Field
id={id}
name={fieldName}
as={Input}
placeholder="portainer.mydomain.tld"
required={required}
readOnly={readonly}
/>
</FormControl>
);
}
export function validation() {
return string()
.required('Tunnel server address is required')
.test(
'valid tunnel server URL',
'The tunnel server address must be a valid address (localhost cannot be used)',
(value) => {
if (!value) {
return false;
}
return !value.startsWith('localhost');
}
);
}
/**
* Returns an address that can be used as a default value for the Portainer tunnel server address
* based on the current window location.
* Used for Edge Compute.
*
*/
export function buildDefaultValue() {
return `${window.location.hostname}:8000`;
}

View File

@ -7,36 +7,23 @@ import { Input } from '@@/form-components/Input';
interface Props {
fieldName: string;
readonly?: boolean;
required?: boolean;
tooltip?: string;
}
export function validation() {
return string()
.test(
'url',
'URL should be a valid URI and cannot include localhost',
(value) => {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.hostname !== 'localhost';
} catch {
return false;
}
}
)
.required('URL is required');
}
export function PortainerUrlField({ fieldName, readonly }: Props) {
export function PortainerUrlField({
fieldName,
readonly,
required,
tooltip = 'URL of the Portainer instance that the agent will use to initiate the communications.',
}: Props) {
const [, metaProps] = useField(fieldName);
const id = `${fieldName}-input`;
return (
<FormControl
label="Portainer server URL"
tooltip="URL of the Portainer instance that the agent will use to initiate the communications."
label="Portainer API server URL"
tooltip={tooltip}
required
errors={metaProps.error}
inputId={id}
@ -45,11 +32,42 @@ export function PortainerUrlField({ fieldName, readonly }: Props) {
id={id}
name={fieldName}
as={Input}
placeholder="e.g. https://10.0.0.10:9443 or https://portainer.mydomain.com"
required
placeholder="https://portainer.mydomain.tld"
required={required}
data-cy="endpointCreate-portainerServerUrlInput"
readOnly={readonly}
/>
</FormControl>
);
}
export function validation() {
return string()
.required('API server URL is required')
.test(
'valid API server URL',
'The API server URL must be a valid URL (localhost cannot be used)',
(value) => {
if (!value) {
return false;
}
try {
const url = new URL(value);
return !!url.hostname && url.hostname !== 'localhost';
} catch {
return false;
}
}
);
}
/**
* Returns a URL that can be used as a default value for the Portainer server API URL
* based on the current window location.
* Used for Edge Compute.
*
*/
export function buildDefaultValue() {
return `${window.location.protocol}//${window.location.host}`;
}

View File

@ -0,0 +1,180 @@
import { useMutation } from 'react-query';
import { useEffect, useState } from 'react';
import { Laptop } from 'lucide-react';
import { generateKey } from '@/react/portainer/environments/environment.service/edge';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { useSettings } from '@/react/portainer/settings/queries';
import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c';
import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { TextTip } from '@@/Tip/TextTip';
import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection';
import { CopyButton } from '@@/buttons';
import { Link } from '@@/Link';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
const commands = {
linux: [
commandsTabs.k8sLinux,
commandsTabs.swarmLinux,
commandsTabs.standaloneLinux,
commandsTabs.nomadLinux,
],
win: [commandsTabs.swarmWindows, commandsTabs.standaloneWindow],
};
const asyncModeOptions = [
{
icon: EdgeAgentStandardIcon,
id: 'standard',
label: 'Edge Agent Standard',
value: false,
iconType: 'badge',
},
{
icon: EdgeAgentAsyncIcon,
id: 'async',
label: 'Edge Agent Async',
value: true,
iconType: 'badge',
},
] as const;
export function AutomaticEdgeEnvCreation() {
const edgeKeyMutation = useGenerateKeyMutation();
const { mutate: generateKey, reset: resetKey } = edgeKeyMutation;
const settingsQuery = useSettings();
const [asyncMode, setAsyncMode] = useState(false);
const url = settingsQuery.data?.EdgePortainerUrl;
const settings = settingsQuery.data;
const edgeKey = edgeKeyMutation.data;
const edgeComputeConfigurationOK = validateConfiguration();
useEffect(() => {
if (edgeComputeConfigurationOK) {
generateKey();
} else {
resetKey();
}
}, [generateKey, edgeComputeConfigurationOK, resetKey]);
if (!settingsQuery.data) {
return null;
}
return (
<Widget>
<WidgetTitle icon={Laptop} title="Automatic Edge Environment Creation" />
<WidgetBody className="form-horizontal">
{!edgeComputeConfigurationOK ? (
<TextTip color="orange">
In order to use this feature, please turn on Edge Compute features{' '}
<Link to="portainer.settings.edgeCompute">here</Link> and have
Portainer API server URL and tunnel server address properly
configured.
</TextTip>
) : (
<>
<BoxSelector
slim
radioName="async-mode-selector"
value={asyncMode}
onChange={handleChangeAsyncMode}
options={asyncModeOptions}
/>
<EdgeKeyInfo
asyncMode={asyncMode}
edgeKey={edgeKey}
isLoading={edgeKeyMutation.isLoading}
url={url}
tunnelUrl={settings?.Edge.TunnelServerAddress}
/>
</>
)}
</WidgetBody>
</Widget>
);
function handleChangeAsyncMode(asyncMode: boolean) {
setAsyncMode(asyncMode);
}
function validateConfiguration() {
return !!(
settings &&
settings.EnableEdgeComputeFeatures &&
settings.EdgePortainerUrl &&
settings.Edge.TunnelServerAddress
);
}
}
// using mutation because we want this action to run only when required
function useGenerateKeyMutation() {
return useMutation(generateKey);
}
function EdgeKeyInfo({
isLoading,
edgeKey,
url,
tunnelUrl,
asyncMode,
}: {
isLoading: boolean;
edgeKey?: string;
url?: string;
tunnelUrl?: string;
asyncMode: boolean;
}) {
if (isLoading || !edgeKey) {
return <div>Generating key for {url} ... </div>;
}
return (
<>
<hr />
<FormSection title="Edge key">
<div className="break-words">
<code>{edgeKey}</code>
</div>
<CopyButton copyText={edgeKey}>Copy token</CopyButton>
</FormSection>
<hr />
<EdgeScriptForm
edgeInfo={{ key: edgeKey }}
commands={commands}
isNomadTokenVisible
asyncMode={asyncMode}
>
<FormControl label="Portainer API server URL">
<Input value={url} readOnly />
</FormControl>
{!asyncMode && (
<FormControl label="Portainer tunnel server address">
<Input value={tunnelUrl} readOnly />
</FormControl>
)}
<TextTip color="blue">
Portainer Server URL{' '}
{!asyncMode ? 'and tunnel server address are' : 'is'} set{' '}
<Link to="portainer.settings.edgeCompute">here</Link>
</TextTip>
</EdgeScriptForm>
</>
);
}

View File

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

View File

@ -0,0 +1,27 @@
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { PageHeader } from '@@/PageHeader';
import { AutomaticEdgeEnvCreation } from './AutomaticEdgeEnvCreation';
export const EdgeAutoCreateScriptViewWrapper = withLimitToBE(
EdgeAutoCreateScriptView
);
function EdgeAutoCreateScriptView() {
return (
<>
<PageHeader
title="Automatic Edge Environment Creation"
breadcrumbs={[
{ label: 'Environments', link: 'portainer.endpoints' },
'Automatic Edge Environment Creation',
]}
/>
<div className="mx-3">
<AutomaticEdgeEnvCreation />
</div>
</>
);
}

View File

@ -0,0 +1 @@
export { EdgeAutoCreateScriptViewWrapper as EdgeAutoCreateScriptView } from './EdgeAutoCreateScriptView';

View File

@ -0,0 +1,28 @@
import { Plus } from 'lucide-react';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { useSettings } from '../../settings/queries';
export function ImportFdoDeviceButton() {
const isFDOEnabledQuery = useSettings(
(settings) => settings.fdoConfiguration.enabled
);
if (!isFDOEnabledQuery.data) {
return null;
}
return (
<Button
type="button"
color="secondary"
icon={Plus}
as={Link}
props={{ to: 'portainer.endpoints.importDevice' }}
>
Import FDO device
</Button>
);
}

View File

@ -2,6 +2,7 @@ import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationV
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { type TagId } from '@/portainer/tags/types';
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { type Environment, EnvironmentCreationTypes } from '../types';
@ -101,6 +102,10 @@ interface TLSSettings {
keyFile?: File;
}
interface EdgeSettings extends EdgeAsyncIntervalsValues {
asyncMode: boolean;
}
export interface EnvironmentOptions {
url?: string;
publicUrl?: string;
@ -110,6 +115,8 @@ export interface EnvironmentOptions {
isEdgeDevice?: boolean;
gpus?: Gpu[];
pollFrequency?: number;
edge?: EdgeSettings;
tunnelServerAddr?: string;
}
interface CreateRemoteEnvironment {
@ -163,10 +170,12 @@ export function createAgentEnvironment({
interface CreateEdgeAgentEnvironment {
name: string;
portainerUrl: string;
tunnelServerAddr?: string;
meta?: EnvironmentMetadata;
pollFrequency: number;
gpus?: Gpu[];
isEdgeDevice?: boolean;
edge: EdgeSettings;
}
export function createEdgeAgentEnvironment({
@ -176,6 +185,7 @@ export function createEdgeAgentEnvironment({
gpus = [],
isEdgeDevice,
pollFrequency,
edge,
}: CreateEdgeAgentEnvironment) {
return createEnvironment(
name,
@ -189,6 +199,7 @@ export function createEdgeAgentEnvironment({
gpus,
isEdgeDevice,
pollFrequency,
edge,
meta,
}
);
@ -240,6 +251,16 @@ async function createEnvironment(
AzureAuthenticationKey: azure.authenticationKey,
};
}
if (options.edge?.asyncMode) {
payload = {
...payload,
EdgeAsyncMode: true,
EdgePingInterval: options.edge?.PingInterval,
EdgeSnapshotInterval: options.edge?.SnapshotInterval,
EdgeCommandInterval: options.edge?.CommandInterval,
};
}
}
const formPayload = json2formData(payload);

View File

@ -22,7 +22,7 @@ export interface EnvironmentsQueryParams {
tagsPartialMatch?: boolean;
groupIds?: EnvironmentGroupId[];
status?: EnvironmentStatus[];
edgeDevice?: boolean;
edgeAsync?: boolean;
edgeDeviceUntrusted?: boolean;
excludeSnapshots?: boolean;
provisioned?: boolean;

View File

@ -15,6 +15,5 @@ export function useAgentDetails() {
return {
agentVersion,
agentSecret: settingsQuery.data.AgentSecret,
useEdgeAsyncMode: settingsQuery.data.Edge.AsyncMode,
};
}

View File

@ -205,12 +205,14 @@ function useAnalyticsState() {
dockerAgent: 0,
dockerApi: 0,
kubernetesAgent: 0,
kubernetesEdgeAgent: 0,
kubernetesEdgeAgentAsync: 0,
kubernetesEdgeAgentStandard: 0,
kaasAgent: 0,
aciApi: 0,
localEndpoint: 0,
nomadEdgeAgent: 0,
dockerEdgeAgent: 0,
nomadEdgeAgentStandard: 0,
dockerEdgeAgentAsync: 0,
dockerEdgeAgentStandard: 0,
});
return { analytics, setAnalytics };

View File

@ -1,7 +1,5 @@
import { useState } from 'react';
import { useAgentDetails } from '@/react/portainer/environments/queries/useAgentDetails';
import { CopyButton } from '@@/buttons/CopyButton';
import { Code } from '@@/Code';
import { NavTabs } from '@@/NavTabs';
@ -22,12 +20,6 @@ const deployments = [
export function DeploymentScripts() {
const [deployType, setDeployType] = useState(deployments[0].id);
const agentDetailsQuery = useAgentDetails();
if (!agentDetailsQuery) {
return null;
}
const options = deployments.map((c) => ({
id: c.id,
label: c.label,

View File

@ -1,8 +1,12 @@
import { useState } from 'react';
import { Zap, Cloud, Network, Plug2 } from 'lucide-react';
import { Zap, Network, Plug2 } from 'lucide-react';
import _ from 'lodash';
import { Environment } from '@/react/portainer/environments/types';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c';
import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelector, type BoxSelectorOption } from '@@/BoxSelector';
import { BadgeIcon } from '@@/BadgeIcon';
@ -21,8 +25,8 @@ interface Props {
}
const defaultOptions: BoxSelectorOption<
'agent' | 'api' | 'socket' | 'edgeAgent'
>[] = [
'agent' | 'api' | 'socket' | 'edgeAgentStandard' | 'edgeAgentAsync'
>[] = _.compact([
{
id: 'agent',
icon: <BadgeIcon icon={Zap} size="3xl" />,
@ -45,17 +49,28 @@ const defaultOptions: BoxSelectorOption<
value: 'socket',
},
{
id: 'edgeAgent',
icon: <BadgeIcon icon={Cloud} size="3xl" />,
label: 'Edge Agent',
id: 'edgeAgentStandard',
icon: EdgeAgentStandardIcon,
iconType: 'badge',
label: 'Edge Agent Standard',
description: '',
value: 'edgeAgent',
hide: window.ddExtension,
value: 'edgeAgentStandard',
},
];
isBE && {
id: 'edgeAgentAsync',
icon: EdgeAgentAsyncIcon,
iconType: 'badge',
label: 'Edge Agent Async',
description: '',
value: 'edgeAgentAsync',
},
]);
export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
const options = useFilterEdgeOptionsIfNeeded(defaultOptions, 'edgeAgent');
const options = useFilterEdgeOptionsIfNeeded(
defaultOptions,
'edgeAgentStandard'
);
const [creationType, setCreationType] = useState(options[0].value);
@ -74,7 +89,14 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
</div>
);
function getTab(creationType: 'agent' | 'api' | 'socket' | 'edgeAgent') {
function getTab(
creationType:
| 'agent'
| 'api'
| 'socket'
| 'edgeAgentStandard'
| 'edgeAgentAsync'
) {
switch (creationType) {
case 'agent':
return (
@ -95,10 +117,29 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) {
onCreate={(environment) => onCreate(environment, 'localEndpoint')}
/>
);
case 'edgeAgent':
case 'edgeAgentStandard':
return (
<EdgeAgentTab
onCreate={(environment) => onCreate(environment, 'dockerEdgeAgent')}
onCreate={(environment) =>
onCreate(environment, 'dockerEdgeAgentStandard')
}
commands={{
linux: isDockerStandalone
? [commandsTabs.standaloneLinux]
: [commandsTabs.swarmLinux],
win: isDockerStandalone
? [commandsTabs.standaloneWindow]
: [commandsTabs.swarmWindows],
}}
/>
);
case 'edgeAgentAsync':
return (
<EdgeAgentTab
asyncMode
onCreate={(environment) =>
onCreate(environment, 'dockerEdgeAgentAsync')
}
commands={{
linux: isDockerStandalone
? [commandsTabs.standaloneLinux]

View File

@ -1,17 +1,17 @@
import { useState } from 'react';
import { Zap, Cloud, UploadCloud } from 'lucide-react';
import { Zap, UploadCloud } from 'lucide-react';
import _ from 'lodash';
import {
Environment,
EnvironmentCreationTypes,
} from '@/react/portainer/environments/types';
import { Environment } from '@/react/portainer/environments/types';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import EdgeAgentStandardIcon from '@/react/edge/components/edge-agent-standard.svg?c';
import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
import { BoxSelectorOption } from '@@/BoxSelector/types';
import { BoxSelector } from '@@/BoxSelector';
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
import { BadgeIcon } from '@@/BadgeIcon';
import { AnalyticsStateKey } from '../types';
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
@ -24,37 +24,50 @@ interface Props {
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
}
const defaultOptions: BoxSelectorOption<EnvironmentCreationTypes>[] = [
type CreationType =
| 'agent'
| 'edgeAgentStandard'
| 'edgeAgentAsync'
| 'kubeconfig';
const defaultOptions: BoxSelectorOption<CreationType>[] = _.compact([
{
id: 'agent_endpoint',
icon: <BadgeIcon icon={Zap} size="3xl" />,
icon: Zap,
iconType: 'badge',
label: 'Agent',
value: EnvironmentCreationTypes.AgentEnvironment,
value: 'agent',
description: '',
},
{
id: 'edgeAgent',
icon: <BadgeIcon icon={Cloud} size="3xl" />,
label: 'Edge Agent',
id: 'edgeAgentStandard',
icon: EdgeAgentStandardIcon,
iconType: 'badge',
label: 'Edge Agent Standard',
description: '',
value: EnvironmentCreationTypes.EdgeAgentEnvironment,
hide: window.ddExtension,
value: 'edgeAgentStandard',
},
isBE && {
id: 'edgeAgentAsync',
icon: EdgeAgentAsyncIcon,
iconType: 'badge',
label: 'Edge Agent Async',
description: '',
value: 'edgeAgentAsync',
},
{
id: 'kubeconfig_endpoint',
icon: <BadgeIcon icon={UploadCloud} size="3xl" />,
icon: UploadCloud,
iconType: 'badge',
label: 'Import',
value: EnvironmentCreationTypes.KubeConfigEnvironment,
value: 'kubeconfig',
description: 'Import an existing Kubernetes config',
feature: FeatureId.K8S_CREATE_FROM_KUBECONFIG,
},
];
]);
export function WizardKubernetes({ onCreate }: Props) {
const options = useFilterEdgeOptionsIfNeeded(
defaultOptions,
EnvironmentCreationTypes.EdgeAgentEnvironment
);
const options = useFilterEdgeOptionsIfNeeded(defaultOptions, 'agent');
const [creationType, setCreationType] = useState(options[0].value);
@ -73,24 +86,34 @@ export function WizardKubernetes({ onCreate }: Props) {
</div>
);
function getTab(type: typeof options[number]['value']) {
function getTab(type: CreationType) {
switch (type) {
case EnvironmentCreationTypes.AgentEnvironment:
case 'agent':
return (
<AgentPanel
onCreate={(environment) => onCreate(environment, 'kubernetesAgent')}
/>
);
case EnvironmentCreationTypes.EdgeAgentEnvironment:
case 'edgeAgentStandard':
return (
<EdgeAgentTab
onCreate={(environment) =>
onCreate(environment, 'kubernetesEdgeAgent')
onCreate(environment, 'kubernetesEdgeAgentStandard')
}
commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]}
/>
);
case EnvironmentCreationTypes.KubeConfigEnvironment:
case 'edgeAgentAsync':
return (
<EdgeAgentTab
asyncMode
onCreate={(environment) =>
onCreate(environment, 'kubernetesEdgeAgentAsync')
}
commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]}
/>
);
case 'kubeconfig':
return (
<div className="border border-solid border-orange-1 px-1 py-5">
<BEFeatureIndicator

View File

@ -1,16 +1,30 @@
import { NameField } from '../../NameField';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { PortainerTunnelAddrField } from '@/react/portainer/common/PortainerTunnelAddrField';
import { PortainerUrlField } from '@/react/portainer/common/PortainerUrlField';
import { PortainerUrlField } from './PortainerUrlField';
import { NameField } from '../../NameField';
interface EdgeAgentFormProps {
readonly?: boolean;
asyncMode?: boolean;
}
export function EdgeAgentFieldset({ readonly }: EdgeAgentFormProps) {
export function EdgeAgentFieldset({ readonly, asyncMode }: EdgeAgentFormProps) {
return (
<>
<NameField readonly={readonly} />
<PortainerUrlField fieldName="portainerUrl" readonly={readonly} />
<PortainerUrlField
fieldName="portainerUrl"
readonly={readonly}
required
/>
{isBE && !asyncMode && (
<PortainerTunnelAddrField
fieldName="tunnelServerAddr"
readonly={readonly}
required
/>
)}
</>
);
}

View File

@ -3,9 +3,15 @@ import { Plug2 } from 'lucide-react';
import { Environment } from '@/react/portainer/environments/types';
import { useCreateEdgeAgentEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation';
import { baseHref } from '@/portainer/helpers/pathHelper';
import { Settings } from '@/react/portainer/settings/types';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import { useCreateEdgeDeviceParam } from '@/react/portainer/environments/wizard/hooks/useCreateEdgeDeviceParam';
import {
EdgeAsyncIntervalsForm,
EDGE_ASYNC_INTERVAL_USE_DEFAULT,
} from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { useSettings } from '@/react/portainer/settings/queries';
import { buildDefaultValue as buildTunnelDefaultValue } from '@/react/portainer/common/PortainerTunnelAddrField';
import { buildDefaultValue as buildApiUrlDefaultValue } from '@/react/portainer/common/PortainerUrlField';
import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons/LoadingButton';
@ -21,15 +27,27 @@ interface Props {
onCreate(environment: Environment): void;
readonly: boolean;
showGpus?: boolean;
asyncMode: boolean;
}
const initialValues = buildInitialValues();
export function EdgeAgentForm({ onCreate, readonly, showGpus = false }: Props) {
const createEdgeDevice = useCreateEdgeDeviceParam();
export function EdgeAgentForm({
onCreate,
readonly,
asyncMode,
showGpus = false,
}: Props) {
const settingsQuery = useSettings();
const createMutation = useCreateEdgeAgentEnvironmentMutation();
const validation = useValidationSchema();
const validation = useValidationSchema(asyncMode);
if (!settingsQuery.data) {
return null;
}
const settings = settingsQuery.data;
const initialValues = buildInitialValues(settings);
return (
<Formik<FormValues>
@ -40,15 +58,23 @@ export function EdgeAgentForm({ onCreate, readonly, showGpus = false }: Props) {
>
{({ isValid, setFieldValue, values }) => (
<Form>
<EdgeAgentFieldset readonly={readonly} />
<EdgeAgentFieldset readonly={readonly} asyncMode={asyncMode} />
<MoreSettingsSection>
<FormSection title="Check-in Intervals">
<EdgeCheckinIntervalField
readonly={readonly}
onChange={(value) => setFieldValue('pollFrequency', value)}
value={values.pollFrequency}
/>
{asyncMode ? (
<EdgeAsyncIntervalsForm
values={values.edge}
readonly={readonly}
onChange={(values) => setFieldValue('edge', values)}
/>
) : (
<EdgeCheckinIntervalField
readonly={readonly}
onChange={(value) => setFieldValue('pollFrequency', value)}
value={values.pollFrequency}
/>
)}
</FormSection>
{showGpus && <Hardware />}
</MoreSettingsSection>
@ -75,7 +101,13 @@ export function EdgeAgentForm({ onCreate, readonly, showGpus = false }: Props) {
function handleSubmit(values: typeof initialValues) {
createMutation.mutate(
{ ...values, isEdgeDevice: createEdgeDevice },
{
...values,
edge: {
...values.edge,
asyncMode,
},
},
{
onSuccess(environment) {
onCreate(environment);
@ -85,20 +117,22 @@ export function EdgeAgentForm({ onCreate, readonly, showGpus = false }: Props) {
}
}
export function buildInitialValues(): FormValues {
export function buildInitialValues(settings: Settings): FormValues {
return {
name: '',
portainerUrl: defaultPortainerUrl(),
portainerUrl: settings.EdgePortainerUrl || buildApiUrlDefaultValue(),
tunnelServerAddr:
settings.Edge.TunnelServerAddress || buildTunnelDefaultValue(),
pollFrequency: 0,
meta: {
groupId: 1,
tagIds: [],
},
edge: {
CommandInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT,
PingInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT,
SnapshotInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT,
},
gpus: [],
};
function defaultPortainerUrl() {
const baseHREF = baseHref();
return window.location.origin + (baseHREF !== '/' ? baseHREF : '');
}
}

View File

@ -1,21 +1,31 @@
import { number, object, SchemaOf } from 'yup';
import { number, object, SchemaOf, string } from 'yup';
import {
edgeAsyncIntervalsValidation,
EdgeAsyncIntervalsValues,
} from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { validation as urlValidation } from '@/react/portainer/common/PortainerTunnelAddrField';
import { validation as addressValidation } from '@/react/portainer/common/PortainerUrlField';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { metadataValidation } from '../../MetadataFieldset/validation';
import { useNameValidation } from '../../NameField';
import { validation as urlValidation } from './PortainerUrlField';
import { FormValues } from './types';
export function useValidationSchema(): SchemaOf<FormValues> {
export function useValidationSchema(asyncMode: boolean): SchemaOf<FormValues> {
const nameValidation = useNameValidation();
return object().shape({
name: nameValidation,
portainerUrl: urlValidation(),
tunnelServerAddr: asyncMode ? string() : addressValidation(),
pollFrequency: number().required(),
meta: metadataValidation(),
gpus: gpusListValidation(),
edge: isBE
? edgeAsyncIntervalsValidation()
: (null as unknown as SchemaOf<EdgeAsyncIntervalsValues>),
});
}

View File

@ -1,3 +1,4 @@
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList';
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
@ -5,7 +6,10 @@ export interface FormValues {
name: string;
portainerUrl: string;
tunnelServerAddr?: string;
pollFrequency: number;
meta: EnvironmentMetadata;
gpus: Gpu[];
edge: EdgeAsyncIntervalsValues;
}

View File

@ -16,6 +16,7 @@ interface Props {
commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>;
isNomadTokenVisible?: boolean;
showGpus?: boolean;
asyncMode?: boolean;
}
export function EdgeAgentTab({
@ -23,9 +24,9 @@ export function EdgeAgentTab({
commands,
isNomadTokenVisible,
showGpus = false,
asyncMode = false,
}: Props) {
const [edgeInfo, setEdgeInfo] = useState<EdgeInfo>();
const [formKey, clearForm] = useReducer((state) => state + 1, 0);
return (
@ -35,6 +36,7 @@ export function EdgeAgentTab({
readonly={!!edgeInfo}
key={formKey}
showGpus={showGpus}
asyncMode={asyncMode}
/>
{edgeInfo && (
@ -51,6 +53,7 @@ export function EdgeAgentTab({
edgeInfo={edgeInfo}
commands={commands}
isNomadTokenVisible={isNomadTokenVisible}
asyncMode={asyncMode}
/>
<hr />

View File

@ -1,13 +1,15 @@
export interface AnalyticsState {
dockerAgent: number;
dockerApi: number;
dockerEdgeAgent: number;
dockerEdgeAgentStandard: number;
dockerEdgeAgentAsync: number;
kubernetesAgent: number;
kubernetesEdgeAgent: number;
kubernetesEdgeAgentStandard: number;
kubernetesEdgeAgentAsync: number;
kaasAgent: number;
aciApi: number;
localEndpoint: number;
nomadEdgeAgent: number;
nomadEdgeAgentStandard: number;
}
export type AnalyticsStateKey = keyof AnalyticsState;

View File

@ -1,57 +1,29 @@
import { Field, Form, Formik } from 'formik';
import { Form, Formik } from 'formik';
import * as yup from 'yup';
import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
import { baseHref } from '@/portainer/helpers/pathHelper';
import { notifySuccess } from '@/portainer/services/notifications';
import { useUpdateSettingsMutation } from '@/react/portainer/settings/queries';
import { Settings } from '@/react/portainer/settings/types';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { Input } from '@@/form-components/Input';
import { EnabledWaitingRoomSwitch } from './EnableWaitingRoomSwitch';
interface FormValues {
EdgePortainerUrl: string;
TrustOnFirstConnect: boolean;
EnableWaitingRoom: boolean;
}
const validation = yup.object({
TrustOnFirstConnect: yup.boolean(),
EdgePortainerUrl: yup
.string()
.test(
'url',
'URL should be a valid URI and cannot include localhost',
(value) => {
if (!value) {
return false;
}
try {
const url = new URL(value);
return !!url.hostname && url.hostname !== 'localhost';
} catch {
return false;
}
}
)
.required('URL is required'),
EnableWaitingRoom: yup.boolean(),
});
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 initialValues: FormValues = {
EnableWaitingRoom: !settings.TrustOnFirstConnect,
};
const mutation = useUpdateSettingsMutation();
@ -60,24 +32,21 @@ export function AutoEnvCreationSettingsForm({ settings }: Props) {
const handleSubmit = useCallback(
(variables: Partial<FormValues>) => {
updateSettings(variables, {
onSuccess() {
notifySuccess(
'Success',
'Successfully updated Automatic Environment Creation settings'
);
},
});
updateSettings(
{ TrustOnFirstConnect: !variables.EnableWaitingRoom },
{
onSuccess() {
notifySuccess(
'Success',
'Successfully updated Automatic Environment Creation settings'
);
},
}
);
},
[updateSettings]
);
useEffect(() => {
if (!url && validation.isValidSync({ EdgePortainerUrl: defaultUrl })) {
updateSettings({ EdgePortainerUrl: defaultUrl });
}
}, [updateSettings, url]);
return (
<Formik<FormValues>
initialValues={initialValues}
@ -86,19 +55,8 @@ export function AutoEnvCreationSettingsForm({ settings }: Props) {
validateOnMount
enableReinitialize
>
{({ errors, isValid, dirty }) => (
{({ 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">
@ -107,6 +65,7 @@ export function AutoEnvCreationSettingsForm({ settings }: Props) {
loadingText="generating..."
isLoading={mutation.isLoading}
disabled={!isValid || !dirty}
className="!ml-0"
>
Save settings
</LoadingButton>
@ -117,8 +76,3 @@ export function AutoEnvCreationSettingsForm({ settings }: Props) {
</Formik>
);
}
function buildDefaultUrl() {
const baseHREF = baseHref();
return window.location.origin + (baseHREF !== '/' ? baseHREF : '');
}

View File

@ -1,69 +1,26 @@
import { useMutation } from 'react-query';
import { useEffect } from 'react';
import { Laptop } from 'lucide-react';
import { generateKey } from '@/react/portainer/environments/environment.service/edge';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { useSettings } from '../../queries';
import { AutoEnvCreationSettingsForm } from './AutoEnvCreationSettingsForm';
const commands = {
linux: [
commandsTabs.k8sLinux,
commandsTabs.swarmLinux,
commandsTabs.standaloneLinux,
commandsTabs.nomadLinux,
],
win: [commandsTabs.swarmWindows, commandsTabs.standaloneWindow],
};
export function AutomaticEdgeEnvCreation() {
const edgeKeyMutation = useGenerateKeyMutation();
const { mutate: generateKey } = edgeKeyMutation;
const settingsQuery = useSettings();
const url = settingsQuery.data?.EdgePortainerUrl;
useEffect(() => {
if (url) {
generateKey();
}
}, [generateKey, url]);
if (!settingsQuery.data) {
return null;
}
const edgeKey = edgeKeyMutation.data;
const settings = settingsQuery.data;
return (
<Widget>
<WidgetTitle icon={Laptop} title="Automatic Edge Environment Creation" />
<WidgetBody>
<AutoEnvCreationSettingsForm settings={settingsQuery.data} />
{edgeKeyMutation.isLoading ? (
<div>Generating key for {url} ... </div>
) : (
edgeKey && (
<EdgeScriptForm
edgeInfo={{ key: edgeKey }}
commands={commands}
isNomadTokenVisible
/>
)
)}
<AutoEnvCreationSettingsForm settings={settings} />
</WidgetBody>
</Widget>
);
}
// using mutation because we want this action to run only when required
function useGenerateKeyMutation() {
return useMutation(generateKey);
}

View File

@ -7,17 +7,18 @@ import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
export function EnabledWaitingRoomSwitch() {
const [inputProps, meta, helpers] = useField<boolean>('TrustOnFirstConnect');
const [inputProps, meta, helpers] = useField<boolean>('EnableWaitingRoom');
return (
<FormControl
inputId="edge_waiting_room"
label="Disable Edge Environment Waiting Room"
label="Enable Edge Environment Waiting Room"
size="medium"
errors={meta.error}
>
<Switch
id="edge_waiting_room"
name="TrustOnFirstConnect"
name="EnableWaitingRoom"
className="space-right"
checked={inputProps.value}
onChange={handleChange}
@ -25,9 +26,9 @@ export function EnabledWaitingRoomSwitch() {
</FormControl>
);
async function handleChange(trust: boolean) {
if (!trust) {
helpers.setValue(false);
async function handleChange(enable: boolean) {
if (enable) {
helpers.setValue(true);
return;
}
@ -39,6 +40,10 @@ export function EnabledWaitingRoomSwitch() {
confirmButton: buildConfirmButton('Confirm', 'danger'),
});
helpers.setValue(!!confirmed);
if (!confirmed) {
return;
}
helpers.setValue(false);
}
}

View File

@ -5,9 +5,8 @@ import { Laptop } from 'lucide-react';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { notifySuccess } from '@/portainer/services/notifications';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { FormControl } from '@@/form-components/FormControl';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons/LoadingButton';
@ -45,7 +44,6 @@ export function DeploymentSyncOptions() {
const initialValues: FormValues = {
Edge: {
AsyncMode: settingsQuery.data.Edge.AsyncMode,
CommandInterval: settingsQuery.data.Edge.CommandInterval,
PingInterval: settingsQuery.data.Edge.PingInterval,
SnapshotInterval: settingsQuery.data.Edge.SnapshotInterval,
@ -63,8 +61,13 @@ export function DeploymentSyncOptions() {
onSubmit={handleSubmit}
key={formKey}
>
{({ errors, setFieldValue, values, isValid, dirty }) => (
{({ setFieldValue, values, isValid, dirty }) => (
<Form className="form-horizontal">
<TextTip color="blue">
Default values set here will be available to choose as an
option for edge environment creation
</TextTip>
<FormSection title="Check-in Intervals">
<EdgeCheckinIntervalField
value={values.EdgeAgentCheckinInterval}
@ -77,30 +80,7 @@ export function DeploymentSyncOptions() {
/>
</FormSection>
<FormControl
inputId="edge_async_mode"
label="Use Async mode by default"
size="small"
errors={errors?.Edge?.AsyncMode}
tooltip="Using Async allows the ability to define different ping,
snapshot and command frequencies."
>
<Switch
id="edge_async_mode"
name="edge_async_mode"
className="space-right"
checked={values.Edge.AsyncMode}
onChange={(e) =>
setFieldValue('Edge.AsyncMode', e.valueOf())
}
/>
</FormControl>
<TextTip color="orange">
Enabling Async disables the tunnel function.
</TextTip>
{values.Edge.AsyncMode && (
{isBE && (
<FormSection title="Async Check-in Intervals">
<EdgeAsyncIntervalsForm
values={values.Edge}
@ -111,20 +91,19 @@ export function DeploymentSyncOptions() {
</FormSection>
)}
<FormSection title="Actions">
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
data-cy="settings-deploySyncOptionsButton"
isLoading={settingsMutation.isLoading}
loadingText="Saving settings..."
>
Save settings
</LoadingButton>
</div>
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
className="!ml-0"
data-cy="settings-deploySyncOptionsButton"
isLoading={settingsMutation.isLoading}
loadingText="Saving settings..."
>
Save settings
</LoadingButton>
</div>
</FormSection>
</div>
</Form>
)}
</Formik>

View File

@ -3,7 +3,6 @@ export interface FormValues {
PingInterval: number;
SnapshotInterval: number;
CommandInterval: number;
AsyncMode: boolean;
};
EdgeAgentCheckinInterval: number;
}

View File

@ -1,57 +0,0 @@
import { useRouter } from '@uirouter/react';
import { Plus } from 'lucide-react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { Button } from '@@/buttons';
import { openModal } from '@@/modals';
import { DeployTypePrompt } from './DeployTypePrompt';
enum DeployType {
FDO = 'FDO',
MANUAL = 'MANUAL',
}
export function AddDeviceButton() {
const router = useRouter();
const isFDOEnabledQuery = usePublicSettings({
select: (settings) => settings.IsFDOEnabled,
});
const isFDOEnabled = !!isFDOEnabledQuery.data;
return (
<Button onClick={handleNewDeviceClick} icon={Plus}>
Add Device
</Button>
);
async function handleNewDeviceClick() {
const result = await getDeployType();
switch (result) {
case DeployType.FDO:
router.stateService.go('portainer.endpoints.importDevice');
break;
case DeployType.MANUAL:
router.stateService.go('portainer.wizard.endpoints', {
edgeDevice: true,
});
break;
default:
break;
}
}
function getDeployType() {
if (!isFDOEnabled) {
return Promise.resolve(DeployType.MANUAL);
}
return askForDeployType();
}
}
function askForDeployType() {
return openModal(DeployTypePrompt, {});
}

View File

@ -1,69 +0,0 @@
import { useState } from 'react';
import { DeployType } from '@/react/nomad/jobs/JobsView/JobsDatatable/types';
import { OnSubmit } from '@@/modals';
import { Dialog } from '@@/modals/Dialog';
import { buildCancelButton, buildConfirmButton } from '@@/modals/utils';
export function DeployTypePrompt({
onSubmit,
}: {
onSubmit: OnSubmit<DeployType>;
}) {
const [deployType, setDeployType] = useState<DeployType>(DeployType.FDO);
return (
<Dialog
title="How would you like to add an Edge Device?"
message={
<>
<RadioInput
name="deployType"
value={DeployType.FDO}
label="Provision bare-metal using Intel FDO"
groupValue={deployType}
onChange={setDeployType}
/>
<RadioInput
name="deployType"
value={DeployType.MANUAL}
onChange={setDeployType}
groupValue={deployType}
label="Deploy agent manually"
/>
</>
}
buttons={[buildCancelButton(), buildConfirmButton()]}
onSubmit={(confirm) => onSubmit(confirm ? deployType : undefined)}
/>
);
}
function RadioInput<T extends number | string>({
value,
onChange,
label,
groupValue,
name,
}: {
value: T;
onChange: (value: T) => void;
label: string;
groupValue: T;
name: string;
}) {
return (
<label className="flex items-center gap-2">
<input
className="!m-0"
type="radio"
name={name}
value={value}
checked={groupValue === value}
onChange={() => onChange(value)}
/>
{label}
</label>
);
}

View File

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

View File

@ -1,19 +1,19 @@
import { Formik, Form } from 'formik';
import { Laptop } from 'lucide-react';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import { Settings } from '@/react/portainer/settings/types';
import { PortainerUrlField } from '@/react/portainer/common/PortainerUrlField';
import { PortainerTunnelAddrField } from '@/react/portainer/common/PortainerTunnelAddrField';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { FormControl } from '@@/form-components/FormControl';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { validationSchema } from './EdgeComputeSettings.validation';
import { FormValues } from './types';
import { AddDeviceButton } from './AddDeviceButton';
interface Props {
settings?: Settings;
@ -28,22 +28,16 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
const initialValues: FormValues = {
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
EdgePortainerUrl: settings.EdgePortainerUrl,
EdgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
Edge: {
TunnelServerAddress: settings.Edge.TunnelServerAddress,
},
EnforceEdgeID: settings.EnforceEdgeID,
};
return (
<div className="row">
<Widget>
<WidgetTitle
icon={Laptop}
title={
<>
<span className="mr-3">Edge Compute settings</span>
{settings.EnableEdgeComputeFeatures && <AddDeviceButton />}
</>
}
/>
<WidgetTitle icon={Laptop} title="Edge Compute settings" />
<WidgetBody>
<Formik
@ -85,10 +79,21 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
</FormControl>
<TextTip color="blue">
When enabled, this will enable Portainer to execute Edge
Device features.
Enable this setting to use Portainer Edge Compute
capabilities.
</TextTip>
{isBE && values.EnableEdgeComputeFeatures && (
<>
<PortainerUrlField
fieldName="EdgePortainerUrl"
tooltip="URL of this Portainer instance that will be used by Edge agents to initiate the communications."
/>
<PortainerTunnelAddrField fieldName="Edge.TunnelServerAddress" />
</>
)}
<FormControl
inputId="edge_enforce_id"
label="Enforce use of Portainer generated Edge ID"
@ -107,18 +112,6 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
/>
</FormControl>
<FormSectionTitle>Check-in Intervals</FormSectionTitle>
<EdgeCheckinIntervalField
value={values.EdgeAgentCheckinInterval}
onChange={(value) =>
setFieldValue('EdgeAgentCheckinInterval', value)
}
isDefaultHidden
label="Edge agent default poll frequency"
tooltip="Interval used by default by each Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
/>
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton

View File

@ -2,5 +2,7 @@ export interface FormValues {
EnableEdgeComputeFeatures: boolean;
EdgePortainerUrl: string;
EnforceEdgeID: boolean;
EdgeAgentCheckinInterval: number;
Edge: {
TunnelServerAddress: string;
};
}

View File

@ -1,6 +1,9 @@
import { Settings } from '@/react/portainer/settings/types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { EdgeComputeSettings } from './EdgeComputeSettings';
import { DeploymentSyncOptions } from './DeploymentSyncOptions/DeploymentSyncOptions';
import { AutomaticEdgeEnvCreation } from './AutomaticEdgeEnvCreation';
interface Props {
@ -13,7 +16,9 @@ export function EdgeComputeSettingsView({ settings, onSubmit }: Props) {
<div className="row">
<EdgeComputeSettings settings={settings} onSubmit={onSubmit} />
{process.env.PORTAINER_EDITION === 'BE' && <AutomaticEdgeEnvCreation />}
<DeploymentSyncOptions />
{isBE && <AutomaticEdgeEnvCreation />}
</div>
);
}