Compare commits

..

1 Commits

Author SHA1 Message Date
Oscar Zhou
4f4e8b5af9 fix(snapshot): prevent from returning SnapshotRaw data [BE-12431] (#1442) 2025-11-27 09:01:01 +13:00
102 changed files with 1229 additions and 3885 deletions

View File

@@ -17,7 +17,7 @@ plugins:
- import
parserOptions:
ecmaVersion: latest
ecmaVersion: 2018
sourceType: module
project: './tsconfig.json'
ecmaFeatures:

View File

@@ -94,10 +94,8 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.5'
- '2.33.4'
- '2.33.3'
- '2.33.2'

View File

@@ -53,5 +53,4 @@ type Connection interface {
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
ConvertToKey(v int) []byte
ConvertStringToKey(v string) []byte
}

View File

@@ -233,10 +233,6 @@ func (connection *DbConnection) ConvertToKey(v int) []byte {
return b
}
func (connection *DbConnection) ConvertStringToKey(v string) []byte {
return []byte(v)
}
// keyToString Converts a key to a string value suitable for logging
func keyToString(b []byte) string {
if len(b) != 8 {

View File

@@ -50,9 +50,6 @@ func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
func (m mockConnection) ConvertToKey(v int) []byte {
return []byte(strconv.Itoa(v))
}
func (c mockConnection) ConvertStringToKey(v string) []byte {
return []byte(v)
}
func TestReadAll(t *testing.T) {
service := BaseDataService[testObject, int]{

View File

@@ -1,70 +0,0 @@
package version
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[models.Version, int] // ID is not used
}
func (tx ServiceTx) InstanceID() (string, error) {
v, err := tx.Version()
if err != nil {
return "", err
}
return v.InstanceID, nil
}
func (tx ServiceTx) UpdateInstanceID(ID string) error {
v, err := tx.Version()
if err != nil {
if !dataservices.IsErrObjectNotFound(err) {
return err
}
v = &models.Version{}
}
v.InstanceID = ID
return tx.UpdateVersion(v)
}
func (tx ServiceTx) Edition() (portainer.SoftwareEdition, error) {
v, err := tx.Version()
if err != nil {
return 0, err
}
return portainer.SoftwareEdition(v.Edition), nil
}
func (tx ServiceTx) Version() (*models.Version, error) {
var v models.Version
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
if err != nil {
return nil, err
}
return &v, nil
}
func (tx ServiceTx) UpdateVersion(version *models.Version) error {
return tx.Tx.UpdateObject(BucketName, []byte(versionKey), version)
}
func (tx ServiceTx) SchemaVersion() (string, error) {
var v models.Version
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
if err != nil {
return "", err
}
return v.SchemaVersion, nil
}

View File

@@ -33,16 +33,6 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[models.Version, int]{
Bucket: BucketName,
Connection: service.connection,
Tx: tx,
},
}
}
func (service *Service) SchemaVersion() (string, error) {
v, err := service.Version()
if err != nil {

View File

@@ -614,7 +614,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.37.0",
"KubectlShellImage": "portainer/kubectl-shell:2.36.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.37.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.36.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -74,7 +74,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, payload.EndpointID))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
var stack *portainer.EdgeStack

View File

@@ -24,8 +24,8 @@ func (payload *logsPayload) Validate(r *http.Request) error {
}
// endpointEdgeJobsLogs
// @summary Update the logs collected from an Edge Job
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
// @summary Inspect an EdgeJob Log
// @description **Access policy**: public
// @tags edge, endpoints
// @accept json
// @produce json
@@ -34,7 +34,6 @@ func (payload *logsPayload) Validate(r *http.Request) error {
// @success 200
// @failure 500
// @failure 400
// @failure 403
// @router /endpoints/{id}/edge/jobs/{jobID}/logs [post]
func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoint, err := middlewares.FetchEndpoint(r)
@@ -43,35 +42,35 @@ func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Requ
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID")
if err != nil {
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment ID: %d", err, endpoint.ID))
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment name: %s", err, endpoint.Name))
}
var payload logsPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment ID: %d", err, endpoint.ID))
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment name: %s", err, endpoint.Name))
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.updateEdgeJobLogs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
return handler.getEdgeJobLobs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
}
return response.JSON(w, nil)
}
func (handler *Handler) updateEdgeJobLogs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error {
func (handler *Handler) getEdgeJobLobs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
@@ -86,11 +85,6 @@ func (handler *Handler) updateEdgeJobLogs(tx dataservices.DataStoreTx, endpointI
return httperror.InternalServerError("Unable to find an edge job with the specified identifier inside the database", err)
}
if resp, err := handler.buildSchedules(tx, endpoint, []portainer.EdgeJob{*edgeJob}); err != nil || len(resp) == 0 {
return httperror.InternalServerError("Unable to verify if the edge job is assigned to the environment",
fmt.Errorf("unable to verify if the edge job is assigned to the environment: %w. Environment name: %s", err, endpoint.Name))
}
if err := handler.FileService.StoreEdgeJobTaskLogFileFromBytes(strconv.Itoa(int(edgeJobID)), strconv.Itoa(int(endpoint.ID)), []byte(payload.FileContent)); err != nil {
return httperror.InternalServerError("Unable to save task log to the filesystem", err)
}

View File

@@ -1,40 +0,0 @@
package endpointedge
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestUpdateUnrelatedEdgeJobLogs(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
h := &Handler{DataStore: store}
endpointID := portainer.EndpointID(2)
edgeJobID := portainer.EdgeJobID(3)
payload := logsPayload{FileContent: "log content"}
err := store.Endpoint().Create(&portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint",
})
require.NoError(t, err)
err = store.EdgeJob().CreateWithID(edgeJobID, &portainer.EdgeJob{
ID: edgeJobID,
Name: "test-edge-job",
})
require.NoError(t, err)
// There is no relation between the edge job and the endpoint, so the
// update must fail
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return h.updateEdgeJobLogs(tx, endpointID, edgeJobID, payload)
})
require.Error(t, err)
}

View File

@@ -40,18 +40,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId")
if err != nil {
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment ID: %d", err, endpoint.ID))
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
}
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment ID: %d", err, endpoint.ID))
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
}
return edgeStack, err
@@ -62,7 +62,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
return httpErr
}
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
}
// WARNING: this variable must not be mutated
@@ -71,7 +71,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
fileName := edgeStack.EntryPoint
if endpointutils.IsDockerEndpoint(endpoint) {
if fileName == "" {
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment ID: %d", endpoint.ID))
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment name: %s", endpoint.Name))
}
}
@@ -84,18 +84,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
fileName = edgeStack.ManifestPath
if fileName == "" {
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment ID: %d", endpoint.ID))
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment name: %s", endpoint.Name))
}
}
dirEntries, err := filesystem.LoadDir(edgeStack.ProjectPath)
if err != nil {
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment name: %s", err, endpoint.Name))
}
fileContent, err := filesystem.FilterDirForCompatibility(dirEntries, fileName, endpoint.Agent.Version)
if err != nil {
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment name: %s", err, endpoint.Name))
}
dirEntries = filesystem.FilterDirForEntryFile(dirEntries, fileName)

View File

@@ -97,13 +97,13 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
firstConn := endpoint.LastCheckInDate == 0
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
handler.DataStore.Endpoint().UpdateHeartbeat(endpoint.ID)
if err := handler.requestBouncer.TrustedEdgeEnvironmentAccess(handler.DataStore, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment ID: %d", err, endpoint.ID))
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment name: %s", err, endpoint.Name))
}
var statusResponse *endpointEdgeStatusInspectResponse
@@ -113,11 +113,11 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
}
return cacheResponse(w, endpoint.ID, *statusResponse)
@@ -170,7 +170,7 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
Credentials: tunnel.Credentials,
}
schedules, handlerErr := handler.buildAllSchedules(tx, endpoint)
schedules, handlerErr := handler.buildSchedules(tx, endpoint)
if handlerErr != nil {
return nil, handlerErr
}
@@ -208,18 +208,14 @@ func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) {
}
}
func (handler *Handler) buildAllSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
schedules := []edgeJobResponse{}
edgeJobs, err := tx.EdgeJob().ReadAll()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve Edge Jobs", err)
}
return handler.buildSchedules(tx, endpoint, edgeJobs)
}
func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob) ([]edgeJobResponse, *httperror.HandlerError) {
schedules := []edgeJobResponse{}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve endpoint groups", err)
@@ -244,10 +240,17 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *po
continue
}
var collectLogs bool
if _, ok := job.GroupLogsCollection[endpoint.ID]; ok {
collectLogs = job.GroupLogsCollection[endpoint.ID].CollectLogs
} else {
collectLogs = job.Endpoints[endpoint.ID].CollectLogs
}
schedule := edgeJobResponse{
ID: job.ID,
CronExpression: job.CronExpression,
CollectLogs: job.GroupLogsCollection[endpoint.ID].CollectLogs || job.Endpoints[endpoint.ID].CollectLogs,
CollectLogs: collectLogs,
Version: job.Version,
}

View File

@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.37.0
// @version 2.36.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -7,7 +7,6 @@ import (
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/pendingactions/handlers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -81,7 +80,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", cli.RegistrySecretName(registry.ID), ns, endpointId)
}
}

View File

@@ -1,11 +0,0 @@
package registryutils
import (
"strconv"
portainer "github.com/portainer/portainer/api"
)
func RegistrySecretName(registryID portainer.RegistryID) string {
return "registry-" + strconv.Itoa(int(registryID))
}

View File

@@ -2,6 +2,7 @@ package cli
import (
"context"
"fmt"
"strconv"
portainer "github.com/portainer/portainer/api"
@@ -33,7 +34,7 @@ type (
)
func (kcl *KubeClient) DeleteRegistrySecret(registry portainer.RegistryID, namespace string) error {
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), registryutils.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), kcl.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
return errors.Wrap(err, "failed removing secret")
}
@@ -61,15 +62,11 @@ func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namesp
}
secret := &v1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{
Name: registryutils.RegistrySecretName(registry.ID),
Name: kcl.RegistrySecretName(registry.ID),
Labels: map[string]string{
labelRegistryType: strconv.Itoa(int(registry.Type)),
"app.kubernetes.io/managed-by": "portainer",
labelRegistryType: strconv.Itoa(int(registry.Type)),
},
Annotations: map[string]string{
annotationRegistryID: strconv.Itoa(int(registry.ID)),
@@ -102,3 +99,7 @@ func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, err
return isSecret, nil
}
func (*KubeClient) RegistrySecretName(registryID portainer.RegistryID) string {
return fmt.Sprintf("registry-%d", registryID)
}

View File

@@ -29,8 +29,6 @@ type (
AccessPolicy struct {
// Role identifier. Reference the role that will be associated to this access policy
RoleID RoleID `json:"RoleId" example:"1"`
// Namespaces is a list of namespaces that this access policy applies to. Only used for namespaced level roles
Namespaces []string `json:"Namespaces,omitempty"`
}
// AgentPlatform represents a platform type for an Agent
@@ -538,65 +536,6 @@ type (
Tags []string `json:"Tags,omitempty"`
}
PolicyChartSummary struct {
ChartName string `json:"ChartName"`
Fingerprint string `json:"Fingerprint"`
}
PolicyChartStatus struct {
ChartName string `json:"chartName"`
Fingerprint string `json:"fingerprint"`
Status HelmInstallStatus `json:"status"`
Message string `json:"message"`
Namespace string `json:"namespace"`
}
ImageBundle struct {
FileName string `json:"FileName"`
EncodedTarGz string `json:"EncodedTarGz"`
}
PolicyChartBundle struct {
PolicyChartSummary
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
EncodedValues string `json:"EncodedValues"`
PreInstallDeletions []ResourceDeletion `json:"PreInstallDeletions,omitempty"`
PreInstallAdoptions []ResourceAdoption `json:"PreInstallAdoptions,omitempty"`
}
// ResourceDeletion identifies an existing Kubernetes resource to delete before policy install
ResourceDeletion struct {
APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"`
Kind string `json:"kind" example:"Secret" yaml:"kind"`
Name string `json:"name" example:"registry-1" yaml:"name"`
Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"`
}
// ResourceAdoption identifies an existing Kubernetes resource to adopt into a Helm release
ResourceAdoption struct {
APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"`
Kind string `json:"kind" example:"Secret" yaml:"kind"`
Name string `json:"name" example:"registry-1" yaml:"name"`
Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"`
}
// RestoreSettings contains instructions for restoring environment-level settings
RestoreSettings struct {
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
}
// RestoreSettingsBundle maps restore type to restoration instructions
RestoreSettingsBundle map[PolicyType]RestoreSettings
PolicyID int
// PolicyType represents the type of policy
PolicyType string
)
type (
// EndpointGroupID represents an environment(endpoint) group identifier
EndpointGroupID int
@@ -927,11 +866,9 @@ type (
RegistryAccesses map[EndpointID]RegistryAccessPolicies
RegistryAccessPolicies struct {
// Docker specific fields (with docker, users/teams have access to a registry)
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
// Kubernetes specific fields (with kubernetes, namespaces have access to a registry, if users/teams have access to the same namespace, they have access to the registry)
Namespaces []string `json:"Namespaces"`
Namespaces []string `json:"Namespaces"`
}
// RegistryID represents a registry identifier
@@ -1857,7 +1794,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.37.0"
APIVersion = "2.36.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
@@ -2430,24 +2367,3 @@ const (
ContainerEngineDocker = "docker"
ContainerEnginePodman = "podman"
)
const (
// PolicyType constants
RbacK8s PolicyType = "rbac-k8s"
SecurityK8s PolicyType = "security-k8s"
SetupK8s PolicyType = "setup-k8s"
RegistryK8s PolicyType = "registry-k8s"
RbacDocker PolicyType = "rbac-docker"
SecurityDocker PolicyType = "security-docker"
SetupDocker PolicyType = "setup-docker"
RegistryDocker PolicyType = "registry-docker"
)
type HelmInstallStatus string
const (
HelmInstallStatusInstalling HelmInstallStatus = "installing"
HelmInstallStatusInstalled HelmInstallStatus = "installed"
HelmInstallStatusFailed HelmInstallStatus = "failed"
HelmInstallStatusUninstalling HelmInstallStatus = "uninstalling"
)

View File

@@ -55,11 +55,12 @@ func (d *stackDeployer) DeployRemoteComposeStack(
d.lock.Lock()
defer d.lock.Unlock()
options := portainer.ComposeOptions{Registries: registries}
d.swarmStackManager.Login(registries, endpoint)
defer d.swarmStackManager.Logout(endpoint)
// --force-recreate doesn't pull updated images
if forcePullImage {
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, options); err != nil {
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, portainer.ComposeOptions{}); err != nil {
return err
}
}

View File

@@ -75,7 +75,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/configs',
views: {
'content@': {
component: 'configsListView',
templateUrl: './views/configs/configs.html',
controller: 'ConfigsController',
controllerAs: 'ctrl',
},
},
data: {

View File

@@ -2,7 +2,7 @@
<host-details-panel
host="$ctrl.hostDetails"
is-browse-enabled="$ctrl.isAdmin && $ctrl.isAgent && $ctrl.agentApiVersion > 1 && $ctrl.hostFeaturesEnabled"
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && $ctrl.hostFeaturesEnabled"
browse-url="{{ $ctrl.browseUrl }}"
></host-details-panel>

View File

@@ -10,7 +10,6 @@ angular.module('portainer.docker').component('hostOverview', {
refreshUrl: '@',
browseUrl: '@',
hostFeaturesEnabled: '<',
isAdmin: '<',
},
transclude: true,
});

View File

@@ -13,6 +13,7 @@ import { InsightsBox } from '@/react/components/InsightsBox';
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
import { ImagesDatatable } from '@/react/docker/images/ListView/ImagesDatatable/ImagesDatatable';
import { EventsDatatable } from '@/react/docker/events/EventsDatatables';
import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatable';
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser';
import { ProcessesDatatable } from '@/react/docker/containers/StatsView/ProcessesDatatable';
@@ -78,6 +79,14 @@ const ngModule = angular
'onRemove',
])
)
.component(
'dockerConfigsDatatable',
r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
'dataset',
'onRemoveClick',
'onRefresh',
])
)
.component(
'agentHostBrowserReact',
r2a(withUIRouter(withCurrentUser(AgentHostBrowser)), [

View File

@@ -1,14 +0,0 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/docker/configs/ListView/ListView';
export const configsModule = angular
.module('portainer.docker.react.views.configs', [])
.component(
'configsListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
).name;

View File

@@ -8,10 +8,9 @@ import { DashboardView } from '@/react/docker/DashboardView/DashboardView';
import { ListView } from '@/react/docker/events/ListView';
import { containersModule } from './containers';
import { configsModule } from './configs';
export const viewsModule = angular
.module('portainer.docker.react.views', [containersModule, configsModule])
.module('portainer.docker.react.views', [containersModule])
.component(
'dockerDashboardView',
r2a(withUIRouter(withCurrentUser(DashboardView)), [])

View File

@@ -3,7 +3,7 @@ import { getConfigs } from '@/react/docker/configs/queries/useConfigs';
import { deleteConfig } from '@/react/docker/configs/queries/useDeleteConfigMutation';
import { createConfig } from '@/react/docker/configs/queries/useCreateConfigMutation';
import { ConfigViewModel } from '@/react/docker/configs/model';
import { ConfigViewModel } from '../models/config';
angular.module('portainer.docker').factory('ConfigService', ConfigServiceFactory);

View File

@@ -0,0 +1,3 @@
<page-header title="'Configs list'" breadcrumbs="['Configs']" reload="true"> </page-header>
<docker-configs-datatable dataset="ctrl.configs" on-remove-click="(ctrl.removeAction)" on-refresh="(ctrl.getConfigs)"></docker-configs-datatable>

View File

@@ -0,0 +1,59 @@
import angular from 'angular';
class ConfigsController {
/* @ngInject */
constructor($state, ConfigService, Notifications, $async, endpoint) {
this.$state = $state;
this.ConfigService = ConfigService;
this.Notifications = Notifications;
this.$async = $async;
this.endpoint = endpoint;
this.removeAction = this.removeAction.bind(this);
this.removeActionAsync = this.removeActionAsync.bind(this);
this.getConfigs = this.getConfigs.bind(this);
this.getConfigsAsync = this.getConfigsAsync.bind(this);
}
getConfigs() {
return this.$async(this.getConfigsAsync);
}
async getConfigsAsync() {
try {
this.configs = await this.ConfigService.configs(this.endpoint.Id);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve configs');
}
}
async $onInit() {
this.configs = [];
this.getConfigs();
}
async removeAction(selectedItems) {
return this.$async(this.removeActionAsync, selectedItems);
}
async removeActionAsync(selectedItems) {
let actionCount = selectedItems.length;
for (const config of selectedItems) {
try {
await this.ConfigService.remove(this.endpoint.Id, config.Id);
this.Notifications.success('Config successfully removed', config.Name);
const index = this.configs.indexOf(config);
this.configs.splice(index, 1);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove config');
} finally {
--actionCount;
if (actionCount === 0) {
this.$state.reload();
}
}
}
}
}
export default ConfigsController;
angular.module('portainer.docker').controller('ConfigsController', ConfigsController);

View File

@@ -8,5 +8,4 @@
refresh-url="docker.host"
browse-url="docker.host.browser"
host-features-enabled="$ctrl.state.enableHostManagementFeatures"
is-admin="$ctrl.state.isAdmin"
></host-overview>

View File

@@ -8,7 +8,6 @@
refresh-url="docker.nodes.node"
browse-url="docker.nodes.node.browse"
host-features-enabled="$ctrl.state.enableHostManagementFeatures"
is-admin="$ctrl.state.isAdmin"
>
<swarm-node-details-panel details="$ctrl.nodeDetails" original-node="$ctrl.originalNode"></swarm-node-details-panel>
</host-overview>

View File

@@ -720,7 +720,7 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.onResetPorts = function (all = false) {
$scope.$evalAsync(() => {
$scope.formValues.ports = portsMappingUtils.toViewModel($scope.service.Model.Spec.EndpointSpec?.Ports);
$scope.formValues.ports = portsMappingUtils.toViewModel($scope.service.Model.Spec.EndpointSpec.Ports);
$scope.cancelChanges($scope.service, all ? undefined : ['Ports']);
});
@@ -744,7 +744,7 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.lastVersion = service.Version;
}
$scope.formValues.ports = portsMappingUtils.toViewModel(service.Model.Spec.EndpointSpec?.Ports);
$scope.formValues.ports = portsMappingUtils.toViewModel(service.Model.Spec.EndpointSpec.Ports);
transformResources(service);
translateServiceArrays(service);

View File

@@ -10,6 +10,7 @@ import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
import { RefField } from '@/react/portainer/gitops/RefField';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
import { StackRedeployGitForm } from '@/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm';
export const gitFormModule = angular
.module('portainer.app.components.forms.git', [])
@@ -79,4 +80,12 @@ export const gitFormModule = angular
.component(
'timeWindowDisplay',
r2a(withReactQuery(withUIRouter(TimeWindowDisplay)), [])
)
.component(
'stackRedeployGitForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(StackRedeployGitForm))), [
'model',
'stack',
'endpoint',
])
).name;

View File

@@ -210,7 +210,6 @@ export const ngModule = angular
'aria-label',
'size',
'loadingMessage',
'getOptionValue',
])
)
.component(

View File

@@ -6,7 +6,6 @@ import { StackDuplicationForm } from '@/react/common/stacks/ItemView/StackDuplic
import { StackEditorTab } from '@/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { StackInfoTab } from '@/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab';
export const stacksModule = angular
.module('portainer.app.react.components.stacks', [])
@@ -33,19 +32,4 @@ export const stacksModule = angular
'originalContainerNames',
'onSubmitSettled',
])
)
.component(
'stackInfoTab',
r2a(withUIRouter(withCurrentUser(StackInfoTab)), [
'stack',
'stackName',
'stackFileContent',
'isRegular',
'isExternal',
'isOrphaned',
'environmentId',
'isOrphanedRunning',
'yamlError',
])
).name;

View File

@@ -11,17 +11,125 @@
<pr-icon icon="'list'"></pr-icon>
Stack
</uib-tab-heading>
<stack-info-tab
stack="stack"
stack-name="stackName"
stack-file-content="stackFileContent"
is-regular="regular"
is-external="external"
is-orphaned="orphaned"
is-orphaned-running="orphanedRunning"
environment-id="endpoint.Id"
yaml-error="yamlError"
></stack-info-tab>
<div style="margin-top: 10px">
<!-- stack-information -->
<div ng-if="external || orphaned">
<div class="col-sm-12 form-section-title"> Information </div>
<div class="form-group">
<span class="small">
<p class="text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'" class-name="'mr-0.5'"></pr-icon>
<span ng-if="external">This stack was created outside of Portainer. Control over this stack is limited.</span>
<span ng-if="orphaned">This stack is orphaned. You can re-associate it with the current environment using the "Associate to this environment" feature.</span>
</p>
</span>
</div>
</div>
<!-- !stack-information -->
<!-- stack-details -->
<div>
<div class="col-sm-12 form-section-title"> Stack details </div>
<div class="form-group">
{{ stackName }}
<button
authorization="PortainerStackUpdate"
ng-if="regular && stack.Status === 2"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-success"
ng-click="startStack()"
>
<pr-icon icon="'play'"></pr-icon>
Start this stack
</button>
<button
ng-if="regular && stack.Status === 1"
authorization="PortainerStackUpdate"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-light"
ng-click="stopStack()"
>
<pr-icon icon="'stop-circle'"></pr-icon>
Stop this stack
</button>
<button authorization="PortainerStackDelete" class="btn btn-xs btn-light" ng-click="removeStack()" ng-if="!external || stack.Type == 1">
<pr-icon icon="'trash-2'"></pr-icon>
Delete this stack
</button>
<button
ng-if="regular && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="docker.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
<pr-icon icon="'plus'"></pr-icon>
Create template from stack
</button>
<button
authorization="PortainerStackUpdate"
ng-if="regular && stackFileContent && !stack.FromAppTemplate && stack.GitConfig"
ng-disabled="state.actionInProgress"
ng-click="detachStackFromGit()"
button-spinner="state.actionInProgress"
class="btn btn-primary btn-xs"
>
<pr-icon icon="'arrow-right'" class-name="'mr-1'"></pr-icon>
<span ng-hide="state.actionInProgress">Detach from Git</span>
<span ng-show="state.actionInProgress">Detachment in progress...</span>
</button>
</div>
</div>
<!-- !stack-details -->
<!-- associate -->
<div ng-if="orphaned">
<div class="col-sm-12 form-section-title"> Associate to this environment </div>
<p class="small text-muted"> This feature allows you to re-associate this stack to the current environment. </p>
<form class="form-horizontal">
<por-access-control-form form-data="formValues.AccessControlData" hide-title="true"></por-access-control-form>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress"
ng-click="associateStack()"
button-spinner="state.actionInProgress"
style="margin-left: -5px"
>
<pr-icon icon="'refresh-cw'" class="!mr-1"></pr-icon>
<span ng-hide="state.actionInProgress">Associate</span>
<span ng-show="state.actionInProgress">Association in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
</div>
</div>
</form>
</div>
<!-- !associate -->
<div ng-if="!orphaned">
<stack-redeploy-git-form
ng-if="stack.GitConfig && !stack.FromAppTemplate && !state.actionInProgress"
model="stack.GitConfig"
stack="stack"
authorization="PortainerStackUpdate"
endpoint="applicationState.endpoint"
>
</stack-redeploy-git-form>
<stack-duplication-form
ng-if="stack && regular"
current-environment-id="endpoint.Id"
yaml-error="state.yamlError"
stack="stack"
original-file-content="stackFileContent"
>
</stack-duplication-form>
</div>
</div>
</uib-tab>
<!-- !tab-info -->
<!-- tab-file -->
@@ -37,7 +145,7 @@
is-orphaned="orphaned"
initial-values="editorTabInitialValues"
container-names="containerNames"
original-container-names="state.originalContainerNames"
original-container-names="originalContainerNames"
versions="state.versions"
stack-id="stack.Id"
on-submit="(deployStack)"

View File

@@ -3,6 +3,10 @@ import { AccessControlFormData } from 'Portainer/components/accessControlForm/po
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { StackStatus, StackType } from '@/react/common/stacks/types';
import { extractContainerNames } from '@/react/docker/stacks/ItemView/container-names';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { confirm, confirmDelete } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
angular.module('portainer.app').controller('StackController', [
'$async',
@@ -21,6 +25,7 @@ angular.module('portainer.app').controller('StackController', [
'Notifications',
'FormHelper',
'StackHelper',
'ResourceControlService',
'Authentication',
'ContainerHelper',
'endpoint',
@@ -41,6 +46,7 @@ angular.module('portainer.app').controller('StackController', [
Notifications,
FormHelper,
StackHelper,
ResourceControlService,
Authentication,
ContainerHelper,
endpoint
@@ -54,6 +60,7 @@ angular.module('portainer.app').controller('StackController', [
};
$scope.endpoint = endpoint;
$scope.isAdmin = Authentication.isAdmin();
$scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK;
$scope.stackPullImageFeature = FeatureId.STACK_PULL_IMAGE;
$scope.state = {
@@ -71,6 +78,68 @@ angular.module('portainer.app').controller('StackController', [
$scope.state.showEditorTab = true;
};
$scope.removeStack = function () {
confirmDelete('Do you want to remove the stack? Associated services will be removed as well').then((confirmed) => {
if (!confirmed) {
return;
}
deleteStack();
});
};
$scope.detachStackFromGit = function () {
confirmDetachment().then(function onConfirm(confirmed) {
if (!confirmed) {
return;
}
deployStack({
stackFileContent: $scope.stackFileContent,
environmentVariables: FormHelper.removeInvalidEnvVars($scope.stack.Env),
prune: false,
});
});
};
function deleteStack() {
var endpointId = +$state.params.endpointId;
var stack = $scope.stack;
StackService.remove(stack, $transition$.params().external, endpointId)
.then(function success() {
Notifications.success('Stack successfully removed', stack.Name);
$state.go('docker.stacks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
});
}
$scope.associateStack = function () {
var endpointId = +$state.params.endpointId;
var stack = $scope.stack;
var accessControlData = $scope.formValues.AccessControlData;
$scope.state.actionInProgress = true;
StackService.associate(stack, endpointId, $scope.orphanedRunning)
.then(function success(data) {
const resourceControl = data.ResourceControl;
const userDetails = Authentication.getUserDetails();
const userId = userDetails.ID;
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
})
.then(function success() {
Notifications.success('Stack successfully associated', stack.Name);
$state.go('docker.stacks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to associate stack ' + stack.Name);
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
$scope.onEditorSubmit = function () {
$scope.state.actionInProgress = true;
};
@@ -79,6 +148,86 @@ angular.module('portainer.app').controller('StackController', [
$scope.state.actionInProgress = false;
};
/**
* Deploy a stack
* @param {Object} stack
* @param {string} stack.stackFileContent - The stack file content to deploy
* @param {import('@@/form-components/EnvironmentVariablesFieldset').EnvVarValues} stack.environmentVariables - Array of environment variables
* @param {boolean} stack.prune - Whether to prune services that are no longer referenced
* @returns {void}
*/
function deployStack({ stackFileContent, environmentVariables, prune }) {
const stack = $scope.stack;
const isSwarmStack = stack.Type === 1;
confirmStackUpdate('Do you want to force an update of the stack?', isSwarmStack).then(function (result) {
if (!result) {
return;
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, we can pass
// the current endpoint identifier as a part of the update request. It will be used if
// the EndpointID property is not defined on the stack.
if (!stack.EndpointId) {
stack.EndpointId = endpoint.Id;
}
$scope.state.actionInProgress = true;
StackService.updateStack(stack, stackFileContent, environmentVariables, prune, result.pullImage)
.then(function success() {
Notifications.success('Success', 'Stack successfully deployed');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create stack');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
});
}
$scope.stopStack = stopStack;
function stopStack() {
return $async(stopStackAsync);
}
async function stopStackAsync() {
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
message: 'Are you sure you want to stop this stack?',
confirmButton: buildConfirmButton('Stop', 'danger'),
});
if (!confirmed) {
return;
}
$scope.state.actionInProgress = true;
try {
await StackService.stop(endpoint.Id, $scope.stack.Id);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Unable to stop stack');
}
$scope.state.actionInProgress = false;
}
$scope.startStack = startStack;
function startStack() {
return $async(startStackAsync);
}
async function startStackAsync() {
$scope.state.actionInProgress = true;
const id = $scope.stack.Id;
try {
await StackService.start(endpoint.Id, id);
$state.reload();
} catch (err) {
Notifications.error('Failure', err, 'Unable to start stack');
}
$scope.state.actionInProgress = false;
}
function loadStack(id) {
return $async(async () => {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
@@ -250,3 +399,12 @@ angular.module('portainer.app').controller('StackController', [
initView();
},
]);
function confirmDetachment() {
return confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message: 'Do you want to detach the stack from Git?',
confirmButton: buildConfirmButton('Detach', 'danger'),
});
}

View File

@@ -1,7 +1,7 @@
import { StackId } from '../types';
export const queryKeys = {
base: () => ['stacks'] as const,
base: () => ['stacks'],
stack: (stackId?: StackId) => [...queryKeys.base(), stackId] as const,
stackFile: (stackId?: StackId, params?: unknown) =>
[...queryKeys.stack(stackId), 'file', params] as const,

View File

@@ -1,50 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { StackId } from '../types';
import { queryKeys } from './query-keys';
interface DeleteStackParams {
stackId: StackId;
stackName: string;
environmentId: EnvironmentId;
namespace: string;
}
export function useDeleteStackByNameMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteStackByName,
onSuccess: (_, variables) => {
// Invalidate the specific stack query
queryClient.invalidateQueries(queryKeys.stack(variables.stackId));
// Invalidate all stacks queries
queryClient.invalidateQueries(queryKeys.base());
},
...withGlobalError('Unable to delete stack'),
});
}
async function deleteStackByName({
environmentId,
namespace,
stackName,
}: DeleteStackParams) {
try {
await axios.delete(`/stacks/name/${stackName}`, {
params: {
external: false,
name: stackName,
endpointId: environmentId,
namespace,
},
});
} catch (error) {
throw parseAxiosError(error, 'Unable to delete stack');
}
}

View File

@@ -1,44 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { StackId } from '../types';
import { queryKeys } from './query-keys';
interface DeleteStackParams {
id?: StackId;
name?: string;
external: boolean;
environmentId: EnvironmentId;
}
export function useDeleteStackMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteStack,
onSuccess: () => queryClient.invalidateQueries(queryKeys.base()),
...withGlobalError('Unable to delete stack'),
});
}
async function deleteStack({
id,
name,
external,
environmentId,
}: DeleteStackParams) {
try {
await axios.delete(`/stacks/${id || name}`, {
params: {
external,
endpointId: environmentId,
},
});
} catch (error) {
throw parseAxiosError(error, 'Unable to delete stack');
}
}

View File

@@ -5,8 +5,6 @@ import {
RepoConfigResponse,
} from '@/react/portainer/gitops/types';
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
export type StackId = number;
export enum StackType {
@@ -36,7 +34,10 @@ export interface Stack {
EndpointId: number;
SwarmId: string;
EntryPoint: string;
Env: EnvVar[];
Env: {
name: string;
value: string;
}[];
ResourceControl?: ResourceControlResponse;
Status: StackStatus;
ProjectPath: string;
@@ -66,7 +67,7 @@ export type StackFile = {
};
export interface GitStackPayload {
env: Array<EnvVar>;
env: Array<{ name: string; value: string }>;
prune?: boolean;
RepositoryReferenceName?: string;
RepositoryAuthentication?: boolean;

View File

@@ -1,16 +1,10 @@
import clsx from 'clsx';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Info,
XCircle,
} from 'lucide-react';
import { AlertCircle, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
import { PropsWithChildren, ReactNode } from 'react';
import { Icon } from '@@/Icon';
type AlertType = 'success' | 'error' | 'info' | 'warn' | 'default';
type AlertType = 'success' | 'error' | 'info' | 'warn';
export const alertSettings: Record<
AlertType,
@@ -18,39 +12,32 @@ export const alertSettings: Record<
> = {
success: {
container:
'border-green-4 bg-green-2 th-dark:bg-green-10 th-dark:border-green-8 th-highcontrast:bg-green-10 th-highcontrast:border-white',
'border-green-4 bg-green-2 th-dark:bg-green-10 th-dark:border-green-8 th-highcontrast:bg-green-10 th-highcontrast:border-green-8',
header: 'text-green-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-green-7 th-dark:text-white th-highcontrast:text-white',
icon: CheckCircle,
},
error: {
container:
'border-error-4 bg-error-2 th-dark:bg-error-10 th-dark:border-error-8 th-highcontrast:bg-error-10 th-highcontrast:border-white',
'border-error-4 bg-error-2 th-dark:bg-error-10 th-dark:border-error-8 th-highcontrast:bg-error-10 th-highcontrast:border-error-8',
header: 'text-error-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-error-7 th-dark:text-white th-highcontrast:text-white',
icon: XCircle,
},
info: {
container:
'border-blue-4 bg-blue-2 th-dark:bg-blue-10 th-dark:border-blue-8 th-highcontrast:bg-blue-10 th-highcontrast:border-white',
'border-blue-4 bg-blue-2 th-dark:bg-blue-10 th-dark:border-blue-8 th-highcontrast:bg-blue-10 th-highcontrast:border-blue-8',
header: 'text-blue-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-blue-7 th-dark:text-white th-highcontrast:text-white',
icon: AlertCircle,
},
warn: {
container:
'border-warning-4 bg-warning-2 th-dark:bg-warning-10 th-dark:border-warning-8 th-highcontrast:bg-warning-10 th-highcontrast:border-white',
'border-warning-4 bg-warning-2 th-dark:bg-warning-10 th-dark:border-warning-8 th-highcontrast:bg-warning-10 th-highcontrast:border-warning-8',
header: 'text-warning-8 th-dark:text-white th-highcontrast:text-white',
body: 'text-warning-7 th-dark:text-white th-highcontrast:text-white',
icon: AlertTriangle,
},
default: {
container:
'border-graphite-200 bg-graphite-50 th-dark:bg-graphite-700 th-dark:border-graphite-500 th-highcontrast:bg-graphite-800 th-highcontrast:border-white',
header: 'text-graphite-600 th-dark:text-white th-highcontrast:text-white',
body: 'text-graphite-500 th-dark:text-white th-highcontrast:text-white',
icon: Info,
},
};
export function Alert({

View File

@@ -76,7 +76,7 @@ export function BoxSelectorItem<T extends Value>({
</div>
<ContentBox>
<div className={styles.header}>{option.label}</div>
<div className="mb-0">{option.description}</div>
<p className="mb-0">{option.description}</p>
</ContentBox>
</div>
</BoxOption>

View File

@@ -81,7 +81,6 @@
background-color: var(--bg-codemirror-gutters-color);
border-top-color: transparent;
color: var(--text-codemirror-color);
z-index: inherit;
}
.root :global(.cm-button) {

View File

@@ -31,7 +31,7 @@ export function RadioGroup<T extends string | number = string>({
key={option.value}
className={
itemClassName ??
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal cursor-pointer'
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal'
}
>
<input
@@ -43,7 +43,6 @@ export function RadioGroup<T extends string | number = string>({
style={{ margin: '0 4px 0 0' }}
data-cy={`radio-${option.value}`}
disabled={option.disabled}
className="cursor-pointer"
/>
{option.label}
</label>

View File

@@ -24,7 +24,6 @@ type Color =
| 'dangerlight'
| 'warninglight'
| 'warning'
| 'success'
| 'none';
type Size = 'xsmall' | 'small' | 'medium' | 'large';

View File

@@ -1,11 +1,10 @@
import { useState } from 'react';
import type { AriaAttributes } from 'react';
import {
GroupBase,
OptionsOrGroups,
SelectComponentsConfig,
} from 'react-select';
import _ from 'lodash';
import { AriaAttributes } from 'react';
import { FilterOptionOption } from 'react-select/dist/declarations/src/filters';
import { AutomationTestingProps } from '@/types';
@@ -49,7 +48,6 @@ interface SharedProps<TValue>
option: FilterOptionOption<Option<TValue>>,
rawInput: string
) => boolean;
getOptionValue?: (option: TValue) => string;
}
interface MultiProps<TValue> extends SharedProps<TValue> {
@@ -119,14 +117,13 @@ export function SingleSelect<TValue = string>({
loadingMessage,
isMulti,
size,
getOptionValue,
...aria
}: SingleProps<TValue>) {
const selectedValue =
value ||
(typeof value === 'number' && value === 0) ||
(typeof value === 'string' && value === '')
? _.first(findSelectedOptions<TValue>(options, value, getOptionValue))
? _.first(findSelectedOptions<TValue>(options, value))
: null;
return (
@@ -134,9 +131,7 @@ export function SingleSelect<TValue = string>({
name={name}
isClearable={isClearable}
getOptionLabel={(option) => option.label}
getOptionValue={(option) =>
getOptionValue ? getOptionValue(option.value) : String(option.value)
}
getOptionValue={(option) => String(option.value)}
options={options}
value={selectedValue}
onChange={(option) => onChange(option ? option.value : null)}
@@ -159,30 +154,19 @@ export function SingleSelect<TValue = string>({
);
}
function isSingleValue<TValue>(
value: TValue | readonly TValue[]
): value is TValue {
return !Array.isArray(value);
}
function findSelectedOptions<TValue>(
options: Options<TValue>,
value: TValue | readonly TValue[],
getOptionValue: (option: TValue) => string | TValue = (v: TValue) => v
value: TValue | readonly TValue[]
) {
const valueArr = isSingleValue(value)
? [getOptionValue(value)]
: value.map((v) => getOptionValue(v));
const valueArr = Array.isArray(value) ? value : [value];
const values = _.compact(
options.flatMap((option) => {
if (isGroup(option)) {
return option.options.find((opt) =>
valueArr.includes(getOptionValue(opt.value))
);
return option.options.find((option) => valueArr.includes(option.value));
}
if (valueArr.includes(getOptionValue(option.value))) {
if (valueArr.includes(option.value)) {
return option;
}
@@ -208,35 +192,27 @@ export function MultiSelect<TValue = string>({
components,
isLoading,
noOptionsMessage,
size,
loadingMessage,
formatCreateLabel,
onCreateOption,
isCreatable,
size,
getOptionValue,
...aria
}: Omit<MultiProps<TValue>, 'isMulti'>) {
const [inputValue, setInputValue] = useState('');
const selectedOptions = findSelectedOptions(options, value, getOptionValue);
const selectedOptions = findSelectedOptions(options, value);
const SelectComponent = isCreatable ? Creatable : ReactSelect;
return (
<SelectComponent
name={name}
isMulti
isClearable={isClearable}
getOptionLabel={(option) => option.label}
getOptionValue={(option) =>
getOptionValue ? getOptionValue(option.value) : String(option.value)
}
getOptionValue={(option) => String(option.value)}
isOptionDisabled={(option) => !!option.disabled}
options={options}
value={selectedOptions}
closeMenuOnSelect={false}
onChange={(newValue) => {
onChange(newValue.map((option) => option.value));
setInputValue('');
}}
onChange={(newValue) => onChange(newValue.map((option) => option.value))}
data-cy={dataCy}
id={dataCy}
inputId={inputId}
@@ -247,31 +223,14 @@ export function MultiSelect<TValue = string>({
components={components}
isLoading={isLoading}
noOptionsMessage={noOptionsMessage}
size={size}
loadingMessage={loadingMessage}
formatCreateLabel={formatCreateLabel}
onCreateOption={onCreateOption}
inputValue={inputValue}
onInputChange={(textInput) => setInputValue(textInput)}
onBlur={handleBlur}
size={size}
// eslint-disable-next-line react/jsx-props-no-spreading
{...aria}
/>
);
function handleBlur() {
const trimmed = inputValue.trim();
if (!trimmed || value.includes(trimmed as TValue)) {
setInputValue('');
return;
}
if (onCreateOption && isCreatable) {
onCreateOption(trimmed);
} else {
onChange([...value, trimmed as TValue]);
}
setInputValue('');
}
}
function isGroup<TValue>(

View File

View File

View File

@@ -1,181 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { Config } from 'docker-types/generated/1.44';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { server } from '@/setup-tests/server';
import { Role, User } from '@/portainer/users/types';
import { createMockUsers } from '@/react-tools/test-mocks';
import { ConfigsDatatable } from './ConfigsDatatable';
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: '1' },
})),
}));
vi.mock('@@/Link', () => ({
Link: ({
children,
'data-cy': dataCy,
}: {
children: React.ReactNode;
'data-cy'?: string;
}) => (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a data-cy={dataCy}>{children}</a>
),
}));
beforeEach(() => {
server.use(
http.get('/api/endpoints/1', () =>
HttpResponse.json({
Id: 1,
Name: 'test-environment',
Type: 1,
})
)
);
});
it('should return null when data is loading', () => {
server.use(
http.get('/api/endpoints/:environmentId/docker/configs', async () => {
// Never resolve to simulate loading state
await new Promise(() => {});
return HttpResponse.json([]);
})
);
const { container } = renderComponent();
// Component returns null while loading, so the container's first child is empty
expect(container.firstChild).toBeEmptyDOMElement();
});
it('should render datatable with configs', async () => {
const mockConfigs = [
createMockConfig({ ID: 'config-1', Spec: { Name: 'my-config-1' } }),
createMockConfig({ ID: 'config-2', Spec: { Name: 'my-config-2' } }),
];
server.use(
http.get('/api/endpoints/:environmentId/docker/configs', () =>
HttpResponse.json(mockConfigs)
)
);
renderComponent();
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Configs' })).toBeVisible();
});
expect(screen.getByText('my-config-1')).toBeVisible();
expect(screen.getByText('my-config-2')).toBeVisible();
});
it('should display config creation date formatted', async () => {
const mockConfigs = [
createMockConfig({
ID: 'config-1',
CreatedAt: '2024-06-15T14:30:00.000000000Z',
}),
];
server.use(
http.get('/api/endpoints/:environmentId/docker/configs', () =>
HttpResponse.json(mockConfigs)
)
);
renderComponent();
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Configs' })).toBeVisible();
});
expect(screen.getByText(/2024-06-15/)).toBeVisible();
});
it('should show Add config button for admin user', async () => {
server.use(
http.get('/api/endpoints/:environmentId/docker/configs', () =>
HttpResponse.json([createMockConfig()])
)
);
renderComponent(createAdminUser());
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Configs' })).toBeVisible();
});
expect(screen.getByText(/add config/i)).toBeVisible();
});
it('should show Add config button for standard user', async () => {
server.use(
http.get('/api/endpoints/:environmentId/docker/configs', () =>
HttpResponse.json([createMockConfig()])
)
);
renderComponent(createStandardUser());
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Configs' })).toBeVisible();
});
expect(screen.getByText(/add config/i)).toBeVisible();
});
it('should render empty datatable when no configs exist', async () => {
server.use(
http.get('/api/endpoints/:environmentId/docker/configs', () =>
HttpResponse.json([])
)
);
renderComponent();
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Configs' })).toBeVisible();
});
});
function createMockConfig(overrides: Partial<Config> = {}): Config {
return {
ID: 'config-id-1',
CreatedAt: '2024-01-15T10:30:00.000000000Z',
UpdatedAt: '2024-01-15T10:30:00.000000000Z',
Version: { Index: 1 },
Spec: {
Name: 'test-config',
Labels: {},
Data: btoa('config data'),
},
...overrides,
};
}
function createAdminUser(): User {
return createMockUsers(1, Role.Admin)[0];
}
function createStandardUser(): User {
return createMockUsers(1, Role.Standard)[0];
}
function renderComponent(user: User = createAdminUser()) {
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(ConfigsDatatable), user)
);
return render(<Wrapped />);
}

View File

@@ -1,43 +1,38 @@
import { Clipboard } from 'lucide-react';
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Datatable, TableSettingsMenu } from '@@/datatables';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useRepeater } from '@@/datatables/useRepeater';
import { AddButton } from '@@/buttons';
import { useTableState } from '@@/datatables/useTableState';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { useConfigsList } from '../../queries/useConfigs';
import { ConfigViewModel } from '../../model';
import { DockerConfig } from '../../types';
import { columns } from './columns';
import { createStore } from './store';
import { DeleteConfigButton } from './DeleteConfigButton';
interface Props {
dataset: Array<DockerConfig>;
onRemoveClick: (configs: Array<DockerConfig>) => void;
onRefresh: () => void;
}
const storageKey = 'docker_configs';
const settingsStore = createStore(storageKey);
export function ConfigsDatatable() {
const environmentId = useEnvironmentId();
export function ConfigsDatatable({ dataset, onRefresh, onRemoveClick }: Props) {
const tableState = useTableState(settingsStore, storageKey);
const configListQuery = useConfigsList(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
select: (configs) => configs.map((c) => new ConfigViewModel(c)),
});
useRepeater(tableState.autoRefreshRate, onRefresh);
const hasWriteAccessQuery = useAuthorizations([
'DockerConfigCreate',
'DockerConfigDelete',
]);
if (!configListQuery.data) {
return null;
}
const dataset = configListQuery.data;
return (
<Datatable
dataset={dataset}
@@ -59,7 +54,12 @@ export function ConfigsDatatable() {
hasWriteAccessQuery.authorized && (
<div className="flex items-center gap-3">
<Authorized authorizations="DockerConfigDelete">
<DeleteConfigButton selectedItems={selectedRows} />
<DeleteButton
disabled={selectedRows.length === 0}
data-cy="remove-docker-configs-button"
onConfirmed={() => onRemoveClick(selectedRows)}
confirmMessage="Do you want to remove the selected config(s)?"
/>
</Authorized>
<Authorized authorizations="DockerConfigCreate">

View File

@@ -1,165 +0,0 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { server } from '@/setup-tests/server';
import { Role } from '@/portainer/users/types';
import { createMockUsers } from '@/react-tools/test-mocks';
import { ConfigViewModel } from '../../model';
import { DeleteConfigButton } from './DeleteConfigButton';
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: '1' },
})),
}));
beforeEach(() => {
server.use(
http.get('/api/endpoints/1', () =>
HttpResponse.json({
Id: 1,
Name: 'test-environment',
Type: 1,
})
)
);
});
afterEach(() => {
// Clean up any portal elements (modals) that may have been left behind
document.body.querySelectorAll('reach-portal').forEach((el) => el.remove());
});
it('should render disabled when no items selected', () => {
renderComponent([]);
const button = screen.getByRole('button', { name: /remove/i });
expect(button).toBeVisible();
expect(button).toBeDisabled();
});
it('should render enabled with selected items', () => {
const selectedItems = [createMockConfigViewModel({ Id: 'config-1' })];
renderComponent(selectedItems);
const button = screen.getByRole('button', { name: /remove/i });
expect(button).toBeVisible();
expect(button).toBeEnabled();
});
it('should show confirmation dialog on click', async () => {
const user = userEvent.setup();
const selectedItems = [createMockConfigViewModel({ Id: 'config-1' })];
renderComponent(selectedItems);
const button = screen.getByRole('button', { name: /remove/i });
await user.click(button);
await waitFor(() => {
expect(
screen.getByText('Do you want to remove the selected config(s)?')
).toBeVisible();
});
});
it('should call delete API for each selected config on confirm', async () => {
const user = userEvent.setup();
const deletedConfigIds: string[] = [];
server.use(
http.delete(
'/api/endpoints/:environmentId/docker/configs/:configId',
({ params }) => {
deletedConfigIds.push(params.configId as string);
return HttpResponse.json({});
}
)
);
const selectedItems = [
createMockConfigViewModel({ Id: 'config-1' }),
createMockConfigViewModel({ Id: 'config-2' }),
];
renderComponent(selectedItems);
await user.click(screen.getByRole('button', { name: /remove/i }));
const dialog = await screen.findByRole('dialog', { name: 'Are you sure?' });
await user.click(within(dialog).getByRole('button', { name: /remove/i }));
await waitFor(() => {
expect(deletedConfigIds).toContain('config-1');
expect(deletedConfigIds).toContain('config-2');
});
});
it('should not call delete API when cancel is clicked', async () => {
const user = userEvent.setup();
let deleteCallCount = 0;
server.use(
http.delete(
'/api/endpoints/:environmentId/docker/configs/:configId',
() => {
deleteCallCount += 1;
return HttpResponse.json({});
}
)
);
const selectedItems = [createMockConfigViewModel({ Id: 'config-1' })];
renderComponent(selectedItems);
await user.click(screen.getByRole('button', { name: /remove/i }));
const dialog = await screen.findByRole('dialog', { name: 'Are you sure?' });
await user.click(within(dialog).getByRole('button', { name: /cancel/i }));
// Wait a bit to ensure no API call was made
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(deleteCallCount).toBe(0);
});
function createMockConfigViewModel(
overrides: Partial<ConfigViewModel> = {}
): ConfigViewModel {
return {
Id: 'config-id-1',
Name: 'test-config',
CreatedAt: '2024-01-15T10:30:00.000000000Z',
UpdatedAt: '2024-01-15T10:30:00.000000000Z',
Version: 1,
Labels: {},
Data: 'config data',
...overrides,
} as ConfigViewModel;
}
function renderComponent(selectedItems: ConfigViewModel[] = []) {
const user = createMockUsers(1, Role.Admin)[0];
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<DeleteConfigButton selectedItems={selectedItems} />
)),
user
)
);
return render(<Wrapped />);
}

View File

@@ -1,60 +0,0 @@
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { pluralize } from '@/portainer/helpers/strings';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { ConfigViewModel } from '../../model';
import { queryKeys } from '../../queries/query-keys';
import { deleteConfig } from '../../queries/useDeleteConfigMutation';
export function DeleteConfigButton({
selectedItems,
}: {
selectedItems: Array<ConfigViewModel>;
}) {
const environmentId = useEnvironmentId();
const mutation = useDeleteConfigListMutation(environmentId);
return (
<DeleteButton
data-cy="remove-docker-configs-button"
onConfirmed={() => {
mutation.mutate(
selectedItems.map((item) => item.Id),
{
onSuccess() {
notifySuccess(
`${pluralize(
selectedItems.length,
'Config'
)} successfully removed`,
// log the item name if it's only one config
selectedItems.length === 1 ? selectedItems[0].Name : ''
);
},
}
);
}}
confirmMessage="Do you want to remove the selected config(s)?"
disabled={selectedItems.length === 0}
/>
);
}
function useDeleteConfigListMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (ids: Array<string>) =>
promiseSequence(
ids.map((configId) => () => deleteConfig({ environmentId, configId }))
),
...withGlobalError('Unable to remove configs'),
...withInvalidate(queryClient, [queryKeys.base(environmentId)]),
});
}

View File

@@ -3,18 +3,18 @@ import { createColumnHelper } from '@tanstack/react-table';
import { isoDate } from '@/portainer/filters/filters';
import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
import { ConfigViewModel } from '../../model';
import { DockerConfig } from '../../types';
const columnHelper = createColumnHelper<ConfigViewModel>();
const columnHelper = createColumnHelper<DockerConfig>();
export const columns = [
buildNameColumnFromObject<ConfigViewModel>({
nameKey: 'Name',
path: 'docker.configs.config',
dataCy: 'docker-configs-name',
}),
buildNameColumn<DockerConfig>(
'Name',
'docker.configs.config',
'docker-configs-name'
),
columnHelper.accessor('CreatedAt', {
header: 'Creation Date',
cell: ({ getValue }) => {
@@ -22,5 +22,5 @@ export const columns = [
return <time dateTime={date}>{isoDate(date)}</time>;
},
}),
createOwnershipColumn<ConfigViewModel>(),
createOwnershipColumn<DockerConfig>(),
];

View File

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

View File

@@ -1,70 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { server } from '@/setup-tests/server';
import { Role } from '@/portainer/users/types';
import { createMockUsers } from '@/react-tools/test-mocks';
import { ListView } from './ListView';
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: '1' },
})),
}));
vi.mock('@@/Link', () => ({
Link: ({
children,
'data-cy': dataCy,
}: {
children: React.ReactNode;
'data-cy'?: string;
}) => (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a data-cy={dataCy}>{children}</a>
),
}));
function renderComponent() {
const user = createMockUsers(1, Role.Admin)[0];
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(ListView), user)
);
return render(<Wrapped />);
}
describe('ListView', () => {
beforeEach(() => {
server.use(
http.get('/api/endpoints/1', () =>
HttpResponse.json({
Id: 1,
Name: 'test-environment',
Type: 1,
})
),
http.get('/api/endpoints/:environmentId/docker/configs', () =>
HttpResponse.json([])
)
);
});
it('should render correctly', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Configs' })).toBeVisible();
});
expect(
screen.getByRole('heading', { name: 'Configs list', level: 1 })
).toBeVisible();
});
});

View File

@@ -1,13 +0,0 @@
import { PageHeader } from '@@/PageHeader';
import { ConfigsDatatable } from './ConfigsDatatable/ConfigsDatatable';
export function ListView() {
return (
<>
<PageHeader title="Configs list" breadcrumbs="Configs" reload />
<ConfigsDatatable />
</>
);
}

View File

@@ -1,55 +0,0 @@
import { Config } from 'docker-types/generated/1.44';
import { PortainerResponse } from '@/react/docker/types';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { IResource } from '../components/datatable/createOwnershipColumn';
export class ConfigViewModel implements IResource {
Id: string;
CreatedAt: string;
UpdatedAt: string;
Version: number;
Name: string;
Labels: Record<string, string>;
Data: string;
ResourceControl?: ResourceControlViewModel;
constructor(data: PortainerResponse<Config>) {
this.Id = data.ID || '';
this.CreatedAt = data.CreatedAt || '';
this.UpdatedAt = data.UpdatedAt || '';
this.Version = data.Version?.Index || 0;
this.Name = data.Spec?.Name || '';
this.Labels = data.Spec?.Labels || {};
this.Data = b64DecodeUnicode(data.Spec?.Data || '');
if (data.Portainer && data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(
data.Portainer.ResourceControl
);
}
}
}
function b64DecodeUnicode(str: string) {
try {
return decodeURIComponent(
window
.atob(str)
.toString()
.split('')
.map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
.join('')
);
} catch (err) {
return window.atob(str);
}
}

View File

@@ -1,6 +0,0 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildDockerProxyUrl } from '@/react/docker/proxy/queries/buildDockerProxyUrl';
export function buildUrl(environmentId: EnvironmentId, id = '', action = '') {
return buildDockerProxyUrl(environmentId, 'configs', id, action);
}

View File

@@ -1,8 +0,0 @@
import { queryKeys as proxyQueryKeys } from '@/react/docker/proxy/queries/query-keys';
import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = {
base: (environmentId: EnvironmentId) =>
[...proxyQueryKeys.base(environmentId), 'configs'] as const,
list: (environmentId: EnvironmentId) => queryKeys.base(environmentId),
};

View File

@@ -3,17 +3,16 @@ import { Config } from 'docker-types/generated/1.44';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { PortainerResponse } from '../../types';
import { buildUrl } from './build-url';
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
import { DockerConfig } from '../types';
export async function getConfig(
environmentId: EnvironmentId,
configId: Config['ID']
configId: DockerConfig['Id']
) {
try {
const { data } = await axios.get<PortainerResponse<Config>>(
buildUrl(environmentId, configId)
const { data } = await axios.get<Config>(
buildDockerProxyUrl(environmentId, 'configs', configId)
);
return data;
} catch (e) {

View File

@@ -1,30 +1,10 @@
import { Config } from 'docker-types/generated/1.44';
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query';
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
import { queryKeys } from './query-keys';
export function useConfigsList<T>(
environmentId: EnvironmentId,
{
refetchInterval,
select,
}: { refetchInterval?: number; select?: (configs: Config[]) => T } = {}
) {
return useQuery({
queryKey: queryKeys.list(environmentId),
queryFn: () => getConfigs(environmentId),
refetchInterval,
select,
...withGlobalError('Unable to retrieve configs'),
});
}
export async function getConfigs(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<Config[]>(

View File

@@ -1,26 +1,16 @@
import { useMutation } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildUrl } from './build-url';
import { DockerConfig } from '../types';
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
export function useDeleteConfigMutation() {
return useMutation({
mutationFn: deleteConfig,
});
}
export async function deleteConfig({
environmentId,
configId,
}: {
environmentId: EnvironmentId;
configId: string;
}) {
export async function deleteConfig(
environmentId: EnvironmentId,
id: DockerConfig['Id']
) {
try {
await axios.delete(buildUrl(environmentId, configId));
} catch (err) {
throw parseAxiosError(err, 'Unable to delete config');
await axios.delete(buildDockerProxyUrl(environmentId, 'configs', id));
} catch (e) {
throw parseAxiosError(e, 'Unable to delete config');
}
}

View File

@@ -149,6 +149,11 @@ describe('form submission', () => {
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
});
// Make form dirty
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' # modified');
// Submit form
const deployButton = screen.getByTestId('stack-deploy-button');
await waitFor(() => {
expect(deployButton).toBeEnabled();
@@ -171,6 +176,9 @@ describe('form submission', () => {
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
});
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' ');
const deployButton = screen.getByTestId('stack-deploy-button');
await waitFor(() => {
expect(deployButton).toBeEnabled();
@@ -200,21 +208,17 @@ describe('form submission', () => {
})
);
const initialValues: Partial<StackEditorFormValues> = {
stackFileContent: 'version: "3.8"',
environmentVariables: [],
webhookId: '',
prune: false,
registries: [],
};
renderComponent({ stackId: 42, initialValues });
renderComponent({ stackId: 42 });
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
});
const editor = screen.getByTestId('stack-editor');
await user.clear(editor);
await user.type(editor, 'version: "3.8"');
const deployButton = screen.getByTestId('stack-deploy-button');
await waitFor(() => {
expect(deployButton).toBeEnabled();
@@ -255,6 +259,9 @@ describe('form submission', () => {
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
});
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' ');
const deployButton = screen.getByTestId('stack-deploy-button');
await waitFor(() => {
expect(deployButton).toBeEnabled();
@@ -283,6 +290,9 @@ describe('form submission', () => {
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
});
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' ');
const deployButton = screen.getByTestId('stack-deploy-button');
await waitFor(() => {
expect(deployButton).toBeEnabled();
@@ -313,6 +323,9 @@ describe('form submission', () => {
expect(screen.getByTestId('stack-editor')).toBeInTheDocument();
});
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' ');
const deployButton = screen.getByTestId('stack-deploy-button');
await waitFor(() => {
expect(deployButton).toBeEnabled();

View File

@@ -357,8 +357,14 @@ describe('version rollback', () => {
});
describe('form submission', () => {
it('should enable submit button when form is valid', async () => {
renderComponent();
it('should enable submit button when form is valid and dirty', async () => {
const onSubmit = vi.fn();
renderComponent({}, { onSubmit });
const user = userEvent.setup();
// Make form dirty by changing content
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' ');
await waitFor(() => {
const deployButton = screen.getByTestId('stack-deploy-button');
@@ -366,9 +372,23 @@ describe('form submission', () => {
});
});
it('should disable submit button when form is not dirty', async () => {
renderComponent();
await waitFor(() => {
const deployButton = screen.getByTestId('stack-deploy-button');
expect(deployButton).toBeDisabled();
});
});
it('should disable submit button when stack is orphaned', async () => {
const onSubmit = vi.fn();
renderComponent({ isOrphaned: true }, { onSubmit });
const user = userEvent.setup();
// Try to make form dirty
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' ');
await waitFor(() => {
const deployButton = screen.queryByTestId('stack-deploy-button');
@@ -383,6 +403,10 @@ describe('form submission', () => {
renderComponent({}, { onSubmit });
const user = userEvent.setup();
// Make form dirty
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' ');
await waitFor(() => {
const deployButton = screen.getByTestId('stack-deploy-button');
expect(deployButton).toBeEnabled();
@@ -396,11 +420,15 @@ describe('form submission', () => {
});
});
it('should call onSubmit with form values when button is clicked', async () => {
it('should call onSubmit with form values', async () => {
const onSubmit = vi.fn();
renderComponent({}, { onSubmit });
const user = userEvent.setup();
// Make form dirty
const editor = screen.getByTestId('stack-editor');
await user.type(editor, ' # comment');
await waitFor(() => {
const deployButton = screen.getByTestId('stack-deploy-button');
expect(deployButton).toBeEnabled();
@@ -410,7 +438,12 @@ describe('form submission', () => {
await user.click(deployButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
stackFileContent: expect.stringContaining('# comment'),
}),
expect.anything()
);
});
});
});

View File

@@ -51,6 +51,7 @@ export function StackEditorTabInner({
setFieldValue,
isSubmitting,
isValid,
dirty,
initialValues,
} = useFormikContext<StackEditorFormValues>();
@@ -167,7 +168,7 @@ export function StackEditorTabInner({
<Authorized authorizations="PortainerStackUpdate">
<FormActions
isValid={isValid && !isDeployDisabled}
isValid={isValid && !isDeployDisabled && dirty}
isLoading={isSubmitting}
loadingText="Deployment in progress..."
submitLabel="Update the stack"

View File

@@ -1,310 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { createMockUsers } from '@/react-tools/test-mocks';
import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
import { AssociateStackForm } from './AssociateStackForm';
// Mock the useSwarmId hook to avoid React Query complexity
vi.mock('@/react/docker/proxy/queries/useSwarm', () => ({
useSwarmId: vi.fn(),
}));
// Mock the AccessControlForm to simplify testing
vi.mock('@/react/portainer/access-control', () => ({
AccessControlForm: vi.fn(({ onChange, values }) => (
<div data-cy="access-control-form">
<button
type="button"
onClick={() =>
onChange({
...values,
ownership: 'private',
})
}
>
Change Access Control
</button>
</div>
)),
}));
beforeEach(() => {
vi.mocked(useSwarmId).mockReturnValue({
data: undefined,
} as ReturnType<typeof useSwarmId>);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render correctly', () => {
renderComponent();
expect(screen.getByText('Associate to this environment')).toBeVisible();
expect(
screen.getByText(/This feature allows you to re-associate this stack/i)
).toBeVisible();
expect(screen.getByTestId('access-control-form')).toBeVisible();
expect(screen.getByRole('button', { name: 'Associate' })).toBeVisible();
});
describe('form submission', () => {
it('should call mutation with correct payload on submit', async () => {
let requestUrl = '';
server.use(
http.put<{ id: string }>(
'/api/stacks/:id/associate',
async ({ request, params }) => {
requestUrl = request.url;
return HttpResponse.json(createMockStackResponse(params.id));
}
),
http.put('/api/resource_controls/:id', async ({ request }) => {
await request.json();
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
renderComponent({
environmentId: 5,
stackId: 123,
isOrphanedRunning: true,
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Associate' })).toBeVisible();
});
const associateButton = screen.getByRole('button', { name: 'Associate' });
await user.click(associateButton);
await waitFor(() => {
expect(requestUrl).toContain('endpointId=5');
expect(requestUrl).toContain('orphanedRunning=true');
});
});
it('should show loading state during submission', async () => {
let associateCalled = false;
server.use(
http.put('/api/stacks/:id/associate', async () => {
associateCalled = true;
await delay(50);
return HttpResponse.json(createMockStackResponse());
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const user = userEvent.setup();
renderComponent();
const associateButton = screen.getByRole('button', { name: 'Associate' });
await user.click(associateButton);
// Check for loading text
expect(screen.getByText(/association in progress/i)).toBeVisible();
// Wait for API call
await waitFor(
() => {
expect(associateCalled).toBe(true);
},
{ timeout: 2000 }
);
});
it('should complete association successfully', async () => {
let associateCalled = false;
let resourceControlCalled = false;
server.use(
http.put('/api/stacks/:id/associate', () => {
associateCalled = true;
return HttpResponse.json(createMockStackResponse());
}),
http.put('/api/resource_controls/:id', () => {
resourceControlCalled = true;
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
renderComponent({ stackName: 'my-stack' });
const associateButton = screen.getByRole('button', { name: 'Associate' });
await user.click(associateButton);
// Verify both API calls were made
await waitFor(
() => {
expect(associateCalled).toBe(true);
expect(resourceControlCalled).toBe(true);
},
{ timeout: 3000 }
);
});
});
describe('swarmId integration', () => {
it('should pass swarmId when environment is in swarm mode', async () => {
let requestUrl = '';
vi.mocked(useSwarmId).mockReturnValue({
data: 'swarm-id-123',
} as ReturnType<typeof useSwarmId>);
server.use(
http.put('/api/stacks/:id/associate', async ({ request }) => {
requestUrl = request.url;
return HttpResponse.json(createMockStackResponse());
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const user = userEvent.setup();
renderComponent({ environmentId: 1 });
const associateButton = screen.getByRole('button', { name: 'Associate' });
await user.click(associateButton);
await waitFor(() => {
expect(requestUrl).toContain('swarmId=swarm-id-123');
});
});
it('should not pass swarmId when environment is not in swarm mode', async () => {
let requestUrl = '';
vi.mocked(useSwarmId).mockReturnValue({
data: undefined,
} as ReturnType<typeof useSwarmId>);
server.use(
http.put('/api/stacks/:id/associate', async ({ request }) => {
requestUrl = request.url;
return HttpResponse.json(createMockStackResponse());
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const user = userEvent.setup();
renderComponent();
const associateButton = screen.getByRole('button', { name: 'Associate' });
await user.click(associateButton);
// Verify swarmId is not included in the request
await waitFor(() => {
expect(requestUrl).not.toContain('swarmId');
});
});
});
describe('orphanedRunning parameter', () => {
it('should pass isOrphanedRunning=true when provided', async () => {
let requestUrl = '';
server.use(
http.put('/api/stacks/:id/associate', async ({ request }) => {
requestUrl = request.url;
return HttpResponse.json(createMockStackResponse());
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const user = userEvent.setup();
renderComponent({ isOrphanedRunning: true });
const associateButton = screen.getByRole('button', { name: 'Associate' });
await user.click(associateButton);
await waitFor(() => {
expect(requestUrl).toContain('orphanedRunning=true');
});
});
it('should pass isOrphanedRunning=false when undefined', async () => {
let requestUrl = '';
server.use(
http.put('/api/stacks/:id/associate', async ({ request }) => {
requestUrl = request.url;
return HttpResponse.json(createMockStackResponse());
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const user = userEvent.setup();
renderComponent({ isOrphanedRunning: undefined });
const associateButton = screen.getByRole('button', { name: 'Associate' });
await user.click(associateButton);
await waitFor(() => {
expect(requestUrl).toContain('orphanedRunning=false');
});
});
});
function renderComponent({
stackName = 'test-stack',
environmentId = 1,
stackId = 123,
isOrphanedRunning,
}: Partial<React.ComponentProps<typeof AssociateStackForm>> = {}) {
const users = createMockUsers(1, [1]);
server.use(
http.get('/api/users/:id', () => HttpResponse.json(users[0])),
http.get('/api/endpoints/:id/docker/swarm', () =>
HttpResponse.json({ message: 'Not in swarm mode' }, { status: 503 })
)
);
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(AssociateStackForm))
);
return render(
<Wrapped
stackName={stackName}
environmentId={environmentId}
stackId={stackId}
isOrphanedRunning={isOrphanedRunning}
/>
);
}
function createMockStackResponse(stackId = '123') {
return {
Id: stackId,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: stackId,
Type: 6,
},
};
}

View File

@@ -1,115 +0,0 @@
import { RefreshCw } from 'lucide-react';
import { Form, Formik, useFormikContext } from 'formik';
import { object } from 'yup';
import { useRouter } from '@uirouter/react';
import { AccessControlForm } from '@/react/portainer/access-control';
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { validationSchema as accessControlValidation } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation';
import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
import { notifySuccess } from '@/portainer/services/notifications';
import { LoadingButton } from '@@/buttons';
import { FormSection } from '@@/form-components/FormSection';
import { useAssociateStackToEnvironmentMutation } from './useAssociateStackToEnvironmentMutation';
function validationSchema({ isAdmin }: { isAdmin: boolean }) {
return object({
accessControl: accessControlValidation(isAdmin),
});
}
export function AssociateStackForm({
stackName,
environmentId,
stackId,
isOrphanedRunning,
}: {
stackName: string;
environmentId: EnvironmentId;
stackId: number;
isOrphanedRunning: boolean | undefined;
}) {
const router = useRouter();
const swarmIdQuery = useSwarmId(environmentId);
const mutation = useAssociateStackToEnvironmentMutation();
const { user } = useCurrentUser();
const { isAdmin } = useIsEdgeAdmin();
const initialValues: FormValues = {
accessControl: parseAccessControlFormData(isAdmin, user.Id),
};
return (
<FormSection title="Associate to this environment">
<p className="small text-muted">
This feature allows you to re-associate this stack to the current
environment.
</p>
<Formik
initialValues={initialValues}
onSubmit={(values) => {
mutation.mutate(
{
environmentId,
accessControl: values.accessControl,
swarmId: swarmIdQuery.data,
isOrphanedRunning,
stackId,
},
{
onSuccess() {
notifySuccess('Stack successfully associated', stackName);
router.stateService.go('docker.stacks');
},
}
);
}}
validateOnMount
validationSchema={() => validationSchema({ isAdmin })}
>
<InnerForm environmentId={environmentId} />
</Formik>
</FormSection>
);
}
type FormValues = {
accessControl: AccessControlFormData;
};
function InnerForm({ environmentId }: { environmentId: EnvironmentId }) {
const { values, setFieldValue, errors, isSubmitting } =
useFormikContext<FormValues>();
return (
<Form className="form-horizontal">
<AccessControlForm
values={values.accessControl}
onChange={(newValues) => setFieldValue('accessControl', newValues)}
hideTitle
environmentId={environmentId}
errors={errors.accessControl}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
color="primary"
size="small"
isLoading={isSubmitting}
loadingText="Association in progress..."
icon={RefreshCw}
className="-ml-1.25"
data-cy="stack-associate-btn"
>
Associate
</LoadingButton>
</div>
</div>
</Form>
);
}

View File

@@ -1,245 +0,0 @@
import {
ArrowRightIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
Trash2Icon,
} from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { Authorized } from '@/react/hooks/useUser';
import { Stack, StackStatus } from '@/react/common/stacks/types';
import { useDeleteStackMutation } from '@/react/common/stacks/queries/useDeleteStackMutation';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Button, LoadingButton } from '@@/buttons';
import { Link } from '@@/Link';
import { confirm, confirmDelete } from '@@/modals/confirm';
import { ModalType } from '@@/modals/Modal/types';
import { buildConfirmButton } from '@@/modals/utils';
import { useUpdateStackMutation } from '../../useUpdateStack';
import { useStartStackMutation } from './useStartStackMutation';
import { useStopStackMutation } from './useStopStackMutation';
export function StackActions({
stack,
fileContent,
isRegular,
environmentId,
isExternal,
}: {
stack: Stack;
fileContent?: string;
isRegular?: boolean;
environmentId: number;
isExternal: boolean;
}) {
const router = useRouter();
const startStackMutation = useStartStackMutation();
const stopStackMutation = useStopStackMutation();
const deleteStackMutation = useDeleteStackMutation();
const detachFromGitMutation = useUpdateStackMutation();
const isMutating =
startStackMutation.isLoading ||
stopStackMutation.isLoading ||
deleteStackMutation.isLoading ||
detachFromGitMutation.isLoading;
const stackId = stack.Id;
const status = stack.Status;
return (
<div className="flex items-center gap-2">
{isRegular && (
<Authorized authorizations="PortainerStackUpdate">
{status === StackStatus.Active ? (
<Button
icon={StopCircleIcon}
color="dangerlight"
size="xsmall"
onClick={() => handleStop()}
disabled={isMutating}
data-cy="stack-stop-btn"
>
Stop this stack
</Button>
) : (
<Button
icon={PlayIcon}
color="success"
data-cy="stack-start-btn"
size="xsmall"
disabled={isMutating}
onClick={() =>
startStackMutation.mutate(
{ id: stackId, environmentId },
{
onError(err) {
notifyError(
'Failure',
err as Error,
'Unable to start stack'
);
},
onSuccess() {
notifySuccess(
'Success',
`Stack ${stack.Name} started successfully`
);
router.stateService.reload();
},
}
)
}
>
Start this stack
</Button>
)}
</Authorized>
)}
<Authorized authorizations="PortainerStackDelete">
<Button
icon={Trash2Icon}
color="dangerlight"
size="xsmall"
onClick={() => handleDelete()}
disabled={isMutating}
data-cy="stack-delete-btn"
>
Delete this stack
</Button>
</Authorized>
{!!(isRegular && fileContent) && (
<Button
as={Link}
icon={PlusIcon}
color="primary"
size="xsmall"
data-cy="stack-create-template-btn"
props={{
to: 'docker.templates.custom.new',
params: {
fileContent,
type: stack.Type,
},
}}
>
Create template from stack
</Button>
)}
{!!(
isRegular &&
fileContent &&
!stack.FromAppTemplate &&
stack.GitConfig
) && (
<Authorized authorizations="PortainerStackUpdate">
<LoadingButton
icon={ArrowRightIcon}
color="primary"
size="xsmall"
onClick={() => handleDetachFromGit()}
disabled={isMutating}
data-cy="stack-detach-git-btn"
isLoading={detachFromGitMutation.isLoading}
loadingText="Detachment in progress..."
>
Detach from Git
</LoadingButton>
</Authorized>
)}
</div>
);
async function handleStop() {
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
message: 'Are you sure you want to stop this stack?',
confirmButton: buildConfirmButton('Stop', 'danger'),
});
if (!confirmed) {
return;
}
stopStackMutation.mutate(
{ id: stackId, environmentId },
{
onError(err) {
notifyError('Failure', err as Error, 'Unable to stop stack');
},
onSuccess() {
notifySuccess('Success', `Stack ${stack.Name} stopped successfully`);
router.stateService.reload();
},
}
);
}
async function handleDelete() {
const confirmed = await confirmDelete(
'Do you want to remove the stack? Associated services will be removed as well'
);
if (!confirmed) {
return;
}
deleteStackMutation.mutate(
{
id: stack.Id,
name: stack.Name,
environmentId: stack.EndpointId,
external: isExternal,
},
{
onError(err) {
notifyError(
'Failure',
err as Error,
`Unable to remove stack ${stack.Name}`
);
},
onSuccess() {
notifySuccess('Stack successfully removed', stack.Name);
router.stateService.go('^');
},
}
);
}
async function handleDetachFromGit() {
const confirmed = await confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message: 'Do you want to detach the stack from Git?',
confirmButton: buildConfirmButton('Detach', 'danger'),
});
if (!confirmed) {
return;
}
detachFromGitMutation.mutate(
{
environmentId,
stackId: stack.Id,
payload: {
stackFileContent: fileContent!,
env: stack.Env,
prune: false,
},
},
{
onSuccess() {
router.stateService.go('^');
},
}
);
}
}

View File

@@ -1,361 +0,0 @@
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { Stack, StackStatus, StackType } from '@/react/common/stacks/types';
import { StackInfoTab } from './StackInfoTab';
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: vi.fn(() => 1),
}));
// Mock the child components to isolate testing
vi.mock('./AssociateStackForm', () => ({
AssociateStackForm: vi.fn(() => (
<div data-cy="associate-stack-form">AssociateStackForm</div>
)),
}));
vi.mock('./StackActions', () => ({
StackActions: vi.fn(() => <div data-cy="stack-actions">StackActions</div>),
}));
vi.mock(
'@/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm',
() => ({
StackDuplicationForm: vi.fn(() => (
<div data-cy="stack-duplication-form">StackDuplicationForm</div>
)),
})
);
vi.mock('./StackRedeployGitForm/StackRedeployGitForm', () => ({
StackRedeployGitForm: vi.fn(() => (
<div data-cy="stack-redeploy-git-form">StackRedeployGitForm</div>
)),
}));
describe('StackInfoTab', () => {
describe('initial rendering', () => {
it('should render stack name', () => {
renderComponent({ stackName: 'my-test-stack' });
expect(screen.getByText('my-test-stack')).toBeVisible();
});
it('should render StackActions when stack exists', () => {
const mockStack = createMockStack();
renderComponent({ stack: mockStack });
expect(screen.getByTestId('stack-actions')).toBeVisible();
});
it('should not render StackActions when stack is undefined', () => {
renderComponent({ stack: undefined });
expect(screen.queryByTestId('stack-actions')).not.toBeInTheDocument();
});
it('should render stack details section', () => {
renderComponent();
expect(screen.getByText('Stack details')).toBeVisible();
});
});
describe('external and orphaned warnings', () => {
it('should show external stack warning when isExternal is true', () => {
renderComponent({ isExternal: true });
expect(
screen.getByText(/This stack was created outside of Portainer/i)
).toBeVisible();
expect(screen.getByText('Information')).toBeVisible();
});
it('should show orphaned stack warning when isOrphaned is true', () => {
renderComponent({ isOrphaned: true });
expect(screen.getByText(/This stack is orphaned/i)).toBeVisible();
expect(screen.getByText(/Associate to this environment/i)).toBeVisible();
});
it('should show orphaned warning when isOrphanedRunning is true', () => {
renderComponent({ isOrphanedRunning: true, isOrphaned: false });
expect(screen.getByText(/This stack is orphaned/i)).toBeVisible();
});
it('should show orphaned warning when both isOrphaned and isOrphanedRunning are true', () => {
renderComponent({ isOrphaned: true, isOrphanedRunning: true });
expect(screen.getByText(/This stack is orphaned/i)).toBeVisible();
});
it('should show both warnings when isExternal and isOrphaned are true', () => {
renderComponent({ isExternal: true, isOrphaned: true });
expect(
screen.getByText(/This stack was created outside of Portainer/i)
).toBeVisible();
expect(screen.getByText(/This stack is orphaned/i)).toBeVisible();
});
it('should not show warnings when both isExternal and isOrphaned are false', () => {
renderComponent({ isExternal: false, isOrphaned: false });
expect(screen.queryByText('Information')).not.toBeInTheDocument();
expect(
screen.queryByText(/This stack was created outside of Portainer/i)
).not.toBeInTheDocument();
expect(
screen.queryByText(/This stack is orphaned/i)
).not.toBeInTheDocument();
});
});
describe('conditional form rendering', () => {
it('should render AssociateStackForm when stack is orphaned', () => {
const mockStack = createMockStack();
renderComponent({
stack: mockStack,
isOrphaned: true,
});
expect(screen.getByTestId('associate-stack-form')).toBeVisible();
expect(
screen.queryByTestId('stack-duplication-form')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('stack-redeploy-git-form')
).not.toBeInTheDocument();
});
it('should not render AssociateStackForm when only isOrphanedRunning is true', () => {
const mockStack = createMockStack();
renderComponent({
stack: mockStack,
isOrphanedRunning: true,
isOrphaned: false,
});
// isOrphanedRunning alone doesn't trigger the form, only isOrphaned does
expect(
screen.queryByTestId('associate-stack-form')
).not.toBeInTheDocument();
// Should show duplication form instead if regular
expect(screen.getByTestId('stack-duplication-form')).toBeVisible();
});
it('should render StackRedeployGitForm when stack has GitConfig and is not from template', () => {
const mockStack = createMockStack({
GitConfig: {
URL: 'https://github.com/test/repo',
ReferenceName: 'main',
ConfigFilePath: 'docker-compose.yml',
ConfigHash: '',
TLSSkipVerify: false,
},
FromAppTemplate: false,
});
renderComponent({
stack: mockStack,
isOrphaned: false,
isRegular: true,
});
expect(screen.getByTestId('stack-redeploy-git-form')).toBeVisible();
});
it('should not render StackRedeployGitForm when stack is from app template', () => {
const mockStack = createMockStack({
GitConfig: {
URL: 'https://github.com/test/repo',
ReferenceName: 'main',
ConfigFilePath: 'docker-compose.yml',
ConfigHash: '',
TLSSkipVerify: false,
},
FromAppTemplate: true,
});
renderComponent({
stack: mockStack,
isOrphaned: false,
isRegular: true,
});
expect(
screen.queryByTestId('stack-redeploy-git-form')
).not.toBeInTheDocument();
});
it('should not render StackRedeployGitForm when stack has no GitConfig', () => {
const mockStack = createMockStack({
GitConfig: undefined,
});
renderComponent({
stack: mockStack,
isOrphaned: false,
isRegular: true,
});
expect(
screen.queryByTestId('stack-redeploy-git-form')
).not.toBeInTheDocument();
});
it('should render StackDuplicationForm when stack is regular and not orphaned', () => {
const mockStack = createMockStack();
renderComponent({
stack: mockStack,
isRegular: true,
isOrphaned: false,
});
expect(screen.getByTestId('stack-duplication-form')).toBeVisible();
});
it('should not render StackDuplicationForm when stack is not regular', () => {
const mockStack = createMockStack();
renderComponent({
stack: mockStack,
isRegular: false,
isOrphaned: false,
});
expect(
screen.queryByTestId('stack-duplication-form')
).not.toBeInTheDocument();
});
it('should not render any forms when stack is undefined', () => {
renderComponent({ stack: undefined });
expect(
screen.queryByTestId('associate-stack-form')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('stack-duplication-form')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('stack-redeploy-git-form')
).not.toBeInTheDocument();
});
});
describe('git and duplication form combination', () => {
it('should render both StackRedeployGitForm and StackDuplicationForm when conditions met', () => {
const mockStack = createMockStack({
GitConfig: {
URL: 'https://github.com/test/repo',
ReferenceName: 'main',
ConfigFilePath: 'docker-compose.yml',
ConfigHash: '',
TLSSkipVerify: false,
},
FromAppTemplate: false,
});
renderComponent({
stack: mockStack,
isRegular: true,
isOrphaned: false,
});
expect(screen.getByTestId('stack-redeploy-git-form')).toBeVisible();
expect(screen.getByTestId('stack-duplication-form')).toBeVisible();
});
});
describe('stack file content and environment id passing', () => {
it('should pass stackFileContent to child components', () => {
const mockStack = createMockStack();
const stackFileContent =
'version: "3"\nservices:\n web:\n image: nginx';
renderComponent({
stack: mockStack,
stackFileContent,
isRegular: true,
});
expect(screen.getByTestId('stack-actions')).toBeVisible();
expect(screen.getByTestId('stack-duplication-form')).toBeVisible();
});
it('should pass environmentId to child components', () => {
const mockStack = createMockStack();
renderComponent({
stack: mockStack,
environmentId: 42,
isRegular: true,
});
expect(screen.getByTestId('stack-actions')).toBeVisible();
});
});
});
function renderComponent({
stack,
stackName = 'test-stack',
stackFileContent,
isRegular = true,
isExternal = false,
isOrphaned = false,
isOrphanedRunning = false,
environmentId = 1,
yamlError,
}: Partial<React.ComponentProps<typeof StackInfoTab>> = {}) {
// Mock the Docker API version endpoint
server.use(
http.get('/api/endpoints/:id/docker/version', () =>
HttpResponse.json({ ApiVersion: '1.41' })
)
);
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(StackInfoTab))
);
return render(
<Wrapped
stack={stack}
stackName={stackName}
stackFileContent={stackFileContent}
isRegular={isRegular}
isExternal={isExternal}
isOrphaned={isOrphaned}
isOrphanedRunning={isOrphanedRunning}
environmentId={environmentId}
yamlError={yamlError}
/>
);
}
function createMockStack(overrides?: Partial<Stack>): Stack {
return {
Id: 1,
Name: 'test-stack',
Type: StackType.DockerCompose,
EndpointId: 1,
SwarmId: '',
EntryPoint: 'docker-compose.yml',
Env: [],
Status: StackStatus.Active,
ProjectPath: '/data/compose/1',
CreationDate: Date.now(),
CreatedBy: 'admin',
UpdateDate: Date.now(),
UpdatedBy: 'admin',
FromAppTemplate: false,
IsComposeFormat: true,
FilesystemPath: '/data/compose/1',
StackFileVersion: '3',
PreviousDeploymentInfo: null,
SupportRelativePath: false,
...overrides,
};
}

View File

@@ -1,129 +0,0 @@
import { AlertTriangle } from 'lucide-react';
import { Stack } from '@/react/common/stacks/types';
import { StackDuplicationForm } from '@/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm';
import { Authorized } from '@/react/hooks/useUser';
import { Icon } from '@@/Icon';
import { FormSection } from '@@/form-components/FormSection';
import { StackRedeployGitForm } from './StackRedeployGitForm/StackRedeployGitForm';
import { StackActions } from './StackActions';
import { AssociateStackForm } from './AssociateStackForm';
interface StackInfoTabProps {
stack?: Stack; // will be loaded only if regular or orphaned
stackName: string;
stackFileContent?: string;
isRegular?: boolean;
isExternal: boolean;
isOrphaned: boolean;
isOrphanedRunning: boolean;
environmentId: number;
yamlError?: string;
}
export function StackInfoTab({
stack,
stackName,
stackFileContent,
isRegular,
isExternal,
isOrphaned,
isOrphanedRunning,
environmentId,
yamlError,
}: StackInfoTabProps) {
return (
<>
<ExternalOrphanedWarning
isExternal={isExternal}
isOrphaned={isOrphaned || isOrphanedRunning}
/>
<FormSection title="Stack details">
<div className="form-group">
{stackName}
{stack && (
<div className="inline-flex ml-3">
<StackActions
stack={stack}
fileContent={stackFileContent}
isRegular={isRegular}
environmentId={environmentId}
isExternal={isExternal}
/>
</div>
)}
</div>
</FormSection>
{stack && (
<>
{isOrphaned ? (
<AssociateStackForm
stackName={stackName}
environmentId={environmentId}
isOrphanedRunning={isOrphanedRunning}
stackId={stack.Id}
/>
) : (
<>
{stack.GitConfig && !stack.FromAppTemplate && (
<Authorized authorizations="PortainerStackUpdate">
<StackRedeployGitForm stack={stack} />
</Authorized>
)}
{isRegular && (
<StackDuplicationForm
yamlError={yamlError}
currentEnvironmentId={environmentId}
originalFileContent={stackFileContent || ''}
stack={stack}
/>
)}
</>
)}
</>
)}
</>
);
}
function ExternalOrphanedWarning({
isExternal,
isOrphaned,
}: {
isExternal: boolean;
isOrphaned: boolean;
}) {
if (!isExternal && !isOrphaned) return null;
return (
<FormSection title="Information">
<div className="form-group">
<span className="small">
<p className="text-muted flex items-start gap-1">
<Icon icon={AlertTriangle} mode="warning" className="!mr-0" />
{isExternal && (
<span>
This stack was created outside of Portainer. Control over this
stack is limited.
</span>
)}
{isOrphaned && (
<span>
This stack is orphaned. You can re-associate it with the current
environment using the &quot;Associate to this environment&quot;
feature.
</span>
)}
</p>
</span>
</div>
</FormSection>
);
}

View File

@@ -1,50 +0,0 @@
import { RefreshCw } from 'lucide-react';
import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons';
interface Props {
isDirty: boolean;
isValid: boolean;
isSaveLoading: boolean;
isDeployLoading: boolean;
onDeploy: () => void;
}
export function ActionsSection({
isDirty,
isValid,
isSaveLoading,
isDeployLoading,
onDeploy,
}: Props) {
return (
<FormSection title="Actions">
<LoadingButton
size="small"
color="primary"
type="button"
onClick={onDeploy}
disabled={isDirty || isSaveLoading}
isLoading={isDeployLoading}
loadingText="In progress..."
data-cy="stack-redeploy-button"
>
<RefreshCw className="mr-1" />
Pull and redeploy
</LoadingButton>
<LoadingButton
size="small"
color="primary"
disabled={!isDirty || !isValid || isDeployLoading}
isLoading={isSaveLoading}
loadingText="In progress..."
className="ml-2"
data-cy="stack-save-settings-button"
>
Save settings
</LoadingButton>
</FormSection>
);
}

View File

@@ -1,112 +0,0 @@
import { MinusIcon, PlusIcon } from 'lucide-react';
import { useReducer } from 'react';
import { useFormikContext } from 'formik';
import { Stack } from '@/react/common/stacks/types';
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
import { RefField } from '@/react/portainer/gitops/RefField';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { RelativePathModel } from '@/react/portainer/gitops/types';
import { RefFieldModel } from '@/react/portainer/gitops/RefField/types';
import { Icon } from '@@/Icon';
import { Button } from '@@/buttons';
import { FormValues } from './types';
import { TLSVerificationField } from './TLSVerificationField';
interface Props {
stack: Stack;
}
export function AdvancedConfigurationSection({ stack }: Props) {
const { values, setFieldValue, errors, initialValues } =
useFormikContext<FormValues>();
const [isAdvancedMode, toggleAdvancedMode] = useReducer(
(state) => !state,
false
);
const gitConfig = stack.GitConfig;
if (!gitConfig) {
return null;
}
const valuesToPassDownToFields: RefFieldModel = {
RepositoryURL: gitConfig.URL || '',
RepositoryAuthentication: values.auth.RepositoryAuthentication,
RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType,
RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID,
RepositoryUsername: values.auth.RepositoryUsername,
RepositoryPassword: values.auth.RepositoryPassword,
TLSSkipVerify: values.tlsSkipVerify,
};
const relativePathValues: RelativePathModel = {
FilesystemPath: stack.FilesystemPath,
SupportRelativePath: stack.SupportRelativePath,
PerDeviceConfigsGroupMatchType: '',
SupportPerDeviceConfigs: false,
PerDeviceConfigsMatchType: '',
PerDeviceConfigsPath: '',
};
return (
<>
<div className="form-group">
<div className="col-sm-12">
<Button
color="none"
onClick={() => toggleAdvancedMode()}
data-cy="advanced-configuration-toggle-button"
>
<Icon
icon={isAdvancedMode ? MinusIcon : PlusIcon}
className="mr-1"
/>
{isAdvancedMode ? 'Hide' : 'Advanced'} configuration
</Button>
</div>
</div>
{isAdvancedMode && (
<>
<RefField
value={values.refName}
onChange={(value) => setFieldValue('refName', value)}
model={valuesToPassDownToFields}
isUrlValid
stackId={stack.Id}
error={errors.refName}
/>
<AuthFieldset
value={values.auth}
onChange={(value) => {
Object.entries(value).forEach(([key, val]) => {
setFieldValue(`auth.${key}`, val);
});
}}
isAuthExplanationVisible
errors={errors.auth}
/>
<TLSVerificationField
value={values.tlsSkipVerify}
initialValue={initialValues.tlsSkipVerify}
onChange={(value) => setFieldValue('tlsSkipVerify', value)}
/>
<RelativePathFieldset
values={relativePathValues}
gitModel={valuesToPassDownToFields}
isEditing
hideEdgeConfigs
onChange={() => {}}
/>
</>
)}
</>
);
}

View File

@@ -1,89 +0,0 @@
import { Form, useFormikContext } from 'formik';
import { Stack, StackType } from '@/react/common/stacks/types';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
import { FormSection } from '@@/form-components/FormSection';
import { StackEnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { FormValues } from './types';
import { AdvancedConfigurationSection } from './AdvancedConfigurationSection';
import { OptionsSection } from './OptionsSection';
import { ActionsSection } from './ActionsSection';
export function InnerForm({
stack,
onDeploy,
webhookId,
isDeployLoading,
isSaveLoading,
}: {
stack: Stack;
webhookId: string;
onDeploy(values: FormValues): Promise<void>;
isSaveLoading: boolean;
isDeployLoading: boolean;
}) {
const envId = useEnvironmentId();
const apiVersion = useApiVersion(envId);
const { values, setFieldValue, errors, dirty, isValid } =
useFormikContext<FormValues>();
const gitConfig = stack.GitConfig;
if (!gitConfig) {
return null;
}
return (
<Form className="form-horizontal my-8">
<FormSection title="Redeploy from git repository">
<InfoPanel
className="text-muted small"
url={gitConfig.URL}
type="stack"
configFilePath={gitConfig.ConfigFilePath}
additionalFiles={stack.AdditionalFiles}
/>
<AutoUpdateFieldset
value={values.autoUpdate}
onChange={(value) => setFieldValue('autoUpdate', value)}
environmentType="DOCKER"
isForcePullVisible={stack.Type !== StackType.Kubernetes}
baseWebhookUrl={baseStackWebhookUrl()}
webhookId={webhookId}
webhooksDocs="/user/docker/stacks/webhooks"
errors={errors.autoUpdate}
/>
<TimeWindowDisplay />
<AdvancedConfigurationSection stack={stack} />
<StackEnvironmentVariablesPanel
values={values.env}
onChange={(value) => setFieldValue('env', value)}
showHelpMessage
isFoldable
errors={errors.env}
/>
<OptionsSection stack={stack} apiVersion={apiVersion} />
<ActionsSection
isDirty={dirty}
isValid={isValid}
isSaveLoading={isSaveLoading}
isDeployLoading={isDeployLoading}
onDeploy={() => onDeploy(values)}
/>
</FormSection>
</Form>
);
}

View File

@@ -1,39 +0,0 @@
import { useFormikContext } from 'formik';
import { Stack, StackType } from '@/react/common/stacks/types';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormSection } from '@@/form-components/FormSection';
import { FormValues } from './types';
interface Props {
stack: Stack;
apiVersion: number;
}
export function OptionsSection({ stack, apiVersion }: Props) {
const { values, setFieldValue } = useFormikContext<FormValues>();
if (stack.Type !== StackType.DockerSwarm || apiVersion < 1.27) {
return null;
}
return (
<FormSection title="Options">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="prune"
checked={values.prune}
tooltip="Prune services that are no longer referenced."
labelClass="col-sm-3 col-lg-2"
label="Prune services"
onChange={(value) => setFieldValue('prune', value)}
data-cy="stack-prune-services-switch"
/>
</div>
</div>
</FormSection>
);
}

View File

@@ -1,144 +0,0 @@
import { Formik, FormikHelpers } from 'formik';
import { useState } from 'react';
import { useRouter } from '@uirouter/react';
import { GitStackPayload, Stack, StackType } from '@/react/common/stacks/types';
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import {
parseAutoUpdateResponse,
transformAutoUpdateViewModel,
} from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { useUpdateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack';
import { useUpdateGitStackSettings } from '@/react/portainer/gitops/queries/useUpdateGitStackSettings';
import { useValidationSchema } from './useValidationSchema';
import { FormValues } from './types';
import { InnerForm } from './InnerForm';
export function StackRedeployGitForm({ stack }: { stack: Stack }) {
const router = useRouter();
const deployMutation = useUpdateGitStack(stack.Id, stack.EndpointId);
const updateSettingsMutation = useUpdateGitStackSettings();
const validationSchema = useValidationSchema({
isAuthEdit: !!stack.GitConfig?.Authentication,
});
const [webhookId] = useState(() => {
if (!stack.AutoUpdate?.Webhook) {
return createWebhookId();
}
return stack.AutoUpdate?.Webhook;
});
const authValues = stack.GitConfig?.Authentication;
const initialValues: FormValues = {
auth: {
NewCredentialName: '',
RepositoryAuthentication: !!authValues,
RepositoryAuthorizationType: authValues?.AuthorizationType,
RepositoryGitCredentialID: authValues?.GitCredentialID,
RepositoryPassword: authValues?.Password,
RepositoryUsername: authValues?.Username,
SaveCredential: false,
},
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
env: stack.Env,
prune: stack.Option?.Prune || false,
refName: stack.GitConfig?.ReferenceName || '',
tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false,
};
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSaveSettings}
>
<InnerForm
stack={stack}
webhookId={webhookId}
isSaveLoading={updateSettingsMutation.isLoading}
isDeployLoading={deployMutation.isLoading}
onDeploy={handleDeploy}
/>
</Formik>
);
function handleSaveSettings(
values: FormValues,
{ resetForm }: FormikHelpers<FormValues>
) {
const autoUpdate = transformAutoUpdateViewModel(
values.autoUpdate,
webhookId
);
const payload: GitStackPayload = {
AutoUpdate: autoUpdate,
env: values.env,
RepositoryReferenceName: values.refName,
RepositoryAuthentication: values.auth.RepositoryAuthentication,
RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID,
RepositoryUsername: values.auth.RepositoryUsername,
RepositoryPassword: values.auth.RepositoryPassword,
RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType,
prune: values.prune,
TLSSkipVerify: values.tlsSkipVerify,
};
updateSettingsMutation.mutate(
{
stackId: stack.Id,
endpointId: stack.EndpointId,
payload,
},
{
onError(err) {
notifyError('Failure', err as Error, 'Unable to save stack settings');
},
onSuccess() {
notifySuccess('Success', 'Save stack settings successfully');
resetForm({ values });
},
}
);
}
async function handleDeploy(values: FormValues) {
const isSwarmStack = stack.Type === StackType.DockerSwarm;
const result = await confirmStackUpdate(
'Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption. Do you wish to continue?',
isSwarmStack
);
if (!result) {
return;
}
const payload: GitStackPayload = {
PullImage: result.pullImage,
env: values.env,
RepositoryReferenceName: values.refName,
RepositoryAuthentication: values.auth.RepositoryAuthentication,
RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID,
RepositoryUsername: values.auth.RepositoryUsername,
RepositoryPassword: values.auth.RepositoryPassword,
RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType,
prune: values.prune,
TLSSkipVerify: values.tlsSkipVerify,
};
deployMutation.mutate(payload, {
onError(err) {
notifyError('Failure', err as Error, 'Failed redeploying stack');
},
onSuccess() {
notifySuccess('Success', 'Pulled and redeployed stack successfully');
router.stateService.reload();
},
});
}
}

View File

@@ -1,36 +0,0 @@
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
import { SwitchField } from '@@/form-components/SwitchField';
interface Props {
value: boolean;
initialValue: boolean;
onChange: (value: boolean) => void;
}
export function TLSVerificationField({ value, initialValue, onChange }: Props) {
return (
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="TLSSkipVerify"
checked={value}
tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate."
labelClass="col-sm-3 col-lg-2"
label="Skip TLS Verification"
onChange={async (newValue) => {
if (initialValue && !newValue) {
const confirmed = await confirmEnableTLSVerify();
if (!confirmed) {
return;
}
}
onChange(newValue);
}}
data-cy="gitops-skip-tls-verification-switch"
/>
</div>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { GitAuthModel, AutoUpdateModel } from '@/react/portainer/gitops/types';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
export interface FormValues {
refName: string;
env: EnvVarValues;
prune: boolean;
tlsSkipVerify: boolean;
auth: GitAuthModel;
autoUpdate: AutoUpdateModel;
}

View File

@@ -1,29 +0,0 @@
import { array, boolean, number, object, SchemaOf, string } from 'yup';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset';
import { autoUpdateValidation } from '@/react/portainer/gitops/AutoUpdateFieldset/validation';
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
import { FormValues } from './types';
export function useValidationSchema({
isAuthEdit,
}: {
isAuthEdit: boolean;
}): SchemaOf<FormValues> {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
return object({
auth: gitAuthValidation(gitCredentialsQuery.data || [], isAuthEdit, false),
refName: string().default(''),
env: envVarValidation(),
prune: boolean().default(false),
registries: array(number().required()),
tlsSkipVerify: boolean().default(false),
autoUpdate: autoUpdateValidation(),
});
}

View File

@@ -1,474 +0,0 @@
import { renderHook } from '@testing-library/react-hooks';
import { waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { Stack } from '@/react/common/stacks/types';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { useAssociateStackToEnvironmentMutation } from './useAssociateStackToEnvironmentMutation';
function renderMutationHook() {
const Wrapper = withTestQueryProvider(({ children }) => <>{children}</>);
return renderHook(() => useAssociateStackToEnvironmentMutation(), {
wrapper: Wrapper,
});
}
describe('useAssociateStackToEnvironmentMutation', () => {
describe('successful association', () => {
it('should make PUT request to correct endpoint with params', async () => {
let requestUrl = '';
let capturedParams: URLSearchParams | undefined;
server.use(
http.put('/api/stacks/:id/associate', async ({ request, params }) => {
requestUrl = request.url;
capturedParams = new URL(request.url).searchParams;
return HttpResponse.json({
Id: Number(params.id),
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: Number(params.id),
Type: 6,
},
} as Partial<Stack>);
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 5,
stackId: 123,
isOrphanedRunning: true,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
swarmId: 'swarm-123',
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(requestUrl).toContain('/api/stacks/123/associate');
expect(capturedParams?.get('endpointId')).toBe('5');
expect(capturedParams?.get('orphanedRunning')).toBe('true');
expect(capturedParams?.get('swarmId')).toBe('swarm-123');
});
it('should apply resource control after association', async () => {
let resourceControlRequestBody: unknown;
server.use(
http.put('/api/stacks/:id/associate', () =>
HttpResponse.json({
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 42,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>)
),
http.put('/api/resource_controls/:id', async ({ request, params }) => {
resourceControlRequestBody = await request.json();
expect(params.id).toBe('42');
return HttpResponse.json({ success: true });
})
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [1, 2],
authorizedTeams: [3],
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(resourceControlRequestBody).toBeDefined();
});
it('should handle optional swarmId parameter', async () => {
let capturedParams: URLSearchParams | undefined;
server.use(
http.put('/api/stacks/:id/associate', async ({ request }) => {
capturedParams = new URL(request.url).searchParams;
return HttpResponse.json({
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>);
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
// swarmId is undefined
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// swarmId should not be in params when undefined
expect(capturedParams?.get('swarmId')).toBeNull();
});
it('should default orphanedRunning to false when undefined', async () => {
let capturedParams: URLSearchParams | undefined;
server.use(
http.put('/api/stacks/:id/associate', async ({ request }) => {
capturedParams = new URL(request.url).searchParams;
return HttpResponse.json({
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>);
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
// isOrphanedRunning is undefined
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(capturedParams?.get('orphanedRunning')).toBe('false');
});
});
describe('error handling', () => {
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Suppress console.error for error tests to reduce noise
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleError.mockRestore();
});
it('should handle API error when association fails', async () => {
server.use(
http.put('/api/stacks/:id/associate', () =>
HttpResponse.json({ message: 'Association failed' }, { status: 500 })
)
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
});
it('should throw error when ResourceControl is missing from response', async () => {
server.use(
http.put('/api/stacks/:id/associate', () =>
HttpResponse.json({
Id: 123,
Name: 'test-stack',
// ResourceControl is missing
} as Partial<Stack>)
)
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
expect((result.current.error as Error).message).toContain(
'resource control expected after creation'
);
});
it('should handle error when applying resource control fails', async () => {
server.use(
http.put('/api/stacks/:id/associate', () =>
HttpResponse.json({
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>)
),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json(
{ message: 'Failed to update resource control' },
{ status: 500 }
)
)
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
});
});
describe('mutation states', () => {
it('should track loading state during mutation', async () => {
server.use(
http.put('/api/stacks/:id/associate', async () => {
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
return HttpResponse.json({
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>);
}),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const { result } = renderMutationHook();
expect(result.current.isLoading).toBe(false);
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
});
await waitFor(() => {
expect(result.current.isLoading).toBe(true);
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.isLoading).toBe(false);
});
it('should return stack data on success', async () => {
const mockStack = {
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>;
server.use(
http.put('/api/stacks/:id/associate', () =>
HttpResponse.json(mockStack)
),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
)
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeUndefined(); // Mutation returns void after applying resource control
});
});
describe('access control integration', () => {
it('should handle private ownership', async () => {
let resourceControlBody: unknown;
server.use(
http.put('/api/stacks/:id/associate', () =>
HttpResponse.json({
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>)
),
http.put('/api/resource_controls/:id', async ({ request }) => {
resourceControlBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.PRIVATE,
authorizedUsers: [],
authorizedTeams: [],
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(resourceControlBody).toBeDefined();
});
it('should handle restricted ownership with users and teams', async () => {
let resourceControlBody: unknown;
server.use(
http.put('/api/stacks/:id/associate', () =>
HttpResponse.json({
Id: 123,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: 123,
Type: 6,
},
} as Partial<Stack>)
),
http.put('/api/resource_controls/:id', async ({ request }) => {
resourceControlBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const { result } = renderMutationHook();
result.current.mutate({
environmentId: 1,
stackId: 123,
accessControl: {
ownership: ResourceControlOwnership.RESTRICTED,
authorizedUsers: [1, 2, 3],
authorizedTeams: [10, 20],
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(resourceControlBody).toBeDefined();
});
});
});

View File

@@ -1,70 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import PortainerError from '@/portainer/error';
import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
import { AccessControlFormData } from '@/react/portainer/access-control/types';
import axios from '@/portainer/services/axios';
import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl';
import { Stack } from '@/react/common/stacks/types';
import { withGlobalError } from '@/react-tools/react-query';
export function useAssociateStackToEnvironmentMutation() {
return useMutation({
mutationFn: associateStackToEnvironmentMutation,
...withGlobalError('Failed to associate stack to environment'),
});
}
async function associateStackToEnvironmentMutation({
environmentId,
stackId,
isOrphanedRunning,
accessControl,
swarmId,
}: {
environmentId: number;
stackId: number;
isOrphanedRunning?: boolean;
accessControl: AccessControlFormData;
swarmId?: string;
}) {
const associatedStack = await associate({
environmentId,
stackId,
isOrphanedRunning,
swarmId,
});
const resourceControl = associatedStack.ResourceControl;
if (!resourceControl) {
throw new PortainerError('resource control expected after creation');
}
await applyResourceControl(accessControl, resourceControl.Id);
}
async function associate({
environmentId,
stackId,
isOrphanedRunning,
swarmId,
}: {
environmentId: number;
stackId: number;
isOrphanedRunning?: boolean;
swarmId?: string;
}) {
const { data } = await axios.put<Stack>(
buildStackUrl(stackId, 'associate'),
{},
{
params: {
endpointId: environmentId,
orphanedRunning: isOrphanedRunning ?? false,
swarmId,
},
}
);
return data;
}

View File

@@ -1,30 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl';
import { Stack } from '@/react/common/stacks/types';
export function useStartStackMutation() {
return useMutation({
mutationFn: startStack,
});
}
async function startStack({
id,
environmentId,
}: {
id: Stack['Id'];
environmentId?: number;
}) {
try {
const { data } = await axios.post<Stack>(
buildStackUrl(id, 'start'),
undefined,
{ params: { endpointId: environmentId } }
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to start stack');
}
}

View File

@@ -1,30 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl';
import { Stack } from '@/react/common/stacks/types';
export function useStopStackMutation() {
return useMutation({
mutationFn: stopStack,
});
}
async function stopStack({
id,
environmentId,
}: {
id: Stack['Id'];
environmentId?: number;
}) {
try {
const { data } = await axios.post<Stack>(
buildStackUrl(id, 'stop'),
undefined,
{ params: { endpointId: environmentId } }
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to stop stack');
}
}

View File

@@ -6,8 +6,6 @@ import {
RepoConfigResponse,
} from '@/react/portainer/gitops/types';
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
import { IResource } from '../../components/datatable/createOwnershipColumn';
export class StackViewModel implements IResource {
@@ -21,7 +19,7 @@ export class StackViewModel implements IResource {
SwarmId: string;
Env: EnvVar[];
Env: { name: string; value: string }[];
Option: { Prune: boolean; Force: boolean } | undefined;

View File

@@ -35,25 +35,20 @@ const defaultProps = {
const mockRepoOptions = [
{
label: 'Custom Repositories',
options: [
{
value: {
repoUrl: 'https://charts.bitnami.com/bitnami',
name: 'Bitnami',
type: RegistryTypes.CUSTOM,
},
label: 'Bitnami',
},
{
value: {
repoUrl: 'https://kubernetes-charts.storage.googleapis.com',
name: 'Stable',
type: RegistryTypes.CUSTOM,
},
label: 'Stable',
},
],
value: {
repoUrl: 'https://charts.bitnami.com/bitnami',
name: 'Bitnami',
type: RegistryTypes.CUSTOM,
},
label: 'Bitnami',
},
{
value: {
repoUrl: 'https://kubernetes-charts.storage.googleapis.com',
name: 'Stable',
type: RegistryTypes.CUSTOM,
},
label: 'Stable',
},
];
@@ -134,8 +129,8 @@ describe('HelmRegistrySelect', () => {
const select = screen.getByRole('combobox');
await user.click(select);
screen.getAllByText('Bitnami').forEach((el) => expect(el).toBeVisible());
screen.getAllByText('Stable').forEach((el) => expect(el).toBeVisible());
expect(screen.getByText('Bitnami')).toBeInTheDocument();
expect(screen.getByText('Stable')).toBeInTheDocument();
});
it.skip('should call onRegistryChange when option is selected', async () => {

View File

@@ -5,11 +5,9 @@ import { subjectKind } from './subjectKind';
import { subjectName } from './subjectName';
import { subjectNamespace } from './subjectNamespace';
import { created } from './created';
import { namespace } from './namespace';
export const columns = [
name,
namespace,
roleKind,
roleName,
subjectKind,

View File

@@ -1,31 +0,0 @@
import { Row } from '@tanstack/react-table';
import { Link } from '@@/Link';
import { filterHOC } from '@@/datatables/Filter';
import { RoleBinding } from '../types';
import { columnHelper } from './helper';
export const namespace = columnHelper.accessor((row) => row.namespace, {
header: 'Namespace',
id: 'namespace',
cell: ({ getValue }) => (
<Link
to="kubernetes.resourcePools.resourcePool"
params={{
id: getValue(),
}}
data-cy={`role-binding-namespace-link-${getValue()}`}
>
{getValue()}
</Link>
),
meta: {
filter: filterHOC('Filter by namespace'),
},
enableColumnFilter: true,
filterFn: (row: Row<RoleBinding>, _columnId: string, filterValue: string[]) =>
filterValue.length === 0 ||
filterValue.includes(row.original.namespace ?? ''),
});

View File

@@ -1,25 +1,6 @@
import { Link } from '@@/Link';
import { columnHelper } from './helper';
export const roleKind = columnHelper.accessor('roleRef.kind', {
header: 'Role Kind',
id: 'roleKind',
cell: ({ row }) => {
const to =
row.original.roleRef.kind === 'ClusterRole'
? 'kubernetes.moreResources.clusterRoles'
: 'kubernetes.moreResources.roles';
const tabParam =
row.original.roleRef.kind === 'ClusterRole' ? 'clusterRoles' : 'roles';
return (
<Link
to={to}
params={{ tab: tabParam }}
data-cy={`role-binding-role-kind-link-${row.original.roleRef.kind}`}
>
{row.original.roleRef.kind}
</Link>
);
},
});

View File

@@ -10,6 +10,7 @@ import { Link } from '@@/Link';
import { StatusBadge } from '@@/StatusBadge';
import { Badge } from '@@/Badge';
import { SystemBadge } from '@@/Badge/SystemBadge';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { Icon } from '@@/Icon';
import { helper } from './helper';
@@ -56,22 +57,24 @@ export function useColumns() {
{status.phase}
</StatusBadge>
{item.UnhealthyEventCount > 0 && (
<span className="inline-flex">
<Badge type="warnSecondary">
<Icon icon={AlertTriangle} className="!mr-1 h-3 w-3" />
<TooltipWithChildren message="View events" position="top">
<span className="inline-flex">
<Link
to="kubernetes.resourcePools.resourcePool"
params={{ id: item.Name, tab: 'events' }}
data-cy={`namespace-warning-link-${item.Name}`}
// use the badge text and hover color
className="text-inherit hover:text-inherit"
title="View events"
>
{item.UnhealthyEventCount}{' '}
{pluralize(item.UnhealthyEventCount, 'warning')}
<Badge type="warnSecondary">
<Icon
icon={AlertTriangle}
className="!mr-1 h-3 w-3"
/>
{item.UnhealthyEventCount}{' '}
{pluralize(item.UnhealthyEventCount, 'warning')}
</Badge>
</Link>
</Badge>
</span>
</span>
</TooltipWithChildren>
)}
</div>
);

View File

@@ -5,19 +5,6 @@ import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
import { getAuthentication } from '../utils';
import { GitFormModel } from '../types';
export type PathSelectorGitModel = Pick<
GitFormModel,
| 'RepositoryAuthentication'
| 'RepositoryPassword'
| 'RepositoryUsername'
| 'RepositoryGitCredentialID'
| 'RepositoryAuthorizationType'
| 'RepositoryURL'
| 'RepositoryReferenceName'
| 'TLSSkipVerify'
| 'RepositoryURLValid'
>;
export function PathSelector({
value,
onChange,
@@ -31,7 +18,7 @@ export function PathSelector({
value: string;
onChange(value: string): void;
placeholder: string;
model: PathSelectorGitModel;
model: GitFormModel;
dirOnly?: boolean;
readOnly?: boolean;
inputId: string;

View File

@@ -1,10 +1,8 @@
import { useState } from 'react';
import { FormikErrors } from 'formik';
import {
PathSelector,
PathSelectorGitModel,
} from '@/react/portainer/gitops/ComposePathField/PathSelector';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector';
import { dummyGitForm } from '@/react/portainer/gitops/RelativePathFieldset/utils';
import { SwitchField } from '@@/form-components/SwitchField';
@@ -18,7 +16,7 @@ import { RelativePathModel, getPerDevConfigsFilterType } from './types';
interface Props {
values: RelativePathModel;
gitModel?: PathSelectorGitModel;
gitModel?: GitFormModel;
onChange: (value: RelativePathModel) => void;
isEditing?: boolean;
hideEdgeConfigs?: boolean;

View File

@@ -1,11 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { HttpResponse } from 'msw';
import _ from 'lodash';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { useUpdateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack';
import { useUpdateGitStackSettings } from '@/react/portainer/gitops/queries/useUpdateGitStackSettings';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
import {
@@ -13,13 +13,10 @@ import {
createWebhookId,
} from '@/portainer/helpers/webhookHelper';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Stack } from '@/react/common/stacks/types';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
import { http, server } from '@/setup-tests/server';
import { StackRedeployGitForm } from './StackRedeployGitForm';
// Extract the props type from the component
type StackRedeployGitFormProps = React.ComponentProps<
typeof StackRedeployGitForm
>;
@@ -34,6 +31,14 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
})),
}));
vi.mock('@/react/portainer/gitops/queries/useUpdateGitStack', () => ({
useUpdateGitStack: vi.fn(),
}));
vi.mock('@/react/portainer/gitops/queries/useUpdateGitStackSettings', () => ({
useUpdateGitStackSettings: vi.fn(),
}));
vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({
confirmStackUpdate: vi.fn(),
}));
@@ -49,20 +54,19 @@ vi.mock('@/portainer/helpers/webhookHelper', () => ({
vi.mock('@/react/portainer/gitops/AutoUpdateFieldset/utils', () => ({
parseAutoUpdateResponse: vi.fn(() => ({
RepositoryAutomaticUpdates: true,
RepositoryAutomaticUpdates: false,
RepositoryMechanism: 'Webhook',
RepositoryFetchInterval: '5m',
ForcePullImage: false,
RepositoryAutomaticUpdatesForce: false,
})),
transformAutoUpdateViewModel: vi.fn(() => ({
RepositoryAutomaticUpdates: false,
RepositoryMechanism: 'Webhook',
RepositoryFetchInterval: '5m',
ForcePullImage: false,
RepositoryAutomaticUpdatesForce: false,
})),
transformAutoUpdateViewModel: vi.fn(
(_viewModel: unknown, webhookId: string) => ({
Interval: '',
Webhook: webhookId,
ForceUpdate: false,
ForcePullImage: false,
})
),
}));
// Mock router hooks
@@ -111,8 +115,7 @@ vi.mock('@/react/portainer/gitops/RefField', () => ({
RefField: vi.fn(() => <div data-testid="ref-field">Ref Field</div>),
}));
vi.mock('@/react/portainer/gitops/AuthFieldset', async (importOriginal) => ({
...(await importOriginal()),
vi.mock('@/react/portainer/gitops/AuthFieldset', () => ({
AuthFieldset: vi.fn(() => (
<div data-testid="auth-fieldset">
<div>Repository Authentication</div>
@@ -129,63 +132,92 @@ vi.mock(
})
);
vi.mock('@@/form-components/MultiRegistrySelectFieldset', () => ({
MultiRegistrySelectFieldset: vi.fn(
({
options,
}: {
options: Array<{ Id: number; Name: string }>;
value: number[];
}) => (
<div data-testid="multi-registry-select">
{options?.map((registry: { Id: number; Name: string }) => (
<span key={registry.Id}>{registry.Name}</span>
))}
</div>
)
),
}));
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
notifyError: vi.fn(),
}));
vi.mock('@/react/docker/proxy/queries/useVersion', () => ({
useApiVersion: vi.fn(),
}));
// In test setup or beforeEach
beforeEach(() => {
vi.mocked(useApiVersion).mockReturnValue(1.27);
});
const mockUseUpdateGitStack = vi.mocked(useUpdateGitStack);
const mockUseUpdateGitStackSettings = vi.mocked(useUpdateGitStackSettings);
const mockConfirmStackUpdate = vi.mocked(confirmStackUpdate);
const mockConfirmEnableTLSVerify = vi.mocked(confirmEnableTLSVerify);
const mockBaseStackWebhookUrl = vi.mocked(baseStackWebhookUrl);
const mockCreateWebhookId = vi.mocked(createWebhookId);
describe('StackRedeployGitForm', () => {
const defaultProps: StackRedeployGitFormProps = {
model: {
URL: 'https://github.com/test/repo',
ReferenceName: 'refs/heads/main',
ConfigFilePath: 'docker-compose.yml',
ConfigHash: 'abc123',
TLSSkipVerify: false,
},
stack: {
Id: 1,
EndpointId: 1,
Type: 1, // Swarm stack
Env: [
{ name: 'ENV1', value: 'value1' },
{ name: 'ENV2', value: 'value2' },
],
Option: {
Prune: false,
},
AdditionalFiles: ['file1.yml', 'file2.yml'],
AutoUpdate: {
Interval: '5m',
Webhook: 'test-webhook-id',
ForceUpdate: false,
ForcePullImage: false,
},
},
endpoint: {
apiVersion: 1.27,
Id: 1,
},
};
const mockUpdateGitStackMutation = {
mutateAsync: vi.fn(),
isLoading: false,
error: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
const mockUpdateGitStackSettingsMutation = {
mutateAsync: vi.fn(),
isLoading: false,
error: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockUseUpdateGitStack.mockReturnValue(mockUpdateGitStackMutation as any);
mockUseUpdateGitStackSettings.mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockUpdateGitStackSettingsMutation as any
);
mockConfirmStackUpdate.mockResolvedValue({ pullImage: false });
mockConfirmEnableTLSVerify.mockResolvedValue(true);
mockBaseStackWebhookUrl.mockReturnValue(
'http://localhost:9000/api/webhooks'
);
mockCreateWebhookId.mockReturnValue('test-webhook-id');
server.use(
http.put('/api/stacks/:id/git/redeploy', () =>
HttpResponse.json({ success: true })
),
http.post('/api/stacks/:id/git', () =>
HttpResponse.json({ success: true })
)
);
});
function renderComponent(props = {}) {
const Component = withTestQueryProvider(
withTestRouter(() => (
<StackRedeployGitForm {...defaultProps} {...props} />
))
);
return render(<Component />);
}
describe('Basic rendering', () => {
it('should render the form with correct sections', () => {
renderComponent();
@@ -193,8 +225,7 @@ describe('StackRedeployGitForm', () => {
expect(
screen.getByText('Redeploy from git repository')
).toBeInTheDocument();
expect(screen.getByText('Options')).toBeInTheDocument(); // available only when apiVersion is >= 1.27
expect(screen.getByText('Options')).toBeInTheDocument();
expect(screen.getByText('Actions')).toBeInTheDocument();
});
@@ -281,14 +312,10 @@ describe('StackRedeployGitForm', () => {
it('should call confirmEnableTLSVerify when enabling TLS verification', async () => {
const user = userEvent.setup();
const propsWithTLSDisabled: DeepPartial<StackRedeployGitFormProps> = {
stack: {
GitConfig: {
TLSSkipVerify: true,
},
},
const propsWithTLSDisabled = {
...defaultProps,
model: { ...defaultProps.model, TLSSkipVerify: true },
};
renderComponent(propsWithTLSDisabled);
const toggleButton = screen.getByTestId(
@@ -307,7 +334,6 @@ describe('StackRedeployGitForm', () => {
describe('Options section', () => {
it('should show prune services option for swarm stacks with API version >= 1.27', () => {
vi.mocked(useApiVersion).mockReturnValue(1.27);
renderComponent();
expect(screen.getByText('Prune services')).toBeInTheDocument();
@@ -317,20 +343,21 @@ describe('StackRedeployGitForm', () => {
});
it('should not show options section for non-swarm stacks', () => {
vi.mocked(useApiVersion).mockReturnValue(1.27);
renderComponent({
stack: {
Type: 2,
},
});
const propsWithComposeStack = {
...defaultProps,
stack: { ...defaultProps.stack, Type: 2 }, // Compose stack
};
renderComponent(propsWithComposeStack);
expect(screen.queryByText('Options')).not.toBeInTheDocument();
});
it('should not show options section for older API versions', () => {
vi.mocked(useApiVersion).mockReturnValue(1.26);
renderComponent();
const propsWithOldAPI = {
...defaultProps,
endpoint: { ...defaultProps.endpoint, apiVersion: 1.26 },
};
renderComponent(propsWithOldAPI);
expect(screen.queryByText('Options')).not.toBeInTheDocument();
});
@@ -351,38 +378,30 @@ describe('StackRedeployGitForm', () => {
});
it('should call updateGitStack mutation when confirmed', async () => {
let requestBody: unknown = null;
server.use(
http.put('/api/stacks/:id/git/redeploy', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
mockUpdateGitStackMutation.mutateAsync.mockResolvedValue({});
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
await user.click(redeployButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
prune: false,
RepositoryReferenceName: 'refs/heads/main',
})
);
expect(mockUpdateGitStackMutation.mutateAsync).toHaveBeenCalledWith({
env: defaultProps.stack.Env,
prune: false,
RepositoryReferenceName: 'refs/heads/main',
RepositoryAuthentication: false,
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: '',
PullImage: false,
});
});
});
it('should notify success on successful redeploy', async () => {
server.use(
http.put('/api/stacks/:id/git/redeploy', async () =>
HttpResponse.json({ success: true })
)
);
const user = userEvent.setup();
mockUpdateGitStackMutation.mutateAsync.mockResolvedValue({});
renderComponent();
@@ -396,13 +415,10 @@ describe('StackRedeployGitForm', () => {
it('should disable redeploy button when in progress', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/stacks/:id/git/redeploy', async () => {
// never resolve
await new Promise(() => {});
return HttpResponse.json({ success: true });
})
);
// Mock the mutation to simulate loading state
mockUpdateGitStackMutation.mutateAsync.mockImplementation(
() => new Promise(() => {})
); // Never resolves
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
@@ -417,16 +433,8 @@ describe('StackRedeployGitForm', () => {
describe('Save settings functionality', () => {
it('should call updateGitStackSettings mutation when save button is clicked', async () => {
let requestBody: unknown;
server.use(
http.post('/api/stacks/:id/git', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
mockUpdateGitStackSettingsMutation.mutateAsync.mockResolvedValue({});
renderComponent();
// Make a change to enable the save button
@@ -444,13 +452,18 @@ describe('StackRedeployGitForm', () => {
await user.click(saveButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
expect(
mockUpdateGitStackSettingsMutation.mutateAsync
).toHaveBeenCalledWith({
stackId: 1,
endpointId: 1,
payload: expect.objectContaining({
env: defaultProps.stack.Env,
RepositoryReferenceName: 'refs/heads/main',
prune: false,
TLSSkipVerify: true,
})
);
}),
});
});
});
@@ -481,13 +494,7 @@ describe('StackRedeployGitForm', () => {
});
it('should disable save button when in progress', () => {
server.use(
http.post('/api/stacks/:id/git', async () => {
// never resolve
await new Promise(() => {});
return HttpResponse.json({ success: true });
})
);
mockUpdateGitStackSettingsMutation.isLoading = true;
renderComponent();
const saveButton = screen.getByTestId('stack-save-settings-button');
@@ -516,18 +523,12 @@ describe('StackRedeployGitForm', () => {
await user.click(tlsSwitch);
// Should now have unsaved changes
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
expect(saveButton).not.toBeDisabled();
});
it('should clear unsaved changes after successful save', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/stacks/:id/git/redeploy', async () =>
HttpResponse.json({ success: true })
)
);
mockUpdateGitStackSettingsMutation.mutateAsync.mockResolvedValue({});
renderComponent();
// Make a change
@@ -554,12 +555,9 @@ describe('StackRedeployGitForm', () => {
describe('Error handling', () => {
it('should handle updateGitStack mutation errors gracefully', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/stacks/:id/git/redeploy', async () =>
HttpResponse.json({ error: 'Update failed' }, { status: 400 })
)
mockUpdateGitStackMutation.mutateAsync.mockRejectedValue(
new Error('Update failed')
);
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
@@ -572,12 +570,9 @@ describe('StackRedeployGitForm', () => {
it('should handle updateGitStackSettings mutation errors gracefully', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/stacks/:id/git', async () =>
HttpResponse.json({ error: 'Update failed' }, { status: 400 })
)
mockUpdateGitStackSettingsMutation.mutateAsync.mockRejectedValue(
new Error('Save failed')
);
renderComponent();
// Make a change to enable save button
@@ -596,7 +591,7 @@ describe('StackRedeployGitForm', () => {
// Should not clear unsaved changes on error
await waitFor(() => {
expect(saveButton).toBeEnabled();
expect(saveButton).not.toBeDisabled();
});
});
});
@@ -604,8 +599,10 @@ describe('StackRedeployGitForm', () => {
describe('Git authentication', () => {
it('should handle git authentication configuration', async () => {
const user = userEvent.setup();
const propsWithAuth: DeepPartial<StackRedeployGitFormProps> = {
const propsWithAuth = {
...defaultProps,
stack: {
...defaultProps.stack,
GitConfig: {
Authentication: {
Username: 'testuser',
@@ -628,139 +625,26 @@ describe('StackRedeployGitForm', () => {
});
describe('Webhook configuration', () => {
it('should generate webhook ID when no webhook is provided and use it in save settings', async () => {
let requestBody: unknown;
server.use(
http.post('/api/stacks/:id/git', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
mockCreateWebhookId.mockReturnValue('generated-webhook-id');
renderComponent({
stack: {
AutoUpdate: {
Webhook: '',
},
},
});
it('should generate webhook ID on initialization', () => {
renderComponent();
expect(mockCreateWebhookId).toHaveBeenCalled();
// Make a change to enable save button
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
const saveButton = screen.getByTestId('stack-save-settings-button');
await user.click(saveButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
AutoUpdate: expect.objectContaining({
Webhook: 'generated-webhook-id',
}),
})
);
});
});
it('should use existing webhook ID from stack without generating new one', async () => {
const user = userEvent.setup();
let requestBody: unknown;
server.use(
http.post('/api/stacks/:id/git', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
renderComponent({
it('should use existing webhook ID from stack if available', () => {
const propsWithWebhook = {
...defaultProps,
stack: {
...defaultProps.stack,
AutoUpdate: {
...defaultProps.stack.AutoUpdate,
Webhook: 'existing-webhook-id',
},
},
});
};
renderComponent(propsWithWebhook);
expect(mockCreateWebhookId).not.toHaveBeenCalled();
// Make a change to enable save button
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
const saveButton = screen.getByTestId('stack-save-settings-button');
await user.click(saveButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
AutoUpdate: expect.objectContaining({
Webhook: 'existing-webhook-id',
}),
})
);
});
expect(mockCreateWebhookId).toHaveBeenCalled();
});
});
});
const defaultProps: StackRedeployGitFormProps = {
stack: {
GitConfig: {
URL: 'https://github.com/test/repo',
ReferenceName: 'refs/heads/main',
ConfigFilePath: 'docker-compose.yml',
ConfigHash: 'abc123',
TLSSkipVerify: false,
},
Name: 'stack',
Id: 1,
EndpointId: 1,
Type: 1, // Swarm stack
Env: [
{ name: 'ENV1', value: 'value1' },
{ name: 'ENV2', value: 'value2' },
],
Option: {
Prune: false,
Force: false,
},
AdditionalFiles: ['file1.yml', 'file2.yml'],
AutoUpdate: {
Interval: '5m',
Webhook: 'test-webhook-id',
ForceUpdate: false,
ForcePullImage: false,
},
} as Stack,
};
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
function renderComponent(props: DeepPartial<StackRedeployGitFormProps> = {}) {
const Component = withTestQueryProvider(
withUserProvider(withTestRouter(StackRedeployGitForm))
);
// merge deep the props
return render(<Component {..._.merge({}, defaultProps, props)} />);
}

View File

@@ -0,0 +1,494 @@
import { useState, useCallback, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { GitStackPayload } from '@/react/common/stacks/types';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import {
baseStackWebhookUrl,
createWebhookId,
} from '@/portainer/helpers/webhookHelper';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Icon } from '@@/Icon';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { Button } from '@@/buttons/Button';
import { SwitchField } from '@@/form-components/SwitchField/SwitchField';
import { FormSection } from '@@/form-components/FormSection/FormSection';
import { StackEnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset/StackEnvironmentVariablesPanel';
import { useUpdateGitStack } from '../queries/useUpdateGitStack';
import { useUpdateGitStackSettings } from '../queries/useUpdateGitStackSettings';
import { GitFormModel, AutoUpdateModel, AutoUpdateResponse } from '../types';
import { RelativePathModel } from '../RelativePathFieldset/types';
import {
parseAutoUpdateResponse,
transformAutoUpdateViewModel,
} from '../AutoUpdateFieldset/utils';
import { confirmEnableTLSVerify } from '../utils';
import { InfoPanel } from '../InfoPanel';
import { AutoUpdateFieldset } from '../AutoUpdateFieldset';
import { RefField } from '../RefField';
import { AuthFieldset } from '../AuthFieldset';
import { RelativePathFieldset } from '../RelativePathFieldset/RelativePathFieldset';
interface StackRedeployGitFormModel extends GitFormModel {
RefName?: string;
Env: Array<{ name: string; value: string }>;
Option: {
Prune: boolean;
};
PullImage: boolean;
isEdit?: boolean;
isAuthEdit?: boolean;
TLSSkipVerify?: boolean;
}
interface StackRedeployGitFormProps {
model: {
URL: string;
ReferenceName: string;
ConfigFilePath: string;
ConfigHash: string;
TLSSkipVerify: boolean;
};
stack: {
Id: number;
EndpointId: number;
Type: number;
Env: Array<{ name: string; value: string }>;
Option?: {
Prune: boolean;
};
AdditionalFiles?: string[];
AutoUpdate?: AutoUpdateResponse;
GitConfig?: {
Authentication?: {
Username?: string;
Password?: string;
GitCredentialID?: number;
};
};
};
endpoint: {
apiVersion: number;
Id: number;
};
}
interface StackRedeployGitFormState {
inProgress: boolean;
redeployInProgress: boolean;
showConfig: boolean;
hasUnsavedChanges: boolean;
baseWebhookUrl: string;
webhookId: string;
}
const defaultState: StackRedeployGitFormState = {
inProgress: false,
redeployInProgress: false,
showConfig: false,
hasUnsavedChanges: false,
baseWebhookUrl: baseStackWebhookUrl(),
webhookId: createWebhookId(),
};
export function StackRedeployGitForm({
model,
stack,
endpoint,
}: StackRedeployGitFormProps) {
const router = useRouter();
const [state, setState] = useState(defaultState);
const [formValues, setFormValues] = useState<StackRedeployGitFormModel>({
RepositoryURL: model.URL,
RepositoryURLValid: true,
ComposeFilePathInRepository: model.ConfigFilePath,
RefName: model.ReferenceName,
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryGitCredentialID: 0,
SaveCredential: true,
NewCredentialName: '',
Env: stack.Env || [],
Option: {
Prune: Boolean(stack.Option?.Prune || false),
},
PullImage: false,
AutoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
TLSSkipVerify: model.TLSSkipVerify,
});
const [savedFormValues, setSavedFormValues] =
useState<StackRedeployGitFormModel>({ ...formValues });
// Use the new git stack mutation hooks
const updateGitStackMutation = useUpdateGitStack(stack.Id, stack.EndpointId);
const updateGitStackSettingsMutation = useUpdateGitStackSettings();
// Initialize form values from stack data
const initializeFormValues = useCallback(() => {
// Extract webhook ID first - only generate if not already set
setState((prev) => {
let { webhookId } = prev;
if (!webhookId) {
webhookId = createWebhookId();
if (stack.AutoUpdate?.Webhook) {
// Extract UUID from webhook URL if it's a full URL
webhookId = stack.AutoUpdate.Webhook;
if (webhookId.includes('/')) {
// Extract the last part of the URL which should be the UUID
const parts = webhookId.split('/');
webhookId = parts[parts.length - 1] || webhookId;
}
}
}
return { ...prev, webhookId };
});
const newFormValues = {
RepositoryURL: model.URL,
RepositoryURLValid: true,
ComposeFilePathInRepository: model.ConfigFilePath,
RefName: model.ReferenceName,
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryGitCredentialID: 0,
SaveCredential: true,
NewCredentialName: '',
Env: stack.Env || [],
Option: {
Prune: Boolean(stack.Option?.Prune || false),
},
PullImage: false,
AutoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
TLSSkipVerify: model.TLSSkipVerify,
};
if (stack.GitConfig?.Authentication) {
const auth = stack.GitConfig.Authentication;
newFormValues.RepositoryUsername = auth.Username || '';
newFormValues.RepositoryPassword = auth.Password || '';
newFormValues.RepositoryAuthentication = true;
if (auth.GitCredentialID && auth.GitCredentialID > 0) {
newFormValues.SaveCredential = false;
newFormValues.RepositoryGitCredentialID = auth.GitCredentialID;
}
}
setFormValues(newFormValues);
setSavedFormValues({ ...newFormValues });
}, [model, stack]);
// Initialize form values from stack data
useEffect(() => {
initializeFormValues();
}, [initializeFormValues]);
const handleChange = useCallback(
(partialValue: Partial<StackRedeployGitFormModel>) => {
setFormValues((prev) => {
const newValues = { ...prev, ...partialValue };
const hasChanges =
JSON.stringify(savedFormValues) !== JSON.stringify(newValues);
setState((statePrev) => ({
...statePrev,
hasUnsavedChanges: hasChanges,
}));
return newValues;
});
},
[savedFormValues]
);
const handleChangeTLSSkipVerify = useCallback(
async (value: boolean) => {
if (model.TLSSkipVerify && !value) {
const confirmed = await confirmEnableTLSVerify();
if (!confirmed) {
return;
}
}
handleChange({ TLSSkipVerify: value });
},
[model.TLSSkipVerify, handleChange]
);
const handleChangeAutoUpdate = useCallback(
(values: Partial<AutoUpdateModel>) => {
setFormValues((prev) => {
const newValues = {
...prev,
AutoUpdate: {
...prev.AutoUpdate!,
...values,
},
};
const hasChanges =
JSON.stringify(savedFormValues) !== JSON.stringify(newValues);
setState((statePrev) => ({
...statePrev,
hasUnsavedChanges: hasChanges,
}));
return newValues;
});
},
[savedFormValues]
);
const handleSubmit = useCallback(async () => {
const isSwarmStack = stack.Type === 1;
const result = await confirmStackUpdate(
'Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption. Do you wish to continue?',
isSwarmStack
);
if (!result) {
return;
}
try {
setState((prev) => ({ ...prev, redeployInProgress: true }));
const payload: GitStackPayload = {
env: formValues.Env,
prune: formValues.Option.Prune,
RepositoryReferenceName: formValues.RefName,
RepositoryAuthentication: formValues.RepositoryAuthentication,
RepositoryGitCredentialID: formValues.RepositoryGitCredentialID,
RepositoryUsername: formValues.RepositoryUsername,
RepositoryPassword: formValues.RepositoryPassword,
PullImage: result.pullImage,
};
await updateGitStackMutation.mutateAsync(payload);
notifySuccess('Success', 'Pulled and redeployed stack successfully');
router.stateService.reload();
} catch (err) {
notifyError('Failure', err as Error, 'Failed redeploying stack');
} finally {
setState((prev) => ({ ...prev, redeployInProgress: false }));
}
}, [
stack.Type,
formValues.Env,
formValues.Option.Prune,
formValues.RefName,
formValues.RepositoryAuthentication,
formValues.RepositoryGitCredentialID,
formValues.RepositoryUsername,
formValues.RepositoryPassword,
updateGitStackMutation,
router.stateService,
]);
const handleSaveSettings = useCallback(async () => {
try {
setState((prev) => ({ ...prev, inProgress: true }));
const autoUpdate = transformAutoUpdateViewModel(
formValues.AutoUpdate,
state.webhookId
);
const payload: GitStackPayload = {
AutoUpdate: autoUpdate,
env: formValues.Env,
RepositoryReferenceName: formValues.RefName,
RepositoryAuthentication: formValues.RepositoryAuthentication,
RepositoryGitCredentialID: formValues.RepositoryGitCredentialID,
RepositoryUsername: formValues.RepositoryUsername,
RepositoryPassword: formValues.RepositoryPassword,
prune: formValues.Option.Prune,
TLSSkipVerify: formValues.TLSSkipVerify,
};
await updateGitStackSettingsMutation.mutateAsync({
stackId: stack.Id,
endpointId: stack.EndpointId,
payload,
});
notifySuccess('Success', 'Save stack settings successfully');
setSavedFormValues(JSON.parse(JSON.stringify(formValues)));
setState((prev) => ({
...prev,
hasUnsavedChanges: false,
inProgress: false,
}));
} catch (err) {
notifyError('Failure', err as Error, 'Unable to save stack settings');
setState((prev) => ({ ...prev, inProgress: false }));
}
}, [
formValues,
state.webhookId,
stack.Id,
stack.EndpointId,
updateGitStackSettingsMutation,
]);
function disableSaveSettingsButton(): boolean {
return Boolean(
state.inProgress ||
state.redeployInProgress ||
!state.hasUnsavedChanges ||
(formValues.RepositoryAuthentication &&
!formValues.RepositoryPassword &&
formValues.RepositoryGitCredentialID === 0) ||
(formValues.RepositoryAuthentication &&
formValues.RepositoryPassword &&
formValues.SaveCredential &&
!formValues.NewCredentialName)
);
}
return (
<div className="form-horizontal my-8">
<FormSection title="Redeploy from git repository">
<InfoPanel
className="text-muted small"
url={formValues.RepositoryURL}
type="stack"
configFilePath={formValues.ComposeFilePathInRepository}
additionalFiles={stack.AdditionalFiles}
/>
<AutoUpdateFieldset
value={formValues.AutoUpdate!}
onChange={handleChangeAutoUpdate}
environmentType="DOCKER"
isForcePullVisible={stack.Type !== 3}
baseWebhookUrl={state.baseWebhookUrl}
webhookId={state.webhookId}
webhooksDocs="/user/docker/stacks/webhooks"
/>
<div className="form-group">
<div className="col-sm-12">
<Button
color="none"
onClick={() =>
setState((prev) => ({ ...prev, showConfig: !prev.showConfig }))
}
data-cy="advanced-configuration-toggle-button"
>
<Icon
icon={state.showConfig ? 'minus' : 'plus'}
className="mr-1"
/>
{state.showConfig ? 'Hide' : 'Advanced'} configuration
</Button>
</div>
</div>
{state.showConfig && (
<>
<RefField
value={formValues.RefName || ''}
onChange={(value: string) => handleChange({ RefName: value })}
model={formValues}
isUrlValid
stackId={stack.Id}
/>
<AuthFieldset
value={formValues}
onChange={(values: Partial<GitFormModel>) => handleChange(values)}
isAuthExplanationVisible
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="TLSSkipVerify"
checked={formValues.TLSSkipVerify || false}
tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate."
labelClass="col-sm-3 col-lg-2"
label="Skip TLS Verification"
onChange={handleChangeTLSSkipVerify}
data-cy="gitops-skip-tls-verification-switch"
/>
</div>
</div>
<RelativePathFieldset
values={stack as unknown as RelativePathModel}
gitModel={formValues}
isEditing
hideEdgeConfigs
onChange={() => {}}
/>
</>
)}
<StackEnvironmentVariablesPanel
values={formValues.Env}
onChange={(value: Array<{ name: string; value: string }>) =>
handleChange({ Env: value })
}
showHelpMessage
isFoldable
/>
{stack.Type === 1 && endpoint.apiVersion >= 1.27 && (
<FormSection title="Options">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="prune"
checked={formValues.Option.Prune || false}
tooltip="Prune services that are no longer referenced."
labelClass="col-sm-3 col-lg-2"
label="Prune services"
onChange={(value: boolean) =>
handleChange({ Option: { Prune: value } })
}
data-cy="stack-prune-services-switch"
/>
</div>
</div>
</FormSection>
)}
<FormSection title="Actions">
<LoadingButton
size="small"
color="primary"
onClick={handleSubmit}
disabled={
state.inProgress ||
state.redeployInProgress ||
state.hasUnsavedChanges
}
isLoading={state.redeployInProgress}
loadingText="In progress..."
data-cy="stack-redeploy-button"
>
<RefreshCw className="mr-1" />
Pull and redeploy
</LoadingButton>
<LoadingButton
size="small"
color="primary"
onClick={handleSaveSettings}
disabled={disableSaveSettingsButton()}
isLoading={state.inProgress}
loadingText="In progress..."
className="ml-2"
data-cy="stack-save-settings-button"
>
Save settings
</LoadingButton>
</FormSection>
</FormSection>
</div>
);
}

View File

@@ -1,7 +1,4 @@
import {
AuthTypeOption,
GitCredential,
} from '@/react/portainer/account/git-credentials/types';
import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types';
export type AutoUpdateMechanism = 'Webhook' | 'Interval';
export { type RelativePathModel } from './RelativePathFieldset/types';
@@ -23,7 +20,6 @@ export interface AutoUpdateResponse {
export interface GitAuthenticationResponse {
Username?: string;
Password?: string;
AuthorizationType?: AuthTypeOption;
GitCredentialID?: number;
}
@@ -48,7 +44,7 @@ export type GitCredentialsModel = {
RepositoryAuthentication?: boolean;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryGitCredentialID?: GitCredential['id'];
RepositoryGitCredentialID?: number;
RepositoryAuthorizationType?: AuthTypeOption;
};
@@ -59,7 +55,7 @@ export type GitNewCredentialModel = {
export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel;
export type DeployMethod = 'compose' | 'manifest' | 'helm';
export type DeployMethod = 'compose' | 'manifest';
export interface GitFormModel extends GitAuthModel {
RepositoryURL: string;
@@ -105,7 +101,6 @@ export function toGitFormModel(
),
RepositoryUsername: Authentication?.Username,
RepositoryPassword: Authentication?.Password,
RepositoryAuthorizationType: Authentication?.AuthorizationType,
RepositoryGitCredentialID: Authentication?.GitCredentialID,
TLSSkipVerify,
AutoUpdate: autoUpdate,

View File

@@ -99,7 +99,4 @@ export const handlers = [
message: 'Registry connection successful',
})
),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
),
];

View File

@@ -69,14 +69,9 @@ if [ "$PLATFORM" = "darwin" ]; then
PLATFORM="linux"
fi
BINARY_NAME="portainer"
if [ "$PLATFORM" = "windows" ]; then
BINARY_NAME="portainer.exe"
fi
GOOS=${PLATFORM} GOARCH=${ARCH} CGO_ENABLED=0 go build \
-trimpath \
--installsuffix cgo \
--ldflags "$ldflags" \
-o "../dist/${BINARY_NAME}" \
-o "../dist/portainer" \
./cmd/portainer/

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.37.0",
"version": "2.36.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"

View File

@@ -26,8 +26,6 @@ type InstallOptions struct {
DryRun bool
Timeout time.Duration
KubernetesClusterAccess *KubernetesClusterAccess
TakeOwnership bool
CreateNamespace bool
// GitOps related options
GitConfig *gittypes.RepoConfig

View File

@@ -153,8 +153,6 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I
installClient.Timeout = installOpts.Timeout
installClient.Version = installOpts.Version
installClient.DryRun = installOpts.DryRun
installClient.TakeOwnership = installOpts.TakeOwnership
installClient.CreateNamespace = installOpts.CreateNamespace
err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation")

View File

@@ -171,7 +171,6 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
upgradeClient.Wait = upgradeOpts.Wait
upgradeClient.Version = upgradeOpts.Version
upgradeClient.DryRun = upgradeOpts.DryRun
upgradeClient.TakeOwnership = upgradeOpts.TakeOwnership // Equivalent to --take-ownership flag
err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade")
@@ -188,7 +187,7 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
upgradeClient.Timeout = upgradeOpts.Timeout
}
if upgradeOpts.Namespace == "" {
upgradeClient.Namespace = "default"
upgradeOpts.Namespace = "default"
} else {
upgradeClient.Namespace = upgradeOpts.Namespace
}

View File

@@ -65,4 +65,4 @@ func AddTagToManifest(registryClient *remote.Registry, repository, tagName, targ
}
return nil
}
}

Some files were not shown because too many files have changed in this diff Show More