diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go
index 02334c6de..cacd332ae 100644
--- a/api/dataservices/interface.go
+++ b/api/dataservices/interface.go
@@ -95,6 +95,7 @@ type (
EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error)
EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
+ // Deprecated: Use UpdateEdgeStackFunc instead.
UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
DeleteEdgeStack(ID portainer.EdgeStackID) error
diff --git a/api/http/errors/tx.go b/api/http/errors/tx.go
new file mode 100644
index 000000000..037cb95d0
--- /dev/null
+++ b/api/http/errors/tx.go
@@ -0,0 +1,20 @@
+package errors
+
+import (
+ "errors"
+
+ httperror "github.com/portainer/libhttp/error"
+)
+
+func TxResponse(err error, validResponse func() *httperror.HandlerError) *httperror.HandlerError {
+ if err != nil {
+ var handlerError *httperror.HandlerError
+ if errors.As(err, &handlerError) {
+ return handlerError
+ }
+
+ return httperror.InternalServerError("Unexpected error", err)
+ }
+
+ return validResponse()
+}
diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go
index e64b39a96..997234eb9 100644
--- a/api/http/handler/edgestacks/edgestack_create.go
+++ b/api/http/handler/edgestacks/edgestack_create.go
@@ -38,6 +38,8 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
switch {
case httperrors.IsInvalidPayloadError(err):
return httperror.BadRequest("Invalid payload", err)
+ case httperrors.IsConflictError(err):
+ return httperror.NewError(http.StatusConflict, err.Error(), err)
default:
return httperror.InternalServerError("Unable to create Edge stack", err)
}
diff --git a/api/http/handler/edgestacks/edgestack_create_string.go b/api/http/handler/edgestacks/edgestack_create_string.go
index a3e2bc918..45ee1240e 100644
--- a/api/http/handler/edgestacks/edgestack_create_string.go
+++ b/api/http/handler/edgestacks/edgestack_create_string.go
@@ -105,7 +105,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
}
return composePath, "", projectPath, nil
-
}
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go
index 2a8910982..cc62de94e 100644
--- a/api/http/handler/edgestacks/edgestack_create_test.go
+++ b/api/http/handler/edgestacks/edgestack_create_test.go
@@ -144,7 +144,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
DeploymentType: edgeStack.DeploymentType,
},
Method: "string",
- ExpectedStatusCode: 500,
+ ExpectedStatusCode: http.StatusConflict,
},
{
Name: "Empty EdgeStack Groups",
diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go
index cf75fd637..98a5c396b 100644
--- a/api/http/handler/edgestacks/edgestack_update.go
+++ b/api/http/handler/edgestacks/edgestack_update.go
@@ -1,10 +1,10 @@
package edgestacks
import (
- "errors"
"net/http"
"strconv"
+ "github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
@@ -12,7 +12,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/edge"
- "github.com/portainer/portainer/api/internal/endpointutils"
+ "github.com/portainer/portainer/api/internal/set"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/rs/zerolog/log"
@@ -20,7 +20,7 @@ import (
type updateEdgeStackPayload struct {
StackFileContent string
- Version *int
+ UpdateVersion bool
EdgeGroups []portainer.EdgeGroupID
DeploymentType portainer.EdgeStackDeploymentType
// Uses the manifest's namespaces instead of the default one
@@ -104,72 +104,32 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
return nil, httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
}
- endpointsToAdd := map[portainer.EndpointID]bool{}
-
+ groupsIds := stack.EdgeGroups
if payload.EdgeGroups != nil {
- newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
+ newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack.ID, payload.EdgeGroups, relatedEndpointIds, relationConfig)
if err != nil {
- return nil, httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
+ return nil, httperror.InternalServerError("Unable to handle edge groups change", err)
}
- oldRelatedSet := endpointutils.EndpointSet(relatedEndpointIds)
- newRelatedSet := endpointutils.EndpointSet(newRelated)
-
- endpointsToRemove := map[portainer.EndpointID]bool{}
- for endpointID := range oldRelatedSet {
- if !newRelatedSet[endpointID] {
- endpointsToRemove[endpointID] = true
- }
- }
-
- for endpointID := range endpointsToRemove {
- relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
- if err != nil {
- return nil, httperror.InternalServerError("Unable to find environment relation in database", err)
- }
-
- delete(relation.EdgeStacks, stack.ID)
-
- err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
- if err != nil {
- return nil, httperror.InternalServerError("Unable to persist environment relation in database", err)
- }
- }
-
- for endpointID := range newRelatedSet {
- if !oldRelatedSet[endpointID] {
- endpointsToAdd[endpointID] = true
- }
- }
-
- for endpointID := range endpointsToAdd {
- relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
- if err != nil {
- return nil, httperror.InternalServerError("Unable to find environment relation in database", err)
- }
-
- relation.EdgeStacks[stack.ID] = true
-
- err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
- if err != nil {
- return nil, httperror.InternalServerError("Unable to persist environment relation in database", err)
- }
- }
-
- stack.EdgeGroups = payload.EdgeGroups
+ groupsIds = payload.EdgeGroups
relatedEndpointIds = newRelated
+
}
- if stack.DeploymentType != payload.DeploymentType {
+ entryPoint := stack.EntryPoint
+ manifestPath := stack.ManifestPath
+ deploymentType := stack.DeploymentType
+
+ if deploymentType != payload.DeploymentType {
// deployment type was changed - need to delete the old file
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
if err != nil {
log.Warn().Err(err).Msg("Unable to clear old files")
}
- stack.EntryPoint = ""
- stack.ManifestPath = ""
- stack.DeploymentType = payload.DeploymentType
+ entryPoint = ""
+ manifestPath = ""
+ deploymentType = payload.DeploymentType
}
stackFolder := strconv.Itoa(int(stack.ID))
@@ -183,52 +143,106 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
}
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
- if stack.EntryPoint == "" {
- stack.EntryPoint = filesystem.ComposeFileDefaultName
+ if entryPoint == "" {
+ entryPoint = filesystem.ComposeFileDefaultName
}
- _, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
+ _, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, []byte(payload.StackFileContent))
if err != nil {
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
- manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, stack.EntryPoint, relatedEndpointIds)
+ tempManifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, entryPoint, relatedEndpointIds)
if err != nil {
return nil, httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
}
- stack.ManifestPath = manifestPath
+ manifestPath = tempManifestPath
}
- if payload.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
- if stack.ManifestPath == "" {
- stack.ManifestPath = filesystem.ManifestFileDefaultName
+ if deploymentType == portainer.EdgeStackDeploymentKubernetes {
+ if manifestPath == "" {
+ manifestPath = filesystem.ManifestFileDefaultName
}
- stack.UseManifestNamespaces = payload.UseManifestNamespaces
-
- _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
+ _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, []byte(payload.StackFileContent))
if err != nil {
return nil, httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err)
}
}
- versionUpdated := payload.Version != nil && *payload.Version != stack.Version
- if versionUpdated {
- stack.Version = *payload.Version
- stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
- }
+ err = tx.EdgeStack().UpdateEdgeStackFunc(stack.ID, func(edgeStack *portainer.EdgeStack) {
+ edgeStack.NumDeployments = len(relatedEndpointIds)
+ if payload.UpdateVersion {
+ edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
+ edgeStack.Version++
+ }
- stack.NumDeployments = len(relatedEndpointIds)
+ edgeStack.UseManifestNamespaces = payload.UseManifestNamespaces
- if versionUpdated {
- stack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
- }
+ edgeStack.DeploymentType = deploymentType
+ edgeStack.EntryPoint = entryPoint
+ edgeStack.ManifestPath = manifestPath
- err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
+ edgeStack.EdgeGroups = groupsIds
+ })
if err != nil {
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
return stack, nil
}
+
+func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) {
+ newRelatedEnvironmentIDs, err := edge.EdgeStackRelatedEndpoints(newEdgeGroupsIDs, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
+ if err != nil {
+ return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
+ }
+
+ oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
+ newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
+
+ endpointsToRemove := set.Set[portainer.EndpointID]{}
+ for endpointID := range oldRelatedSet {
+ if !newRelatedSet[endpointID] {
+ endpointsToRemove[endpointID] = true
+ }
+ }
+
+ for endpointID := range endpointsToRemove {
+ relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
+ if err != nil {
+ return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
+ }
+
+ delete(relation.EdgeStacks, edgeStackID)
+
+ err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
+ if err != nil {
+ return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
+ }
+ }
+
+ endpointsToAdd := set.Set[portainer.EndpointID]{}
+ for endpointID := range newRelatedSet {
+ if !oldRelatedSet[endpointID] {
+ endpointsToAdd[endpointID] = true
+ }
+ }
+
+ for endpointID := range endpointsToAdd {
+ relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
+ if err != nil {
+ return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
+ }
+
+ relation.EdgeStacks[edgeStackID] = true
+
+ err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
+ if err != nil {
+ return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
+ }
+ }
+
+ return newRelatedEnvironmentIDs, endpointsToAdd, nil
+}
diff --git a/api/http/handler/edgestacks/edgestack_update_test.go b/api/http/handler/edgestacks/edgestack_update_test.go
index deccb3690..34b7359a1 100644
--- a/api/http/handler/edgestacks/edgestack_update_test.go
+++ b/api/http/handler/edgestacks/edgestack_update_test.go
@@ -54,10 +54,9 @@ func TestUpdateAndInspect(t *testing.T) {
t.Fatal(err)
}
- newVersion := 238
payload := updateEdgeStackPayload{
StackFileContent: "update-test",
- Version: &newVersion,
+ UpdateVersion: true,
EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID),
DeploymentType: portainer.EdgeStackDeploymentCompose,
}
@@ -101,7 +100,7 @@ func TestUpdateAndInspect(t *testing.T) {
t.Fatal("error decoding response:", err)
}
- if data.Version != *payload.Version {
+ if payload.UpdateVersion && data.Version != edgeStack.Version+1 {
t.Fatalf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version)
}
@@ -132,7 +131,6 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
- newVersion := 238
cases := []struct {
Name string
Payload updateEdgeStackPayload
@@ -142,7 +140,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
"Update with non-existing EdgeGroupID",
updateEdgeStackPayload{
StackFileContent: "error-test",
- Version: &newVersion,
+ UpdateVersion: true,
EdgeGroups: []portainer.EdgeGroupID{9999},
DeploymentType: edgeStack.DeploymentType,
},
@@ -152,7 +150,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
"Update with invalid EdgeGroup (non-existing Endpoint)",
updateEdgeStackPayload{
StackFileContent: "error-test",
- Version: &newVersion,
+ UpdateVersion: true,
EdgeGroups: []portainer.EdgeGroupID{2},
DeploymentType: edgeStack.DeploymentType,
},
@@ -162,7 +160,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
"Update DeploymentType from Docker to Kubernetes",
updateEdgeStackPayload{
StackFileContent: "error-test",
- Version: &newVersion,
+ UpdateVersion: true,
EdgeGroups: []portainer.EdgeGroupID{1},
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
},
@@ -200,7 +198,6 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
endpoint := createEndpoint(t, handler.DataStore)
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
- newVersion := 238
cases := []struct {
Name string
Payload updateEdgeStackPayload
@@ -210,7 +207,7 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
"Update with empty StackFileContent",
updateEdgeStackPayload{
StackFileContent: "",
- Version: &newVersion,
+ UpdateVersion: true,
EdgeGroups: edgeStack.EdgeGroups,
DeploymentType: edgeStack.DeploymentType,
},
@@ -220,7 +217,7 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
"Update with empty EdgeGroups",
updateEdgeStackPayload{
StackFileContent: "error-test",
- Version: &newVersion,
+ UpdateVersion: true,
EdgeGroups: []portainer.EdgeGroupID{},
DeploymentType: edgeStack.DeploymentType,
},
diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go
index 141eec911..b504794bc 100644
--- a/api/http/handler/edgestacks/handler.go
+++ b/api/http/handler/edgestacks/handler.go
@@ -25,6 +25,8 @@ type Handler struct {
KubernetesDeployer portainer.KubernetesDeployer
}
+const contextKey = "edgeStack_item"
+
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
h := &Handler{
diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go
index 7ad2180fe..38e0b6ddd 100644
--- a/api/internal/edge/edgestacks/service.go
+++ b/api/internal/edge/edgestacks/service.go
@@ -63,7 +63,7 @@ func validateUniqueName(edgeStacksGetter func() ([]portainer.EdgeStack, error),
for _, stack := range edgeStacks {
if strings.EqualFold(stack.Name, name) {
- return errors.New("Edge stack name must be unique")
+ return httperrors.NewConflictError("Edge stack name must be unique")
}
}
diff --git a/api/stacks/stackutils/gitops.go b/api/stacks/stackutils/gitops.go
index e80d38da7..d035b783d 100644
--- a/api/stacks/stackutils/gitops.go
+++ b/api/stacks/stackutils/gitops.go
@@ -5,13 +5,13 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/git"
gittypes "github.com/portainer/portainer/api/git/types"
)
var (
ErrStackAlreadyExists = errors.New("A stack already exists with this name")
ErrWebhookIDAlreadyExists = errors.New("A webhook ID already exists")
- ErrInvalidGitCredential = errors.New("Invalid git credential")
)
// DownloadGitRepository downloads the target git repository on the disk
@@ -28,7 +28,7 @@ func DownloadGitRepository(config gittypes.RepoConfig, gitService portainer.GitS
err := gitService.CloneRepository(projectPath, config.URL, config.ReferenceName, username, password, config.TLSSkipVerify)
if err != nil {
if errors.Is(err, gittypes.ErrAuthenticationFailure) {
- newErr := ErrInvalidGitCredential
+ newErr := git.ErrInvalidGitCredential
return "", newErr
}
diff --git a/app/edge/components/edge-job-form/edgeJobForm.html b/app/edge/components/edge-job-form/edgeJobForm.html
index 3f5828532..01f5a2be2 100644
--- a/app/edge/components/edge-job-form/edgeJobForm.html
+++ b/app/edge/components/edge-job-form/edgeJobForm.html
@@ -169,15 +169,7 @@
-
Edge Groups
-
+
Target environments
diff --git a/app/edge/components/edge-job-form/edgeJobFormController.js b/app/edge/components/edge-job-form/edgeJobFormController.js
index 68fecebb6..4c0b22aff 100644
--- a/app/edge/components/edge-job-form/edgeJobFormController.js
+++ b/app/edge/components/edge-job-form/edgeJobFormController.js
@@ -6,11 +6,9 @@ import { cronMethodOptions } from '@/react/edge/edge-jobs/CreateView/cron-method
export class EdgeJobFormController {
/* @ngInject */
- constructor($async, $scope, EdgeGroupService, Notifications) {
+ constructor($async, $scope) {
this.$scope = $scope;
this.$async = $async;
- this.EdgeGroupService = EdgeGroupService;
- this.Notifications = Notifications;
this.cronMethods = cronMethodOptions;
this.buildMethods = [editor, upload];
@@ -127,18 +125,8 @@ export class EdgeJobFormController {
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
}
- async getEdgeGroups() {
- try {
- this.edgeGroups = await this.EdgeGroupService.groups();
- this.noGroups = this.edgeGroups.length === 0;
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
- }
- }
-
$onInit() {
this.onChangeModel(this.model);
- this.getEdgeGroups();
}
}
diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html
deleted file mode 100644
index be3d8482e..000000000
--- a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html
+++ /dev/null
@@ -1,95 +0,0 @@
-
diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js
deleted file mode 100644
index a34039713..000000000
--- a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
-import { EditorType } from '@/react/edge/edge-stacks/types';
-import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
-export class EditEdgeStackFormController {
- /* @ngInject */
- constructor($scope) {
- this.$scope = $scope;
- this.state = {
- endpointTypes: [],
- readOnlyCompose: false,
- };
-
- this.fileContents = {
- 0: '',
- 1: '',
- };
-
- this.EditorType = EditorType;
-
- this.onChangeGroups = this.onChangeGroups.bind(this);
- this.onChangeFileContent = this.onChangeFileContent.bind(this);
- this.onChangeComposeConfig = this.onChangeComposeConfig.bind(this);
- this.onChangeKubeManifest = this.onChangeKubeManifest.bind(this);
- this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
- this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this);
- this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
- this.removeLineBreaks = this.removeLineBreaks.bind(this);
- this.onChangeFileContent = this.onChangeFileContent.bind(this);
- this.onChangeUseManifestNamespaces = this.onChangeUseManifestNamespaces.bind(this);
- this.selectValidDeploymentType = this.selectValidDeploymentType.bind(this);
- }
-
- onChangeUseManifestNamespaces(value) {
- this.$scope.$evalAsync(() => {
- this.model.UseManifestNamespaces = value;
- });
- }
-
- hasKubeEndpoint() {
- return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
- }
-
- hasDockerEndpoint() {
- return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
- }
-
- onChangeGroups(groups) {
- return this.$scope.$evalAsync(() => {
- this.model.EdgeGroups = groups;
- this.setEnvironmentTypesInSelection(groups);
- this.selectValidDeploymentType();
- this.state.readOnlyCompose = this.hasKubeEndpoint();
- });
- }
-
- isFormValid() {
- return this.model.EdgeGroups.length && this.model.StackFileContent && this.validateEndpointsForDeployment();
- }
-
- setEnvironmentTypesInSelection(groups) {
- const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
- this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
- }
-
- selectValidDeploymentType() {
- const validTypes = getValidEditorTypes(this.state.endpointTypes, this.allowKubeToSelectCompose);
-
- if (!validTypes.includes(this.model.DeploymentType)) {
- this.onChangeDeploymentType(validTypes[0]);
- }
- }
-
- removeLineBreaks(value) {
- return value.replace(/(\r\n|\n|\r)/gm, '');
- }
-
- onChangeFileContent(type, value) {
- const oldValue = this.fileContents[type];
- if (this.removeLineBreaks(oldValue) !== this.removeLineBreaks(value)) {
- this.model.StackFileContent = value;
- this.fileContents[type] = value;
- this.isEditorDirty = true;
- }
- }
-
- onChangeKubeManifest(value) {
- this.onChangeFileContent(1, value);
- }
-
- onChangeComposeConfig(value) {
- this.onChangeFileContent(0, value);
- }
-
- onChangeDeploymentType(deploymentType) {
- return this.$scope.$evalAsync(() => {
- this.model.DeploymentType = deploymentType;
- this.model.StackFileContent = this.fileContents[deploymentType];
- });
- }
-
- validateEndpointsForDeployment() {
- return this.model.DeploymentType == 0 || !this.hasDockerEndpoint();
- }
-
- $onInit() {
- this.setEnvironmentTypesInSelection(this.model.EdgeGroups);
- this.fileContents[this.model.DeploymentType] = this.model.StackFileContent;
-
- // allow kube to view compose if it's an existing kube compose stack
- const initiallyContainsKubeEnv = this.hasKubeEndpoint();
- const isComposeStack = this.model.DeploymentType === 0;
- this.allowKubeToSelectCompose = initiallyContainsKubeEnv && isComposeStack;
- this.state.readOnlyCompose = this.allowKubeToSelectCompose;
- this.selectValidDeploymentType();
- }
-}
diff --git a/app/edge/components/edit-edge-stack-form/index.js b/app/edge/components/edit-edge-stack-form/index.js
deleted file mode 100644
index 5f3d23628..000000000
--- a/app/edge/components/edit-edge-stack-form/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import angular from 'angular';
-
-import { EditEdgeStackFormController } from './editEdgeStackFormController';
-
-angular.module('portainer.edge').component('editEdgeStackForm', {
- templateUrl: './editEdgeStackForm.html',
- controller: EditEdgeStackFormController,
- bindings: {
- model: '<',
- actionInProgress: '<',
- submitAction: '<',
- edgeGroups: '<',
- isEditorDirty: '=',
- },
-});
diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts
index 1fe7e475b..673a0daca 100644
--- a/app/edge/react/components/index.ts
+++ b/app/edge/react/components/index.ts
@@ -7,6 +7,8 @@ import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInInt
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
+import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
export const componentsModule = angular
@@ -60,6 +62,18 @@ export const componentsModule = angular
'onChange',
'hasDockerEndpoint',
'hasKubeEndpoint',
+ 'hasNomadEndpoint',
+ 'allowKubeToSelectCompose',
+ ])
+ )
+ .component(
+ 'editEdgeStackForm',
+ r2a(withUIRouter(withReactQuery(withCurrentUser(EditEdgeStackForm))), [
+ 'edgeStack',
+ 'fileContent',
+ 'isSubmitting',
+ 'onEditorChange',
+ 'onSubmit',
'allowKubeToSelectCompose',
])
).name;
diff --git a/app/edge/services/edge-stack.js b/app/edge/services/edge-stack.js
index ecacda4d6..821026631 100644
--- a/app/edge/services/edge-stack.js
+++ b/app/edge/services/edge-stack.js
@@ -65,9 +65,5 @@ angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackS
}
};
- service.update = function update(stack) {
- return EdgeStacks.update(stack).$promise;
- };
-
return service;
});
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
index 2d4a78fa3..44047571c 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
+++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js
@@ -1,8 +1,9 @@
import { EditorType } from '@/react/edge/edge-stacks/types';
-import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
+import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
+import { EnvironmentType } from '@/react/portainer/environments/types';
export default class CreateEdgeStackViewController {
/* @ngInject */
@@ -27,6 +28,7 @@ export default class CreateEdgeStackViewController {
};
this.EditorType = EditorType;
+ this.EnvironmentType = EnvironmentType;
this.state = {
Method: 'editor',
@@ -36,6 +38,7 @@ export default class CreateEdgeStackViewController {
isEditorDirty: false,
hasKubeEndpoint: false,
endpointTypes: [],
+ baseWebhookUrl: baseEdgeStackWebhookUrl(),
};
this.edgeGroups = null;
@@ -49,8 +52,7 @@ export default class CreateEdgeStackViewController {
this.createStackFromFileUpload = this.createStackFromFileUpload.bind(this);
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
this.onChangeGroups = this.onChangeGroups.bind(this);
- this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
- this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this);
+ this.hasType = this.hasType.bind(this);
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
}
@@ -139,9 +141,11 @@ export default class CreateEdgeStackViewController {
}
checkIfEndpointTypes(groups) {
- const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
- this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
- this.selectValidDeploymentType();
+ return this.$scope.$evalAsync(() => {
+ const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
+ this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
+ this.selectValidDeploymentType();
+ });
}
selectValidDeploymentType() {
@@ -152,12 +156,8 @@ export default class CreateEdgeStackViewController {
}
}
- hasKubeEndpoint() {
- return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
- }
-
- hasDockerEndpoint() {
- return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
+ hasType(envType) {
+ return this.state.endpointTypes.includes(envType);
}
validateForm(method) {
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html
index 0c406ad96..63b8bdab5 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html
+++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html
@@ -39,12 +39,18 @@
-
+
+
+
+ There are no available deployment types when there is more than one type of environment in your edge group
+ selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
+
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html
index 93de92133..328a6ec28 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html
+++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html
@@ -21,7 +21,14 @@
You can upload a Compose file from your computer.
-
+
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html
index d8ab1fe3d..41f8c7530 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html
+++ b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html
@@ -32,4 +32,11 @@
-
+
diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html
index c70193915..341bc2640 100644
--- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html
+++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html
@@ -12,11 +12,14 @@
diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js
index dc79edab8..255c51c54 100644
--- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js
+++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js
@@ -1,6 +1,8 @@
import _ from 'lodash-es';
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
+import { EnvironmentType } from '@/react/portainer/environments/types';
+import { createWebhookId } from '@/portainer/helpers/webhookHelper';
export class EditEdgeStackViewController {
/* @ngInject */
@@ -18,56 +20,74 @@ export class EditEdgeStackViewController {
this.state = {
actionInProgress: false,
activeTab: 0,
- isEditorDirty: false,
+ isStackDeployed: false,
+ };
+
+ this.formValues = {
+ content: '',
};
this.deployStack = this.deployStack.bind(this);
this.deployStackAsync = this.deployStackAsync.bind(this);
this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this);
this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this);
+ this.onEditorChange = this.onEditorChange.bind(this);
+ this.isEditorDirty = this.isEditorDirty.bind(this);
}
async $onInit() {
- const { stackId, tab } = this.$state.params;
- this.state.activeTab = tab;
- try {
- const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]);
- this.edgeGroups = edgeGroups;
- this.stack = model;
- this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups);
- this.originalFileContent = file;
- this.formValues = {
- StackFileContent: file,
- EdgeGroups: this.stack.EdgeGroups,
- UseManifestNamespaces: this.stack.UseManifestNamespaces,
- DeploymentType: this.stack.DeploymentType,
- };
- this.oldFileContent = this.formValues.StackFileContent;
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve stack data');
- }
+ return this.$async(async () => {
+ const { stackId, tab } = this.$state.params;
+ this.state.activeTab = tab;
+ try {
+ const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]);
- this.$window.onbeforeunload = () => {
- if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) {
- return '';
+ this.edgeGroups = edgeGroups;
+ this.stack = model;
+ this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups);
+ this.originalFileContent = file;
+ this.formValues = {
+ content: file,
+ };
+
+ const stackEdgeGroups = model.EdgeGroups.map((id) => this.edgeGroups.find((e) => e.Id === id));
+ const endpointTypes = stackEdgeGroups.flatMap((group) => group.EndpointTypes);
+ const initiallyContainsKubeEnv = endpointTypes.includes(EnvironmentType.EdgeAgentOnKubernetes);
+ const isComposeStack = this.stack.DeploymentType === 0;
+
+ this.allowKubeToSelectCompose = initiallyContainsKubeEnv && isComposeStack;
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to retrieve stack data');
}
- };
+
+ this.oldFileContent = this.formValues.StackFileContent;
+
+ this.$window.onbeforeunload = () => {
+ if (this.isEditorDirty()) {
+ return '';
+ }
+ };
+ });
}
$onDestroy() {
- this.state.isEditorDirty = false;
+ this.$window.onbeforeunload = undefined;
}
async uiCanExit() {
- if (
- this.formValues.StackFileContent &&
- this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') &&
- this.state.isEditorDirty
- ) {
+ if (this.isEditorDirty()) {
return confirmWebEditorDiscard();
}
}
+ onEditorChange(content) {
+ this.formValues.content = content;
+ }
+
+ isEditorDirty() {
+ return !this.state.isStackDeployed && this.formValues.content.replace(/(\r\n|\n|\r)/gm, '') !== this.originalFileContent.replace(/(\r\n|\n|\r)/gm, '');
+ }
+
filterStackEndpoints(groupIds, groups) {
return _.flatten(
_.map(groupIds, (Id) => {
@@ -77,19 +97,24 @@ export class EditEdgeStackViewController {
);
}
- deployStack() {
- return this.$async(this.deployStackAsync);
+ deployStack(values) {
+ return this.deployStackAsync(values);
}
- async deployStackAsync() {
+ async deployStackAsync(values) {
this.state.actionInProgress = true;
try {
- if (this.originalFileContent != this.formValues.StackFileContent || this.formValues.UseManifestNamespaces !== this.stack.UseManifestNamespaces) {
- this.formValues.Version = this.stack.Version + 1;
- }
- await this.EdgeStackService.updateStack(this.stack.Id, this.formValues);
+ const updateVersion = !!(this.originalFileContent != values.content || values.useManifestNamespaces !== this.stack.UseManifestNamespaces);
+
+ await this.EdgeStackService.updateStack(this.stack.Id, {
+ stackFileContent: values.content,
+ edgeGroups: values.edgeGroups,
+ deploymentType: values.deploymentType,
+ updateVersion,
+ webhook: values.webhookEnabled ? this.stack.Webhook || createWebhookId() : '',
+ });
this.Notifications.success('Success', 'Stack successfully deployed');
- this.state.isEditorDirty = false;
+ this.state.isStackDeployed = true;
this.$state.go('edge.stacks');
} catch (err) {
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
diff --git a/app/portainer/helpers/webhookHelper.ts b/app/portainer/helpers/webhookHelper.ts
index a6dc7fb49..b0ff5f661 100644
--- a/app/portainer/helpers/webhookHelper.ts
+++ b/app/portainer/helpers/webhookHelper.ts
@@ -1,6 +1,10 @@
import uuid from 'uuid';
-import { API_ENDPOINT_STACKS, API_ENDPOINT_WEBHOOKS } from '@/constants';
+import {
+ API_ENDPOINT_EDGE_STACKS,
+ API_ENDPOINT_STACKS,
+ API_ENDPOINT_WEBHOOKS,
+} from '@/constants';
import { baseHref } from './pathHelper';
@@ -22,6 +26,10 @@ export function createWebhookId() {
return uuid();
}
+export function baseEdgeStackWebhookUrl() {
+ return `${baseUrl}${API_ENDPOINT_EDGE_STACKS}/webhooks`;
+}
+
/* @ngInject */
export function WebhookHelperFactory() {
return {
diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js
index 02f85654d..79abfcd19 100644
--- a/app/portainer/services/authentication.js
+++ b/app/portainer/services/authentication.js
@@ -130,6 +130,10 @@ angular.module('portainer.app').factory('Authentication', [
return !!user && user.role === 1;
}
+ if (process.env.NODE_ENV === 'development') {
+ window.login = loginAsync;
+ }
+
return service;
},
]);
diff --git a/app/react/components/Tip/TextTip/TextTip.tsx b/app/react/components/Tip/TextTip/TextTip.tsx
index a19e12280..97c914c61 100644
--- a/app/react/components/Tip/TextTip/TextTip.tsx
+++ b/app/react/components/Tip/TextTip/TextTip.tsx
@@ -4,7 +4,7 @@ import clsx from 'clsx';
import { Icon, IconMode } from '@@/Icon';
-type Color = 'orange' | 'blue';
+type Color = 'orange' | 'blue' | 'red' | 'green';
export interface Props {
icon?: React.ReactNode;
@@ -33,6 +33,10 @@ function getMode(color: Color): IconMode {
switch (color) {
case 'blue':
return 'primary';
+ case 'red':
+ return 'danger';
+ case 'green':
+ return 'success';
case 'orange':
default:
return 'warning';
diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx
new file mode 100644
index 000000000..fe35f0597
--- /dev/null
+++ b/app/react/components/WebEditorForm.tsx
@@ -0,0 +1,111 @@
+import { PropsWithChildren } from 'react';
+
+import { BROWSER_OS_PLATFORM } from '@/react/constants';
+
+import { CodeEditor } from '@@/CodeEditor';
+import { Tooltip } from '@@/Tip/Tooltip';
+
+import { FormSectionTitle } from './form-components/FormSectionTitle';
+
+const otherEditorConfig = {
+ tooltip: (
+ <>
+
Ctrl+F - Start searching
+
Ctrl+G - Find next
+
Ctrl+Shift+G - Find previous
+
Ctrl+Shift+F - Replace
+
Ctrl+Shift+R - Replace all
+
Alt+G - Jump to line
+
Persistent search:
+
Enter - Find next
+
Shift+Enter - Find previous
+ >
+ ),
+ searchCmdLabel: 'Ctrl+F for search',
+} as const;
+
+const editorConfig = {
+ mac: {
+ tooltip: (
+ <>
+
Cmd+F - Start searching
+
Cmd+G - Find next
+
Cmd+Shift+G - Find previous
+
Cmd+Option+F - Replace
+
Cmd+Option+R - Replace all
+
Option+G - Jump to line
+
Persistent search:
+
Enter - Find next
+
Shift+Enter - Find previous
+ >
+ ),
+ searchCmdLabel: 'Cmd+F for search',
+ },
+
+ lin: otherEditorConfig,
+ win: otherEditorConfig,
+} as const;
+
+interface Props {
+ value: string;
+ onChange: (value: string) => void;
+
+ id: string;
+ placeholder?: string;
+ yaml?: boolean;
+ readonly?: boolean;
+ hideTitle?: boolean;
+ error?: string;
+}
+
+export function WebEditorForm({
+ id,
+ onChange,
+ placeholder,
+ value,
+ hideTitle,
+ readonly,
+ yaml,
+ children,
+ error,
+}: PropsWithChildren
) {
+ return (
+
+
+ {!hideTitle && (
+
+ Web editor
+
+ {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
+
+
+
+
+ )}
+
+ {children && (
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx b/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx
index ea26c517d..024af8454 100644
--- a/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx
+++ b/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
-import { Registry } from '@/react/portainer/environments/environment.service/registries';
+import { Registry } from '@/react/portainer/registries/types';
import { Modal, OnSubmit, openModal } from '@@/modals';
import { Button } from '@@/buttons';
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
new file mode 100644
index 000000000..28776e35e
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
@@ -0,0 +1,79 @@
+import { useFormikContext } from 'formik';
+
+import { TextTip } from '@@/Tip/TextTip';
+import { WebEditorForm } from '@@/WebEditorForm';
+
+import { DeploymentType } from '../../types';
+
+import { FormValues } from './types';
+
+export function ComposeForm({
+ handleContentChange,
+ hasKubeEndpoint,
+}: {
+ hasKubeEndpoint: boolean;
+ handleContentChange: (type: DeploymentType, content: string) => void;
+}) {
+ const { errors, values } = useFormikContext();
+
+ return (
+ <>
+ {hasKubeEndpoint && (
+
+
+ Portainer no longer supports{' '}
+
+ docker-compose
+ {' '}
+ format manifests for Kubernetes deployments, and we have removed the{' '}
+
+ Kompose
+ {' '}
+ conversion tool which enables this. The reason for this is because
+ Kompose now poses a security risk, since it has a number of Common
+ Vulnerabilities and Exposures (CVEs).
+
+
+ Unfortunately, while the Kompose project has a maintainer and is
+ part of the CNCF, it is not being actively maintained. Releases are
+ very infrequent and new pull requests to the project (including ones
+ we've submitted) are taking months to be merged, with new CVEs
+ arising in the meantime.
+
+
+ We advise installing your own instance of Kompose in a sandbox
+ environment, performing conversions of your Docker Compose files to
+ Kubernetes manifests and using those manifests to set up
+ applications.
+
+
+ )}
+
+ handleContentChange(DeploymentType.Compose, value)}
+ error={errors.content}
+ readonly={hasKubeEndpoint}
+ >
+
+
+ >
+ );
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx
new file mode 100644
index 000000000..24d277547
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx
@@ -0,0 +1,267 @@
+import { Form, Formik, useFormikContext } from 'formik';
+import { useState } from 'react';
+import { array, boolean, number, object, SchemaOf, string } from 'yup';
+
+import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
+import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
+import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
+import { EnvironmentType } from '@/react/portainer/environments/types';
+import { WebhookSettings } from '@/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings';
+import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
+import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
+
+import { FormSection } from '@@/form-components/FormSection';
+import { TextTip } from '@@/Tip/TextTip';
+import { SwitchField } from '@@/form-components/SwitchField';
+import { LoadingButton } from '@@/buttons';
+import { FormError } from '@@/form-components/FormError';
+
+import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper';
+import { FormValues } from './types';
+import { ComposeForm } from './ComposeForm';
+import { KubernetesForm } from './KubernetesForm';
+import { NomadForm } from './NomadForm';
+import { GitForm } from './GitForm';
+import { useValidateEnvironmentTypes } from './useEdgeGroupHasType';
+import { atLeastTwo } from './atLeastTwo';
+
+interface Props {
+ edgeStack: EdgeStack;
+ isSubmitting: boolean;
+ onSubmit: (values: FormValues) => void;
+ onEditorChange: (content: string) => void;
+ fileContent: string;
+ allowKubeToSelectCompose: boolean;
+}
+
+const forms = {
+ [DeploymentType.Compose]: ComposeForm,
+ [DeploymentType.Kubernetes]: KubernetesForm,
+ [DeploymentType.Nomad]: NomadForm,
+};
+
+export function EditEdgeStackForm({
+ isSubmitting,
+ edgeStack,
+ onSubmit,
+ onEditorChange,
+ fileContent,
+ allowKubeToSelectCompose,
+}: Props) {
+ if (edgeStack.GitConfig) {
+ return ;
+ }
+
+ const formValues: FormValues = {
+ edgeGroups: edgeStack.EdgeGroups,
+ deploymentType: edgeStack.DeploymentType,
+ privateRegistryId: edgeStack.Registries?.[0],
+ content: fileContent,
+ useManifestNamespaces: edgeStack.UseManifestNamespaces,
+ prePullImage: edgeStack.PrePullImage,
+ retryDeploy: edgeStack.RetryDeploy,
+ webhookEnabled: !!edgeStack.Webhook,
+ };
+
+ return (
+
+
+
+ );
+}
+
+function InnerForm({
+ onEditorChange,
+ edgeStack,
+ isSubmitting,
+ allowKubeToSelectCompose,
+}: {
+ edgeStack: EdgeStack;
+ isSubmitting: boolean;
+ onEditorChange: (content: string) => void;
+ allowKubeToSelectCompose: boolean;
+}) {
+ const {
+ values,
+ setFieldValue,
+ isValid,
+
+ errors,
+ setFieldError,
+ } = useFormikContext();
+ const { getCachedContent, setContentCache } = useCachedContent();
+ const { hasType } = useValidateEnvironmentTypes(values.edgeGroups);
+
+ const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
+ const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
+ const hasNomadEndpoint = hasType(EnvironmentType.EdgeAgentOnNomad);
+
+ const DeploymentForm = forms[values.deploymentType];
+
+ return (
+
+ );
+
+ function handleContentChange(type: DeploymentType, content: string) {
+ setFieldValue('content', content);
+ setContentCache(type, content);
+ onEditorChange(content);
+ }
+}
+
+function useCachedContent() {
+ const [cachedContent, setCachedContent] = useState({
+ [DeploymentType.Compose]: '',
+ [DeploymentType.Kubernetes]: '',
+ [DeploymentType.Nomad]: '',
+ });
+
+ function handleChangeContent(type: DeploymentType, content: string) {
+ setCachedContent((cache) => ({ ...cache, [type]: content }));
+ }
+
+ return {
+ setContentCache: handleChangeContent,
+ getCachedContent: (type: DeploymentType) => cachedContent[type],
+ };
+}
+
+function formValidation(): SchemaOf {
+ return object({
+ content: string().required('Content is required'),
+ deploymentType: number()
+ .oneOf([0, 1, 2])
+ .required('Deployment type is required'),
+ privateRegistryId: number().optional(),
+ prePullImage: boolean().default(false),
+ retryDeploy: boolean().default(false),
+ useManifestNamespaces: boolean().default(false),
+ edgeGroups: array()
+ .of(number().required())
+ .required()
+ .min(1, 'At least one edge group is required'),
+ webhookEnabled: boolean().default(false),
+ });
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx
new file mode 100644
index 000000000..cce19747c
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx
@@ -0,0 +1,279 @@
+import { Form, Formik, useFormikContext } from 'formik';
+import { useRouter } from '@uirouter/react';
+
+import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
+import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
+import {
+ parseAutoUpdateResponse,
+ transformAutoUpdateViewModel,
+} from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
+import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
+import { RefField } from '@/react/portainer/gitops/RefField';
+import { AutoUpdateModel, GitAuthModel } from '@/react/portainer/gitops/types';
+import {
+ baseEdgeStackWebhookUrl,
+ createWebhookId,
+} from '@/portainer/helpers/webhookHelper';
+import {
+ parseAuthResponse,
+ transformGitAuthenticationViewModel,
+} from '@/react/portainer/gitops/AuthFieldset/utils';
+import { EdgeGroup } from '@/react/edge/edge-groups/types';
+import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
+import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
+import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
+import { useCurrentUser } from '@/react/hooks/useUser';
+import { useCreateGitCredentialMutation } from '@/react/portainer/account/git-credentials/git-credentials.service';
+import { notifyError, notifySuccess } from '@/portainer/services/notifications';
+import { EnvironmentType } from '@/react/portainer/environments/types';
+
+import { LoadingButton } from '@@/buttons';
+import { FormSection } from '@@/form-components/FormSection';
+import { TextTip } from '@@/Tip/TextTip';
+import { FormError } from '@@/form-components/FormError';
+
+import { useValidateEnvironmentTypes } from '../useEdgeGroupHasType';
+import { atLeastTwo } from '../atLeastTwo';
+
+import { useUpdateEdgeStackGitMutation } from './useUpdateEdgeStackGitMutation';
+
+interface FormValues {
+ groupIds: EdgeGroup['Id'][];
+ deploymentType: DeploymentType;
+ autoUpdate: AutoUpdateModel;
+ refName: string;
+ authentication: GitAuthModel;
+}
+
+export function GitForm({ stack }: { stack: EdgeStack }) {
+ const router = useRouter();
+ const updateStackMutation = useUpdateEdgeStackGitMutation();
+ const saveCredentialsMutation = useCreateGitCredentialMutation();
+ const { user } = useCurrentUser();
+
+ if (!stack.GitConfig) {
+ return null;
+ }
+
+ const gitConfig = stack.GitConfig;
+
+ const initialValues: FormValues = {
+ groupIds: stack.EdgeGroups,
+ deploymentType: stack.DeploymentType,
+ autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
+ refName: stack.GitConfig.ReferenceName,
+ authentication: parseAuthResponse(stack.GitConfig.Authentication),
+ };
+
+ const webhookId = stack.AutoUpdate?.Webhook || createWebhookId();
+
+ return (
+
+ {({ values, isValid }) => {
+ return (
+
+ );
+
+ async function handleUpdateSettings() {
+ if (!isValid) {
+ return;
+ }
+
+ const credentialId = await saveCredentialsIfRequired(
+ values.authentication
+ );
+
+ updateStackMutation.mutate(getPayload(values, credentialId, false), {
+ onSuccess() {
+ notifySuccess('Success', 'Stack updated successfully');
+ router.stateService.reload();
+ },
+ });
+ }
+ }}
+
+ );
+
+ async function handleSubmit(values: FormValues) {
+ const credentialId = await saveCredentialsIfRequired(values.authentication);
+
+ updateStackMutation.mutate(getPayload(values, credentialId, true), {
+ onSuccess() {
+ notifySuccess('Success', 'Stack updated successfully');
+ router.stateService.reload();
+ },
+ });
+ }
+
+ function getPayload(
+ { authentication, autoUpdate, ...values }: FormValues,
+ credentialId: number | undefined,
+ updateVersion: boolean
+ ) {
+ return {
+ updateVersion,
+ id: stack.Id,
+ authentication: transformGitAuthenticationViewModel({
+ ...authentication,
+ RepositoryGitCredentialID: credentialId,
+ }),
+ autoUpdate: transformAutoUpdateViewModel(autoUpdate, webhookId),
+ ...values,
+ };
+ }
+
+ async function saveCredentialsIfRequired(authentication: GitAuthModel) {
+ if (
+ !authentication.SaveCredential ||
+ !authentication.RepositoryPassword ||
+ !authentication.NewCredentialName
+ ) {
+ return authentication.RepositoryGitCredentialID;
+ }
+
+ try {
+ const credential = await saveCredentialsMutation.mutateAsync({
+ userId: user.Id,
+ username: authentication.RepositoryUsername,
+ password: authentication.RepositoryPassword,
+ name: authentication.NewCredentialName,
+ });
+ return credential.id;
+ } catch (err) {
+ notifyError('Error', err as Error, 'Unable to save credentials');
+ return undefined;
+ }
+ }
+}
+
+function InnerForm({
+ gitUrl,
+ gitPath,
+ isLoading,
+ isUpdateVersion,
+ onUpdateSettingsClick,
+ webhookId,
+}: {
+ gitUrl: string;
+ gitPath: string;
+
+ isLoading: boolean;
+ isUpdateVersion: boolean;
+ onUpdateSettingsClick(): void;
+ webhookId: string;
+}) {
+ const { values, setFieldValue, isValid, handleSubmit, errors, dirty } =
+ useFormikContext();
+
+ const { hasType } = useValidateEnvironmentTypes(values.groupIds);
+
+ const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
+ const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
+ const hasNomadEndpoint = hasType(EnvironmentType.EdgeAgentOnNomad);
+
+ return (
+
+ );
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/index.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/index.ts
new file mode 100644
index 000000000..9b69bdbc8
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/index.ts
@@ -0,0 +1 @@
+export { GitForm } from './GitForm';
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts
new file mode 100644
index 000000000..2416c0934
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts
@@ -0,0 +1,39 @@
+import { useMutation } from 'react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { mutationOptions, withError } from '@/react-tools/react-query';
+import {
+ AutoUpdateResponse,
+ GitAuthenticationResponse,
+} from '@/react/portainer/gitops/types';
+import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl';
+import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
+import { EdgeGroup } from '@/react/edge/edge-groups/types';
+
+interface UpdateEdgeStackGitPayload {
+ id: EdgeStack['Id'];
+ autoUpdate: AutoUpdateResponse | null;
+ refName: string;
+ authentication: GitAuthenticationResponse | null;
+ groupIds: EdgeGroup['Id'][];
+ deploymentType: DeploymentType;
+ updateVersion: boolean;
+}
+
+export function useUpdateEdgeStackGitMutation() {
+ return useMutation(
+ updateEdgeStackGit,
+ mutationOptions(withError('Failed updating stack'))
+ );
+}
+
+async function updateEdgeStackGit({
+ id,
+ ...payload
+}: UpdateEdgeStackGitPayload) {
+ try {
+ await axios.put(buildUrl(id, 'git'), payload);
+ } catch (err) {
+ throw parseAxiosError(err as Error, 'Failed updating stack');
+ }
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx
new file mode 100644
index 000000000..aab1504a3
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx
@@ -0,0 +1,54 @@
+import { useFormikContext } from 'formik';
+
+import { SwitchField } from '@@/form-components/SwitchField';
+import { WebEditorForm } from '@@/WebEditorForm';
+
+import { DeploymentType } from '../../types';
+
+import { FormValues } from './types';
+
+export function KubernetesForm({
+ handleContentChange,
+}: {
+ handleContentChange: (type: DeploymentType, content: string) => void;
+}) {
+ const { errors, values, setFieldValue } = useFormikContext();
+
+ return (
+ <>
+
+
+ setFieldValue('useManifestNamespaces', value)}
+ />
+
+
+
+
+ handleContentChange(DeploymentType.Kubernetes, value)
+ }
+ error={errors.content}
+ >
+
+ You can get more information about Kubernetes file format in the{' '}
+
+ official documentation
+
+ .
+
+
+ >
+ );
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NomadForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NomadForm.tsx
new file mode 100644
index 000000000..9a526e159
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NomadForm.tsx
@@ -0,0 +1,38 @@
+import { useFormikContext } from 'formik';
+
+import { WebEditorForm } from '@@/WebEditorForm';
+
+import { DeploymentType } from '../../types';
+
+import { FormValues } from './types';
+
+export function NomadForm({
+ handleContentChange,
+}: {
+ handleContentChange: (type: DeploymentType, content: string) => void;
+}) {
+ const { errors, values } = useFormikContext();
+
+ return (
+ handleContentChange(DeploymentType.Nomad, value)}
+ error={errors.content}
+ >
+
+ You can get more information about Nomad file format in the{' '}
+
+ official documentation
+
+ .
+
+
+ );
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx
new file mode 100644
index 000000000..3eb58cadc
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx
@@ -0,0 +1,80 @@
+import _ from 'lodash';
+
+import { notifyError } from '@/portainer/services/notifications';
+import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
+import { useCreateStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateStackFromFileContent';
+import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
+
+import { FormValues } from './types';
+
+export function PrivateRegistryFieldsetWrapper({
+ value,
+ isValid,
+ error,
+ onChange,
+ values,
+ stackName,
+ onFieldError,
+}: {
+ value: FormValues['privateRegistryId'];
+ isValid: boolean;
+ error?: string;
+ onChange: (value?: number) => void;
+ values: FormValues;
+ stackName: string;
+ onFieldError: (message: string) => void;
+}) {
+ const dryRunMutation = useCreateStackFromFileContent();
+
+ const registriesQuery = useRegistries();
+
+ if (!registriesQuery.data) {
+ return null;
+ }
+
+ return (
+ matchRegistry()}
+ onSelect={(value) => onChange(value)}
+ isActive={!!value}
+ clearRegistries={() => onChange(undefined)}
+ />
+ );
+
+ async function matchRegistry() {
+ try {
+ const response = await dryRunMutation.mutateAsync({
+ name: `${stackName}-dryrun`,
+ stackFileContent: values.content,
+ edgeGroups: values.edgeGroups,
+ deploymentType: values.deploymentType,
+ dryRun: true,
+ });
+
+ if (response.Registries.length === 0) {
+ onChange(undefined);
+ return;
+ }
+
+ const validRegistry = onlyOne(response.Registries);
+ if (validRegistry) {
+ onChange(response.Registries[0]);
+ } else {
+ onChange(undefined);
+ onFieldError(
+ 'Images need to be from a single registry, please edit and reload'
+ );
+ }
+ } catch (err) {
+ notifyError('Failure', err as Error, 'Unable to retrieve registries');
+ }
+ }
+
+ function onlyOne(arr: Array) {
+ return _.uniq(arr).length === 1;
+ }
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/atLeastTwo.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/atLeastTwo.ts
new file mode 100644
index 000000000..d99d90f2f
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/atLeastTwo.ts
@@ -0,0 +1,3 @@
+export function atLeastTwo(a: boolean, b: boolean, c: boolean) {
+ return (a && b) || (a && c) || (b && c);
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts
new file mode 100644
index 000000000..85b6328cd
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts
@@ -0,0 +1,13 @@
+import { EdgeGroup } from '@/react/edge/edge-groups/types';
+import { DeploymentType } from '@/react/edge/edge-stacks/types';
+
+export interface FormValues {
+ edgeGroups: EdgeGroup['Id'][];
+ deploymentType: DeploymentType;
+ privateRegistryId?: number;
+ content: string;
+ useManifestNamespaces: boolean;
+ prePullImage: boolean;
+ retryDeploy: boolean;
+ webhookEnabled: boolean;
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts
new file mode 100644
index 000000000..dd5910837
--- /dev/null
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts
@@ -0,0 +1,26 @@
+import _ from 'lodash';
+import { useCallback } from 'react';
+
+import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
+import { EdgeGroup } from '@/react/edge/edge-groups/types';
+import { EnvironmentType } from '@/react/portainer/environments/types';
+
+export function useValidateEnvironmentTypes(groupIds: Array) {
+ const edgeGroupsQuery = useEdgeGroups();
+
+ const edgeGroups = edgeGroupsQuery.data || [];
+
+ const modelEdgeGroups = _.compact(
+ groupIds.map((id) => edgeGroups.find((e) => e.Id === id))
+ );
+ const endpointTypes = modelEdgeGroups.flatMap((group) => group.EndpointTypes);
+
+ const hasType = useCallback(
+ (type: EnvironmentType) => endpointTypes.includes(type),
+ [endpointTypes]
+ );
+
+ return {
+ hasType,
+ };
+}
diff --git a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx
index 85a2423fe..c2376c3dc 100644
--- a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx
+++ b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx
@@ -1,4 +1,8 @@
+import _ from 'lodash';
+
import { EditorType } from '@/react/edge/edge-stacks/types';
+import NomadIcon from '@/assets/ico/vendor/nomad.svg?c';
+import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { BoxSelector } from '@@/BoxSelector';
import { BoxSelectorOption } from '@@/BoxSelector/types';
@@ -12,6 +16,7 @@ interface Props {
onChange(value: number): void;
hasDockerEndpoint: boolean;
hasKubeEndpoint: boolean;
+ hasNomadEndpoint: boolean;
allowKubeToSelectCompose?: boolean;
}
@@ -20,29 +25,45 @@ export function EdgeStackDeploymentTypeSelector({
onChange,
hasDockerEndpoint,
hasKubeEndpoint,
+ hasNomadEndpoint,
allowKubeToSelectCompose,
}: Props) {
- const deploymentOptions: BoxSelectorOption[] = [
+ const deploymentOptions: BoxSelectorOption[] = _.compact([
{
...compose,
value: EditorType.Compose,
- disabled: () => (allowKubeToSelectCompose ? false : hasKubeEndpoint),
+ disabled: () =>
+ allowKubeToSelectCompose
+ ? hasNomadEndpoint
+ : hasNomadEndpoint || hasKubeEndpoint,
tooltip: () =>
- hasKubeEndpoint
- ? 'Cannot use this option with Edge Kubernetes environments'
+ hasNomadEndpoint || hasKubeEndpoint
+ ? 'Cannot use this option with Edge Kubernetes or Edge Nomad environments'
: '',
},
{
...kubernetes,
value: EditorType.Kubernetes,
- disabled: () => hasDockerEndpoint,
+ disabled: () => hasDockerEndpoint || hasNomadEndpoint,
tooltip: () =>
- hasDockerEndpoint
- ? 'Cannot use this option with Edge Docker environments'
+ hasDockerEndpoint || hasNomadEndpoint
+ ? 'Cannot use this option with Edge Docker or Edge Nomad environments'
: '',
iconType: 'logo',
},
- ];
+ isBE && {
+ id: 'deployment_nomad',
+ icon: NomadIcon,
+ label: 'Nomad',
+ description: 'Nomad HCL format',
+ value: EditorType.Nomad,
+ disabled: () => hasDockerEndpoint || hasKubeEndpoint,
+ tooltip: () =>
+ hasDockerEndpoint || hasKubeEndpoint
+ ? 'Cannot use this option with Edge Docker or Edge Kubernetes environments'
+ : '',
+ },
+ ]);
return (
<>
diff --git a/app/react/edge/edge-stacks/components/PrivateRegistryFieldset.tsx b/app/react/edge/edge-stacks/components/PrivateRegistryFieldset.tsx
new file mode 100644
index 000000000..6a50d3e19
--- /dev/null
+++ b/app/react/edge/edge-stacks/components/PrivateRegistryFieldset.tsx
@@ -0,0 +1,106 @@
+import { useState, useEffect } from 'react';
+
+import { Registry } from '@/react/portainer/registries/types';
+
+import { Select } from '@@/form-components/ReactSelect';
+import { FormControl } from '@@/form-components/FormControl';
+import { Button } from '@@/buttons';
+import { FormError } from '@@/form-components/FormError';
+import { SwitchField } from '@@/form-components/SwitchField';
+import { TextTip } from '@@/Tip/TextTip';
+import { FormSection } from '@@/form-components/FormSection';
+
+interface Props {
+ value?: number;
+ registries: Registry[];
+ onChange: () => void;
+ formInvalid?: boolean;
+ errorMessage?: string;
+ onSelect: (value?: number) => void;
+ isActive?: boolean;
+ clearRegistries: () => void;
+ method?: string;
+}
+
+export function PrivateRegistryFieldset({
+ value,
+ registries,
+ onChange,
+ formInvalid,
+ errorMessage,
+ onSelect,
+ isActive,
+ clearRegistries,
+ method,
+}: Props) {
+ const [checked, setChecked] = useState(isActive || false);
+ const [selected, setSelected] = useState(value);
+
+ const tooltipMessage =
+ 'Use this when using a private registry that requires credentials';
+
+ useEffect(() => {
+ if (checked) {
+ onChange();
+ } else {
+ clearRegistries();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [checked]);
+
+ useEffect(() => {
+ setSelected(value);
+ }, [value]);
+
+ function reload() {
+ onChange();
+ setSelected(value);
+ }
+
+ return (
+
+
+
+ setChecked(value)}
+ tooltip={tooltipMessage}
+ label="Use Credentials"
+ labelClass="col-sm-3 col-lg-2"
+ disabled={formInvalid}
+ />
+
+
+
+ {checked && (
+ <>
+ {method !== 'repository' && (
+ <>
+
+ If you make any changes to the image urls in your yaml, please
+ reload or select registry manually
+
+
+ Reload
+ >
+ )}
+ {!errorMessage ? (
+
+ registry.Id === selected
+ )}
+ options={registries}
+ getOptionLabel={(registry) => registry.Name}
+ getOptionValue={(registry) => registry.Id.toString()}
+ onChange={(value) => onSelect(value?.Id)}
+ />
+
+ ) : (
+ {errorMessage}
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/app/react/edge/edge-stacks/queries/buildUrl.ts b/app/react/edge/edge-stacks/queries/buildUrl.ts
new file mode 100644
index 000000000..a4bfa072a
--- /dev/null
+++ b/app/react/edge/edge-stacks/queries/buildUrl.ts
@@ -0,0 +1,7 @@
+import { EdgeStack } from '../types';
+
+export function buildUrl(id?: EdgeStack['Id'], action?: string) {
+ const baseUrl = '/edge_stacks';
+ const url = id ? `${baseUrl}/${id}` : baseUrl;
+ return action ? `${url}/${action}` : url;
+}
diff --git a/app/react/edge/edge-stacks/queries/query-keys.ts b/app/react/edge/edge-stacks/queries/query-keys.ts
new file mode 100644
index 000000000..fc4ae88a0
--- /dev/null
+++ b/app/react/edge/edge-stacks/queries/query-keys.ts
@@ -0,0 +1,10 @@
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { EdgeStack } from '../types';
+
+export const queryKeys = {
+ base: () => ['edge-stacks'] as const,
+ item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const,
+ logsStatus: (edgeStackId: EdgeStack['Id'], environmentId: EnvironmentId) =>
+ [...queryKeys.item(edgeStackId), 'logs', environmentId] as const,
+};
diff --git a/app/react/edge/edge-stacks/queries/useCreateStackFromFileContent.ts b/app/react/edge/edge-stacks/queries/useCreateStackFromFileContent.ts
new file mode 100644
index 000000000..52281339c
--- /dev/null
+++ b/app/react/edge/edge-stacks/queries/useCreateStackFromFileContent.ts
@@ -0,0 +1,41 @@
+import { useMutation } from 'react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { withError } from '@/react-tools/react-query';
+import { RegistryId } from '@/react/portainer/registries/types';
+
+import { EdgeGroup } from '../../edge-groups/types';
+import { DeploymentType, EdgeStack } from '../types';
+
+import { buildUrl } from './buildUrl';
+
+export function useCreateStackFromFileContent() {
+ return useMutation(createStackFromFileContent, {
+ ...withError('Failed creating Edge stack'),
+ });
+}
+
+interface FileContentPayload {
+ name: string;
+ stackFileContent: string;
+ edgeGroups: EdgeGroup['Id'][];
+ deploymentType: DeploymentType;
+ registries?: RegistryId[];
+ useManifestNamespaces?: boolean;
+ prePullImage?: boolean;
+ dryRun?: boolean;
+}
+
+export async function createStackFromFileContent({
+ dryRun,
+ ...payload
+}: FileContentPayload) {
+ try {
+ const { data } = await axios.post(buildUrl(), payload, {
+ params: { method: 'string', dryrun: dryRun ? 'true' : 'false' },
+ });
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts
index 624e6439d..0c05567f4 100644
--- a/app/react/edge/edge-stacks/types.ts
+++ b/app/react/edge/edge-stacks/types.ts
@@ -1,3 +1,62 @@
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ AutoUpdateResponse,
+ RepoConfigResponse,
+} from '@/react/portainer/gitops/types';
+import { RegistryId } from '@/react/portainer/registries/types';
+
+import { EdgeGroup } from '../edge-groups/types';
+
+interface EdgeStackStatusDetails {
+ Pending: boolean;
+ Ok: boolean;
+ Error: boolean;
+ Acknowledged: boolean;
+ Remove: boolean;
+ RemoteUpdateSuccess: boolean;
+ ImagesPulled: boolean;
+}
+
+interface EdgeStackStatus {
+ Details: EdgeStackStatusDetails;
+ Error: string;
+ EndpointID: EnvironmentId;
+}
+
+export enum DeploymentType {
+ /** represent an edge stack deployed using a compose file */
+ Compose,
+ /** represent an edge stack deployed using a kubernetes manifest file */
+ Kubernetes,
+ /** represent an edge stack deployed using a nomad hcl job file */
+ Nomad,
+}
+
+export type EdgeStack = {
+ Id: number;
+ Name: string;
+ Status: { [key: EnvironmentId]: EdgeStackStatus };
+ CreationDate: number;
+ EdgeGroups: Array;
+ Registries: RegistryId[];
+ ProjectPath: string;
+ EntryPoint: string;
+ Version: number;
+ NumDeployments: number;
+ ManifestPath: string;
+ DeploymentType: DeploymentType;
+ EdgeUpdateID: number;
+ ScheduledTime: string;
+ UseManifestNamespaces: boolean;
+ PrePullImage: boolean;
+ RePullImage: boolean;
+ AutoUpdate?: AutoUpdateResponse;
+ GitConfig?: RepoConfigResponse;
+ Prune: boolean;
+ RetryDeploy: boolean;
+ Webhook?: string;
+};
+
export enum EditorType {
Compose,
Kubernetes,
diff --git a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector.tsx b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector.tsx
index 423d37de7..046871c32 100644
--- a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector.tsx
+++ b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector.tsx
@@ -1,4 +1,4 @@
-import { Registry } from '@/react/portainer/environments/environment.service/registries';
+import { Registry } from '@/react/portainer/registries/types';
import { Select } from '@@/form-components/ReactSelect';
diff --git a/app/react/portainer/account/git-credentials/git-credentials.service.ts b/app/react/portainer/account/git-credentials/git-credentials.service.ts
index 751eb0a75..89086a96b 100644
--- a/app/react/portainer/account/git-credentials/git-credentials.service.ts
+++ b/app/react/portainer/account/git-credentials/git-credentials.service.ts
@@ -13,7 +13,11 @@ export async function createGitCredential(
gitCredential: CreateGitCredentialPayload
) {
try {
- await axios.post(buildGitUrl(gitCredential.userId), gitCredential);
+ const { data } = await axios.post<{ gitCredential: GitCredential }>(
+ buildGitUrl(gitCredential.userId),
+ gitCredential
+ );
+ return data.gitCredential;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create git credential');
}
diff --git a/app/react/portainer/environments/environment.service/registries.ts b/app/react/portainer/environments/environment.service/registries.ts
index d370ce910..4efc75ae9 100644
--- a/app/react/portainer/environments/environment.service/registries.ts
+++ b/app/react/portainer/environments/environment.service/registries.ts
@@ -1,6 +1,7 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TeamId } from '@/react/portainer/users/teams/types';
import { UserId } from '@/portainer/users/types';
+import { RegistryId, Registry } from '@/react/portainer/registries/types';
import { EnvironmentId } from '../types';
@@ -14,12 +15,6 @@ interface AccessPolicy {
type UserAccessPolicies = Record; // map[UserID]AccessPolicy
type TeamAccessPolicies = Record;
-export type RegistryId = number;
-export interface Registry {
- Id: RegistryId;
- Name: string;
-}
-
interface RegistryAccess {
UserAccessPolicies: UserAccessPolicies;
TeamAccessPolicies: TeamAccessPolicies;
diff --git a/app/react/portainer/gitops/AuthFieldset/utils.ts b/app/react/portainer/gitops/AuthFieldset/utils.ts
index 5199d73aa..ba7b4299f 100644
--- a/app/react/portainer/gitops/AuthFieldset/utils.ts
+++ b/app/react/portainer/gitops/AuthFieldset/utils.ts
@@ -22,3 +22,27 @@ export function parseAuthResponse(
RepositoryUsername: auth.Username,
};
}
+
+export function transformGitAuthenticationViewModel(
+ auth?: GitAuthModel
+): GitAuthenticationResponse | null {
+ if (
+ !auth ||
+ !auth.RepositoryAuthentication ||
+ typeof auth.RepositoryGitCredentialID === 'undefined' ||
+ (auth.RepositoryGitCredentialID === 0 && auth.RepositoryPassword === '')
+ ) {
+ return null;
+ }
+
+ if (auth.RepositoryGitCredentialID !== 0) {
+ return {
+ GitCredentialID: auth.RepositoryGitCredentialID,
+ };
+ }
+
+ return {
+ Username: auth.RepositoryUsername,
+ Password: auth.RepositoryPassword,
+ };
+}
diff --git a/app/react/portainer/gitops/types.ts b/app/react/portainer/gitops/types.ts
index 4c0014ba4..0501929ca 100644
--- a/app/react/portainer/gitops/types.ts
+++ b/app/react/portainer/gitops/types.ts
@@ -15,9 +15,9 @@ export interface AutoUpdateResponse {
}
export interface GitAuthenticationResponse {
- Username: string;
- Password: string;
- GitCredentialID: number;
+ Username?: string;
+ Password?: string;
+ GitCredentialID?: number;
}
export interface RepoConfigResponse {
diff --git a/app/react/portainer/registries/queries/queryKeys.ts b/app/react/portainer/registries/queries/queryKeys.ts
new file mode 100644
index 000000000..eab8e728b
--- /dev/null
+++ b/app/react/portainer/registries/queries/queryKeys.ts
@@ -0,0 +1,3 @@
+export const queryKeys = {
+ registries: () => ['registries'] as const,
+};
diff --git a/app/react/portainer/registries/queries/useRegistries.ts b/app/react/portainer/registries/queries/useRegistries.ts
new file mode 100644
index 000000000..4419903c2
--- /dev/null
+++ b/app/react/portainer/registries/queries/useRegistries.ts
@@ -0,0 +1,20 @@
+import { useQuery } from 'react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+
+import { Registry } from '../types';
+
+import { queryKeys } from './queryKeys';
+
+export function useRegistries() {
+ return useQuery(queryKeys.registries(), getRegistries);
+}
+
+async function getRegistries() {
+ try {
+ const response = await axios.get>('/registries');
+ return response.data;
+ } catch (err) {
+ throw parseAxiosError(err as Error, 'Unable to retrieve registries');
+ }
+}
diff --git a/app/react/portainer/registries/types.ts b/app/react/portainer/registries/types.ts
new file mode 100644
index 000000000..63ffbbcdb
--- /dev/null
+++ b/app/react/portainer/registries/types.ts
@@ -0,0 +1,5 @@
+export type RegistryId = number;
+export interface Registry {
+ Id: RegistryId;
+ Name: string;
+}