Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4e8b5af9 |
@@ -17,7 +17,7 @@ plugins:
|
||||
- import
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: latest
|
||||
ecmaVersion: 2018
|
||||
sourceType: module
|
||||
project: './tsconfig.json'
|
||||
ecmaFeatures:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.37.0
|
||||
// @version 2.36.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ angular.module('portainer.docker').component('hostOverview', {
|
||||
refreshUrl: '@',
|
||||
browseUrl: '@',
|
||||
hostFeaturesEnabled: '<',
|
||||
isAdmin: '<',
|
||||
},
|
||||
transclude: true,
|
||||
});
|
||||
|
||||
@@ -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)), [
|
||||
|
||||
@@ -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;
|
||||
@@ -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)), [])
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
3
app/docker/views/configs/configs.html
Normal file
3
app/docker/views/configs/configs.html
Normal 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>
|
||||
59
app/docker/views/configs/configsController.js
Normal file
59
app/docker/views/configs/configsController.js
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -210,7 +210,6 @@ export const ngModule = angular
|
||||
'aria-label',
|
||||
'size',
|
||||
'loadingMessage',
|
||||
'getOptionValue',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,6 @@ type Color =
|
||||
| 'dangerlight'
|
||||
| 'warninglight'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'none';
|
||||
type Size = 'xsmall' | 'small' | 'medium' | 'large';
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
0
app/react/docker/configs/.keep
Normal file
0
app/react/docker/configs/.keep
Normal file
0
app/react/docker/configs/ListView/.keep
Normal file
0
app/react/docker/configs/ListView/.keep
Normal 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 />);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />);
|
||||
}
|
||||
@@ -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)]),
|
||||
});
|
||||
}
|
||||
@@ -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>(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ConfigsDatatable } from './ConfigsDatatable';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]>(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('^');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 "Associate to this environment"
|
||||
feature.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? ''),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)} />);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -99,7 +99,4 @@ export const handlers = [
|
||||
message: 'Registry connection successful',
|
||||
})
|
||||
),
|
||||
http.put('/api/resource_controls/:id', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -26,8 +26,6 @@ type InstallOptions struct {
|
||||
DryRun bool
|
||||
Timeout time.Duration
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
TakeOwnership bool
|
||||
CreateNamespace bool
|
||||
|
||||
// GitOps related options
|
||||
GitConfig *gittypes.RepoConfig
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user