Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8869b91b71 | ||
|
|
2406d67bfc | ||
|
|
f0266e9316 | ||
|
|
c08f42315e | ||
|
|
d2649dac90 | ||
|
|
300681055e | ||
|
|
712dbc9396 | ||
|
|
f6b8e8615f | ||
|
|
4826c13848 | ||
|
|
80f497a185 | ||
|
|
d2a9adb4be | ||
|
|
8675086441 | ||
|
|
b79e784764 | ||
|
|
93ba3e700e | ||
|
|
bf6cb8d0b8 | ||
|
|
7010d7bf66 | ||
|
|
1a862157a0 | ||
|
|
532575cab5 | ||
|
|
0794d0f89f | ||
|
|
e227ffd6d8 |
@@ -17,7 +17,7 @@ plugins:
|
||||
- import
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: 2018
|
||||
ecmaVersion: latest
|
||||
sourceType: module
|
||||
project: './tsconfig.json'
|
||||
ecmaFeatures:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,8 +94,10 @@ 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,4 +53,5 @@ type Connection interface {
|
||||
|
||||
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
|
||||
ConvertToKey(v int) []byte
|
||||
ConvertStringToKey(v string) []byte
|
||||
}
|
||||
|
||||
@@ -233,6 +233,10 @@ 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,6 +50,9 @@ 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]{
|
||||
|
||||
70
api/dataservices/version/tx.go
Normal file
70
api/dataservices/version/tx.go
Normal file
@@ -0,0 +1,70 @@
|
||||
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,6 +33,16 @@ 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.36.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.37.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.36.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.37.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 name: %s", err, endpoint.Name))
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, payload.EndpointID))
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
|
||||
@@ -24,8 +24,8 @@ func (payload *logsPayload) Validate(r *http.Request) error {
|
||||
}
|
||||
|
||||
// endpointEdgeJobsLogs
|
||||
// @summary Inspect an EdgeJob Log
|
||||
// @description **Access policy**: public
|
||||
// @summary Update the logs collected from an Edge Job
|
||||
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
|
||||
// @tags edge, endpoints
|
||||
// @accept json
|
||||
// @produce json
|
||||
@@ -34,6 +34,7 @@ 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)
|
||||
@@ -42,35 +43,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 name: %s", err, endpoint.Name))
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return handler.getEdgeJobLobs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
|
||||
return handler.updateEdgeJobLogs(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 name: %s", httpErr.Err, endpoint.Name)
|
||||
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
return response.JSON(w, nil)
|
||||
}
|
||||
|
||||
func (handler *Handler) getEdgeJobLobs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error {
|
||||
func (handler *Handler) updateEdgeJobLogs(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)
|
||||
@@ -85,6 +86,11 @@ func (handler *Handler) getEdgeJobLobs(tx dataservices.DataStoreTx, endpointID p
|
||||
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)
|
||||
}
|
||||
|
||||
40
api/http/handler/endpointedge/endpointedge_job_logs_test.go
Normal file
40
api/http/handler/endpointedge/endpointedge_job_logs_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
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 name: %s", err, endpoint.Name))
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
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 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 name: %s", err, endpoint.Name))
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 name: %s", endpoint.Name))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 name: %s", endpoint.Name))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
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))
|
||||
}
|
||||
|
||||
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 name: %s", err, endpoint.Name))
|
||||
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))
|
||||
}
|
||||
|
||||
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 name: %s", httpErr.Err, endpoint.Name)
|
||||
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
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.buildSchedules(tx, endpoint)
|
||||
schedules, handlerErr := handler.buildAllSchedules(tx, endpoint)
|
||||
if handlerErr != nil {
|
||||
return nil, handlerErr
|
||||
}
|
||||
@@ -208,14 +208,18 @@ func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
|
||||
schedules := []edgeJobResponse{}
|
||||
|
||||
func (handler *Handler) buildAllSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
|
||||
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)
|
||||
@@ -240,17 +244,10 @@ 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: collectLogs,
|
||||
CollectLogs: job.GroupLogsCollection[endpoint.ID].CollectLogs || job.Endpoints[endpoint.ID].CollectLogs,
|
||||
Version: job.Version,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @success 200 {object} portainer.Endpoint "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Environment(Endpoint) not found"
|
||||
@@ -53,10 +52,9 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
|
||||
|
||||
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
if !excludeSnapshot {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, !excludeRaw); err != nil {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, false); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ const (
|
||||
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
|
||||
// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)"
|
||||
// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @param name query string false "will return only environments(endpoints) with this name"
|
||||
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
|
||||
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
|
||||
@@ -63,7 +62,6 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
@@ -118,7 +116,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
|
||||
|
||||
if !query.excludeSnapshots {
|
||||
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], !excludeRaw); err != nil {
|
||||
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], false); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.36.0
|
||||
// @version 2.37.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -80,7 +81,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", cli.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
api/internal/registryutils/get_registry_name.go
Normal file
11
api/internal/registryutils/get_registry_name.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package registryutils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func RegistrySecretName(registryID portainer.RegistryID) string {
|
||||
return "registry-" + strconv.Itoa(int(registryID))
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -34,7 +33,7 @@ type (
|
||||
)
|
||||
|
||||
func (kcl *KubeClient) DeleteRegistrySecret(registry portainer.RegistryID, namespace string) error {
|
||||
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), kcl.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
|
||||
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), registryutils.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
|
||||
return errors.Wrap(err, "failed removing secret")
|
||||
}
|
||||
|
||||
@@ -62,11 +61,15 @@ func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namesp
|
||||
}
|
||||
|
||||
secret := &v1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "Secret",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: kcl.RegistrySecretName(registry.ID),
|
||||
Name: registryutils.RegistrySecretName(registry.ID),
|
||||
Labels: map[string]string{
|
||||
labelRegistryType: strconv.Itoa(int(registry.Type)),
|
||||
labelRegistryType: strconv.Itoa(int(registry.Type)),
|
||||
"app.kubernetes.io/managed-by": "portainer",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
annotationRegistryID: strconv.Itoa(int(registry.ID)),
|
||||
@@ -99,7 +102,3 @@ 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,6 +29,8 @@ 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
|
||||
@@ -536,6 +538,65 @@ 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
|
||||
|
||||
@@ -866,9 +927,11 @@ 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"`
|
||||
Namespaces []string `json:"Namespaces"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// RegistryID represents a registry identifier
|
||||
@@ -1794,7 +1857,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.36.0"
|
||||
APIVersion = "2.37.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
|
||||
@@ -2367,3 +2430,24 @@ 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,12 +55,11 @@ func (d *stackDeployer) DeployRemoteComposeStack(
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.swarmStackManager.Login(registries, endpoint)
|
||||
defer d.swarmStackManager.Logout(endpoint)
|
||||
options := portainer.ComposeOptions{Registries: registries}
|
||||
|
||||
// --force-recreate doesn't pull updated images
|
||||
if forcePullImage {
|
||||
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, portainer.ComposeOptions{}); err != nil {
|
||||
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +75,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
url: '/configs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/configs/configs.html',
|
||||
controller: 'ConfigsController',
|
||||
controllerAs: 'ctrl',
|
||||
component: 'configsListView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<host-details-panel
|
||||
host="$ctrl.hostDetails"
|
||||
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && $ctrl.hostFeaturesEnabled"
|
||||
is-browse-enabled="$ctrl.isAdmin && $ctrl.isAgent && $ctrl.agentApiVersion > 1 && $ctrl.hostFeaturesEnabled"
|
||||
browse-url="{{ $ctrl.browseUrl }}"
|
||||
></host-details-panel>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ angular.module('portainer.docker').component('hostOverview', {
|
||||
refreshUrl: '@',
|
||||
browseUrl: '@',
|
||||
hostFeaturesEnabled: '<',
|
||||
isAdmin: '<',
|
||||
},
|
||||
transclude: true,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -79,14 +78,6 @@ const ngModule = angular
|
||||
'onRemove',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'dockerConfigsDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
|
||||
'dataset',
|
||||
'onRemoveClick',
|
||||
'onRefresh',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'agentHostBrowserReact',
|
||||
r2a(withUIRouter(withCurrentUser(AgentHostBrowser)), [
|
||||
|
||||
14
app/docker/react/views/configs.ts
Normal file
14
app/docker/react/views/configs.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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,9 +8,10 @@ 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])
|
||||
.module('portainer.docker.react.views', [containersModule, configsModule])
|
||||
.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 '../models/config';
|
||||
import { ConfigViewModel } from '@/react/docker/configs/model';
|
||||
|
||||
angular.module('portainer.docker').factory('ConfigService', ConfigServiceFactory);
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
@@ -1,59 +0,0 @@
|
||||
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,4 +8,5 @@
|
||||
refresh-url="docker.host"
|
||||
browse-url="docker.host.browser"
|
||||
host-features-enabled="$ctrl.state.enableHostManagementFeatures"
|
||||
is-admin="$ctrl.state.isAdmin"
|
||||
></host-overview>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
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,7 +10,6 @@ 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', [])
|
||||
@@ -80,12 +79,4 @@ export const gitFormModule = angular
|
||||
.component(
|
||||
'timeWindowDisplay',
|
||||
r2a(withReactQuery(withUIRouter(TimeWindowDisplay)), [])
|
||||
)
|
||||
.component(
|
||||
'stackRedeployGitForm',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(StackRedeployGitForm))), [
|
||||
'model',
|
||||
'stack',
|
||||
'endpoint',
|
||||
])
|
||||
).name;
|
||||
|
||||
@@ -210,6 +210,7 @@ export const ngModule = angular
|
||||
'aria-label',
|
||||
'size',
|
||||
'loadingMessage',
|
||||
'getOptionValue',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -6,6 +6,7 @@ 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', [])
|
||||
@@ -32,4 +33,19 @@ export const stacksModule = angular
|
||||
'originalContainerNames',
|
||||
'onSubmitSettled',
|
||||
])
|
||||
)
|
||||
|
||||
.component(
|
||||
'stackInfoTab',
|
||||
r2a(withUIRouter(withCurrentUser(StackInfoTab)), [
|
||||
'stack',
|
||||
'stackName',
|
||||
'stackFileContent',
|
||||
'isRegular',
|
||||
'isExternal',
|
||||
'isOrphaned',
|
||||
'environmentId',
|
||||
'isOrphanedRunning',
|
||||
'yamlError',
|
||||
])
|
||||
).name;
|
||||
|
||||
@@ -11,125 +11,17 @@
|
||||
<pr-icon icon="'list'"></pr-icon>
|
||||
Stack
|
||||
</uib-tab-heading>
|
||||
<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>
|
||||
<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>
|
||||
</uib-tab>
|
||||
<!-- !tab-info -->
|
||||
<!-- tab-file -->
|
||||
@@ -145,7 +37,7 @@
|
||||
is-orphaned="orphaned"
|
||||
initial-values="editorTabInitialValues"
|
||||
container-names="containerNames"
|
||||
original-container-names="originalContainerNames"
|
||||
original-container-names="state.originalContainerNames"
|
||||
versions="state.versions"
|
||||
stack-id="stack.Id"
|
||||
on-submit="(deployStack)"
|
||||
|
||||
@@ -3,10 +3,6 @@ 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',
|
||||
@@ -25,7 +21,6 @@ angular.module('portainer.app').controller('StackController', [
|
||||
'Notifications',
|
||||
'FormHelper',
|
||||
'StackHelper',
|
||||
'ResourceControlService',
|
||||
'Authentication',
|
||||
'ContainerHelper',
|
||||
'endpoint',
|
||||
@@ -46,7 +41,6 @@ angular.module('portainer.app').controller('StackController', [
|
||||
Notifications,
|
||||
FormHelper,
|
||||
StackHelper,
|
||||
ResourceControlService,
|
||||
Authentication,
|
||||
ContainerHelper,
|
||||
endpoint
|
||||
@@ -60,7 +54,6 @@ 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 = {
|
||||
@@ -78,68 +71,6 @@ 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;
|
||||
};
|
||||
@@ -148,86 +79,6 @@ 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;
|
||||
@@ -399,12 +250,3 @@ 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'],
|
||||
base: () => ['stacks'] as const,
|
||||
stack: (stackId?: StackId) => [...queryKeys.base(), stackId] as const,
|
||||
stackFile: (stackId?: StackId, params?: unknown) =>
|
||||
[...queryKeys.stack(stackId), 'file', params] as const,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
44
app/react/common/stacks/queries/useDeleteStackMutation.ts
Normal file
44
app/react/common/stacks/queries/useDeleteStackMutation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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,6 +5,8 @@ import {
|
||||
RepoConfigResponse,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
|
||||
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
||||
|
||||
export type StackId = number;
|
||||
|
||||
export enum StackType {
|
||||
@@ -34,10 +36,7 @@ export interface Stack {
|
||||
EndpointId: number;
|
||||
SwarmId: string;
|
||||
EntryPoint: string;
|
||||
Env: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
Env: EnvVar[];
|
||||
ResourceControl?: ResourceControlResponse;
|
||||
Status: StackStatus;
|
||||
ProjectPath: string;
|
||||
@@ -67,7 +66,7 @@ export type StackFile = {
|
||||
};
|
||||
|
||||
export interface GitStackPayload {
|
||||
env: Array<{ name: string; value: string }>;
|
||||
env: Array<EnvVar>;
|
||||
prune?: boolean;
|
||||
RepositoryReferenceName?: string;
|
||||
RepositoryAuthentication?: boolean;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
type AlertType = 'success' | 'error' | 'info' | 'warn';
|
||||
type AlertType = 'success' | 'error' | 'info' | 'warn' | 'default';
|
||||
|
||||
export const alertSettings: Record<
|
||||
AlertType,
|
||||
@@ -12,32 +18,39 @@ 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-green-8',
|
||||
'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',
|
||||
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-error-8',
|
||||
'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',
|
||||
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-blue-8',
|
||||
'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',
|
||||
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-warning-8',
|
||||
'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',
|
||||
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>
|
||||
<p className="mb-0">{option.description}</p>
|
||||
<div className="mb-0">{option.description}</div>
|
||||
</ContentBox>
|
||||
</div>
|
||||
</BoxOption>
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
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'
|
||||
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal cursor-pointer'
|
||||
}
|
||||
>
|
||||
<input
|
||||
@@ -43,6 +43,7 @@ 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,6 +24,7 @@ type Color =
|
||||
| 'dangerlight'
|
||||
| 'warninglight'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'none';
|
||||
type Size = 'xsmall' | 'small' | 'medium' | 'large';
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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';
|
||||
@@ -48,6 +49,7 @@ interface SharedProps<TValue>
|
||||
option: FilterOptionOption<Option<TValue>>,
|
||||
rawInput: string
|
||||
) => boolean;
|
||||
getOptionValue?: (option: TValue) => string;
|
||||
}
|
||||
|
||||
interface MultiProps<TValue> extends SharedProps<TValue> {
|
||||
@@ -117,13 +119,14 @@ 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))
|
||||
? _.first(findSelectedOptions<TValue>(options, value, getOptionValue))
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -131,7 +134,9 @@ export function SingleSelect<TValue = string>({
|
||||
name={name}
|
||||
isClearable={isClearable}
|
||||
getOptionLabel={(option) => option.label}
|
||||
getOptionValue={(option) => String(option.value)}
|
||||
getOptionValue={(option) =>
|
||||
getOptionValue ? getOptionValue(option.value) : String(option.value)
|
||||
}
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={(option) => onChange(option ? option.value : null)}
|
||||
@@ -154,19 +159,30 @@ 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[]
|
||||
value: TValue | readonly TValue[],
|
||||
getOptionValue: (option: TValue) => string | TValue = (v: TValue) => v
|
||||
) {
|
||||
const valueArr = Array.isArray(value) ? value : [value];
|
||||
const valueArr = isSingleValue(value)
|
||||
? [getOptionValue(value)]
|
||||
: value.map((v) => getOptionValue(v));
|
||||
|
||||
const values = _.compact(
|
||||
options.flatMap((option) => {
|
||||
if (isGroup(option)) {
|
||||
return option.options.find((option) => valueArr.includes(option.value));
|
||||
return option.options.find((opt) =>
|
||||
valueArr.includes(getOptionValue(opt.value))
|
||||
);
|
||||
}
|
||||
|
||||
if (valueArr.includes(option.value)) {
|
||||
if (valueArr.includes(getOptionValue(option.value))) {
|
||||
return option;
|
||||
}
|
||||
|
||||
@@ -192,27 +208,35 @@ export function MultiSelect<TValue = string>({
|
||||
components,
|
||||
isLoading,
|
||||
noOptionsMessage,
|
||||
size,
|
||||
loadingMessage,
|
||||
formatCreateLabel,
|
||||
onCreateOption,
|
||||
isCreatable,
|
||||
size,
|
||||
getOptionValue,
|
||||
...aria
|
||||
}: Omit<MultiProps<TValue>, 'isMulti'>) {
|
||||
const selectedOptions = findSelectedOptions(options, value);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const selectedOptions = findSelectedOptions(options, value, getOptionValue);
|
||||
const SelectComponent = isCreatable ? Creatable : ReactSelect;
|
||||
|
||||
return (
|
||||
<SelectComponent
|
||||
name={name}
|
||||
isMulti
|
||||
isClearable={isClearable}
|
||||
getOptionLabel={(option) => option.label}
|
||||
getOptionValue={(option) => String(option.value)}
|
||||
getOptionValue={(option) =>
|
||||
getOptionValue ? getOptionValue(option.value) : String(option.value)
|
||||
}
|
||||
isOptionDisabled={(option) => !!option.disabled}
|
||||
options={options}
|
||||
value={selectedOptions}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(newValue) => onChange(newValue.map((option) => option.value))}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue.map((option) => option.value));
|
||||
setInputValue('');
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
@@ -223,14 +247,31 @@ 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,0 +1,181 @@
|
||||
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,38 +1,43 @@
|
||||
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 { DockerConfig } from '../../types';
|
||||
import { useConfigsList } from '../../queries/useConfigs';
|
||||
import { ConfigViewModel } from '../../model';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { createStore } from './store';
|
||||
|
||||
interface Props {
|
||||
dataset: Array<DockerConfig>;
|
||||
onRemoveClick: (configs: Array<DockerConfig>) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
import { DeleteConfigButton } from './DeleteConfigButton';
|
||||
|
||||
const storageKey = 'docker_configs';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function ConfigsDatatable({ dataset, onRefresh, onRemoveClick }: Props) {
|
||||
export function ConfigsDatatable() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
||||
const configListQuery = useConfigsList(environmentId, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
select: (configs) => configs.map((c) => new ConfigViewModel(c)),
|
||||
});
|
||||
|
||||
const hasWriteAccessQuery = useAuthorizations([
|
||||
'DockerConfigCreate',
|
||||
'DockerConfigDelete',
|
||||
]);
|
||||
|
||||
if (!configListQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataset = configListQuery.data;
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
dataset={dataset}
|
||||
@@ -54,12 +59,7 @@ export function ConfigsDatatable({ dataset, onRefresh, onRemoveClick }: Props) {
|
||||
hasWriteAccessQuery.authorized && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Authorized authorizations="DockerConfigDelete">
|
||||
<DeleteButton
|
||||
disabled={selectedRows.length === 0}
|
||||
data-cy="remove-docker-configs-button"
|
||||
onConfirmed={() => onRemoveClick(selectedRows)}
|
||||
confirmMessage="Do you want to remove the selected config(s)?"
|
||||
/>
|
||||
<DeleteConfigButton selectedItems={selectedRows} />
|
||||
</Authorized>
|
||||
|
||||
<Authorized authorizations="DockerConfigCreate">
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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 />);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 { buildNameColumn } from '@@/datatables/buildNameColumn';
|
||||
import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn';
|
||||
|
||||
import { DockerConfig } from '../../types';
|
||||
import { ConfigViewModel } from '../../model';
|
||||
|
||||
const columnHelper = createColumnHelper<DockerConfig>();
|
||||
const columnHelper = createColumnHelper<ConfigViewModel>();
|
||||
|
||||
export const columns = [
|
||||
buildNameColumn<DockerConfig>(
|
||||
'Name',
|
||||
'docker.configs.config',
|
||||
'docker-configs-name'
|
||||
),
|
||||
buildNameColumnFromObject<ConfigViewModel>({
|
||||
nameKey: 'Name',
|
||||
path: 'docker.configs.config',
|
||||
dataCy: '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<DockerConfig>(),
|
||||
createOwnershipColumn<ConfigViewModel>(),
|
||||
];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ConfigsDatatable } from './ConfigsDatatable';
|
||||
70
app/react/docker/configs/ListView/ListView.test.tsx
Normal file
70
app/react/docker/configs/ListView/ListView.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
13
app/react/docker/configs/ListView/ListView.tsx
Normal file
13
app/react/docker/configs/ListView/ListView.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { ConfigsDatatable } from './ConfigsDatatable/ConfigsDatatable';
|
||||
|
||||
export function ListView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Configs list" breadcrumbs="Configs" reload />
|
||||
|
||||
<ConfigsDatatable />
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
app/react/docker/configs/model.ts
Normal file
55
app/react/docker/configs/model.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
6
app/react/docker/configs/queries/build-url.ts
Normal file
6
app/react/docker/configs/queries/build-url.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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);
|
||||
}
|
||||
8
app/react/docker/configs/queries/query-keys.ts
Normal file
8
app/react/docker/configs/queries/query-keys.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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,16 +3,17 @@ import { Config } from 'docker-types/generated/1.44';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
|
||||
import { DockerConfig } from '../types';
|
||||
import { PortainerResponse } from '../../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export async function getConfig(
|
||||
environmentId: EnvironmentId,
|
||||
configId: DockerConfig['Id']
|
||||
configId: Config['ID']
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<Config>(
|
||||
buildDockerProxyUrl(environmentId, 'configs', configId)
|
||||
const { data } = await axios.get<PortainerResponse<Config>>(
|
||||
buildUrl(environmentId, configId)
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
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,16 +1,26 @@
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { DockerConfig } from '../types';
|
||||
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export async function deleteConfig(
|
||||
environmentId: EnvironmentId,
|
||||
id: DockerConfig['Id']
|
||||
) {
|
||||
export function useDeleteConfigMutation() {
|
||||
return useMutation({
|
||||
mutationFn: deleteConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteConfig({
|
||||
environmentId,
|
||||
configId,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
configId: string;
|
||||
}) {
|
||||
try {
|
||||
await axios.delete(buildDockerProxyUrl(environmentId, 'configs', id));
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to delete config');
|
||||
await axios.delete(buildUrl(environmentId, configId));
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to delete config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,11 +149,6 @@ 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();
|
||||
@@ -176,9 +171,6 @@ 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();
|
||||
@@ -208,17 +200,21 @@ describe('form submission', () => {
|
||||
})
|
||||
);
|
||||
|
||||
renderComponent({ stackId: 42 });
|
||||
const initialValues: Partial<StackEditorFormValues> = {
|
||||
stackFileContent: 'version: "3.8"',
|
||||
environmentVariables: [],
|
||||
webhookId: '',
|
||||
prune: false,
|
||||
registries: [],
|
||||
};
|
||||
|
||||
renderComponent({ stackId: 42, initialValues });
|
||||
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();
|
||||
@@ -259,9 +255,6 @@ 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();
|
||||
@@ -290,9 +283,6 @@ 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();
|
||||
@@ -323,9 +313,6 @@ 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,14 +357,8 @@ describe('version rollback', () => {
|
||||
});
|
||||
|
||||
describe('form submission', () => {
|
||||
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, ' ');
|
||||
it('should enable submit button when form is valid', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const deployButton = screen.getByTestId('stack-deploy-button');
|
||||
@@ -372,23 +366,9 @@ 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');
|
||||
@@ -403,10 +383,6 @@ 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();
|
||||
@@ -420,15 +396,11 @@ describe('form submission', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSubmit with form values', async () => {
|
||||
it('should call onSubmit with form values when button is clicked', 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();
|
||||
@@ -438,12 +410,7 @@ describe('form submission', () => {
|
||||
await user.click(deployButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stackFileContent: expect.stringContaining('# comment'),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,6 @@ export function StackEditorTabInner({
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
dirty,
|
||||
initialValues,
|
||||
} = useFormikContext<StackEditorFormValues>();
|
||||
|
||||
@@ -168,7 +167,7 @@ export function StackEditorTabInner({
|
||||
|
||||
<Authorized authorizations="PortainerStackUpdate">
|
||||
<FormActions
|
||||
isValid={isValid && !isDeployDisabled && dirty}
|
||||
isValid={isValid && !isDeployDisabled}
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Deployment in progress..."
|
||||
submitLabel="Update the stack"
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
245
app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx
Normal file
245
app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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('^');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
129
app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx
Normal file
129
app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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={() => {}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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,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,10 +13,13 @@ 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
|
||||
>;
|
||||
@@ -31,14 +34,6 @@ 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(),
|
||||
}));
|
||||
@@ -54,19 +49,20 @@ vi.mock('@/portainer/helpers/webhookHelper', () => ({
|
||||
|
||||
vi.mock('@/react/portainer/gitops/AutoUpdateFieldset/utils', () => ({
|
||||
parseAutoUpdateResponse: vi.fn(() => ({
|
||||
RepositoryAutomaticUpdates: false,
|
||||
RepositoryMechanism: 'Webhook',
|
||||
RepositoryFetchInterval: '5m',
|
||||
ForcePullImage: false,
|
||||
RepositoryAutomaticUpdatesForce: false,
|
||||
})),
|
||||
transformAutoUpdateViewModel: vi.fn(() => ({
|
||||
RepositoryAutomaticUpdates: false,
|
||||
RepositoryAutomaticUpdates: true,
|
||||
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
|
||||
@@ -115,7 +111,8 @@ vi.mock('@/react/portainer/gitops/RefField', () => ({
|
||||
RefField: vi.fn(() => <div data-testid="ref-field">Ref Field</div>),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/portainer/gitops/AuthFieldset', () => ({
|
||||
vi.mock('@/react/portainer/gitops/AuthFieldset', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
AuthFieldset: vi.fn(() => (
|
||||
<div data-testid="auth-fieldset">
|
||||
<div>Repository Authentication</div>
|
||||
@@ -132,91 +129,62 @@ 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(),
|
||||
}));
|
||||
|
||||
const mockUseUpdateGitStack = vi.mocked(useUpdateGitStack);
|
||||
const mockUseUpdateGitStackSettings = vi.mocked(useUpdateGitStackSettings);
|
||||
vi.mock('@/react/docker/proxy/queries/useVersion', () => ({
|
||||
useApiVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
// In test setup or beforeEach
|
||||
beforeEach(() => {
|
||||
vi.mocked(useApiVersion).mockReturnValue(1.27);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const Component = withTestQueryProvider(
|
||||
withTestRouter(() => (
|
||||
<StackRedeployGitForm {...defaultProps} {...props} />
|
||||
))
|
||||
server.use(
|
||||
http.put('/api/stacks/:id/git/redeploy', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
),
|
||||
http.post('/api/stacks/:id/git', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
return render(<Component />);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Basic rendering', () => {
|
||||
it('should render the form with correct sections', () => {
|
||||
@@ -225,7 +193,8 @@ describe('StackRedeployGitForm', () => {
|
||||
expect(
|
||||
screen.getByText('Redeploy from git repository')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Options')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Options')).toBeInTheDocument(); // available only when apiVersion is >= 1.27
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -312,10 +281,14 @@ describe('StackRedeployGitForm', () => {
|
||||
|
||||
it('should call confirmEnableTLSVerify when enabling TLS verification', async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithTLSDisabled = {
|
||||
...defaultProps,
|
||||
model: { ...defaultProps.model, TLSSkipVerify: true },
|
||||
const propsWithTLSDisabled: DeepPartial<StackRedeployGitFormProps> = {
|
||||
stack: {
|
||||
GitConfig: {
|
||||
TLSSkipVerify: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderComponent(propsWithTLSDisabled);
|
||||
|
||||
const toggleButton = screen.getByTestId(
|
||||
@@ -334,6 +307,7 @@ 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();
|
||||
@@ -343,21 +317,20 @@ describe('StackRedeployGitForm', () => {
|
||||
});
|
||||
|
||||
it('should not show options section for non-swarm stacks', () => {
|
||||
const propsWithComposeStack = {
|
||||
...defaultProps,
|
||||
stack: { ...defaultProps.stack, Type: 2 }, // Compose stack
|
||||
};
|
||||
renderComponent(propsWithComposeStack);
|
||||
vi.mocked(useApiVersion).mockReturnValue(1.27);
|
||||
|
||||
renderComponent({
|
||||
stack: {
|
||||
Type: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show options section for older API versions', () => {
|
||||
const propsWithOldAPI = {
|
||||
...defaultProps,
|
||||
endpoint: { ...defaultProps.endpoint, apiVersion: 1.26 },
|
||||
};
|
||||
renderComponent(propsWithOldAPI);
|
||||
vi.mocked(useApiVersion).mockReturnValue(1.26);
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByText('Options')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -378,30 +351,38 @@ 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(mockUpdateGitStackMutation.mutateAsync).toHaveBeenCalledWith({
|
||||
env: defaultProps.stack.Env,
|
||||
prune: false,
|
||||
RepositoryReferenceName: 'refs/heads/main',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryGitCredentialID: 0,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
PullImage: false,
|
||||
});
|
||||
expect(requestBody).toEqual(
|
||||
expect.objectContaining({
|
||||
prune: false,
|
||||
RepositoryReferenceName: 'refs/heads/main',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -415,10 +396,13 @@ describe('StackRedeployGitForm', () => {
|
||||
|
||||
it('should disable redeploy button when in progress', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Mock the mutation to simulate loading state
|
||||
mockUpdateGitStackMutation.mutateAsync.mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
); // Never resolves
|
||||
server.use(
|
||||
http.put('/api/stacks/:id/git/redeploy', async () => {
|
||||
// never resolve
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
renderComponent();
|
||||
|
||||
const redeployButton = screen.getByTestId('stack-redeploy-button');
|
||||
@@ -433,8 +417,16 @@ 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
|
||||
@@ -452,18 +444,13 @@ describe('StackRedeployGitForm', () => {
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
mockUpdateGitStackSettingsMutation.mutateAsync
|
||||
).toHaveBeenCalledWith({
|
||||
stackId: 1,
|
||||
endpointId: 1,
|
||||
payload: expect.objectContaining({
|
||||
env: defaultProps.stack.Env,
|
||||
expect(requestBody).toEqual(
|
||||
expect.objectContaining({
|
||||
RepositoryReferenceName: 'refs/heads/main',
|
||||
prune: false,
|
||||
TLSSkipVerify: true,
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -494,7 +481,13 @@ describe('StackRedeployGitForm', () => {
|
||||
});
|
||||
|
||||
it('should disable save button when in progress', () => {
|
||||
mockUpdateGitStackSettingsMutation.isLoading = true;
|
||||
server.use(
|
||||
http.post('/api/stacks/:id/git', async () => {
|
||||
// never resolve
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
renderComponent();
|
||||
|
||||
const saveButton = screen.getByTestId('stack-save-settings-button');
|
||||
@@ -523,12 +516,18 @@ describe('StackRedeployGitForm', () => {
|
||||
await user.click(tlsSwitch);
|
||||
|
||||
// Should now have unsaved changes
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear unsaved changes after successful save', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateGitStackSettingsMutation.mutateAsync.mockResolvedValue({});
|
||||
server.use(
|
||||
http.put('/api/stacks/:id/git/redeploy', async () =>
|
||||
HttpResponse.json({ success: true })
|
||||
)
|
||||
);
|
||||
renderComponent();
|
||||
|
||||
// Make a change
|
||||
@@ -555,9 +554,12 @@ describe('StackRedeployGitForm', () => {
|
||||
describe('Error handling', () => {
|
||||
it('should handle updateGitStack mutation errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateGitStackMutation.mutateAsync.mockRejectedValue(
|
||||
new Error('Update failed')
|
||||
server.use(
|
||||
http.put('/api/stacks/:id/git/redeploy', async () =>
|
||||
HttpResponse.json({ error: 'Update failed' }, { status: 400 })
|
||||
)
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const redeployButton = screen.getByTestId('stack-redeploy-button');
|
||||
@@ -570,9 +572,12 @@ describe('StackRedeployGitForm', () => {
|
||||
|
||||
it('should handle updateGitStackSettings mutation errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateGitStackSettingsMutation.mutateAsync.mockRejectedValue(
|
||||
new Error('Save failed')
|
||||
server.use(
|
||||
http.post('/api/stacks/:id/git', async () =>
|
||||
HttpResponse.json({ error: 'Update failed' }, { status: 400 })
|
||||
)
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Make a change to enable save button
|
||||
@@ -591,7 +596,7 @@ describe('StackRedeployGitForm', () => {
|
||||
|
||||
// Should not clear unsaved changes on error
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -599,10 +604,8 @@ describe('StackRedeployGitForm', () => {
|
||||
describe('Git authentication', () => {
|
||||
it('should handle git authentication configuration', async () => {
|
||||
const user = userEvent.setup();
|
||||
const propsWithAuth = {
|
||||
...defaultProps,
|
||||
const propsWithAuth: DeepPartial<StackRedeployGitFormProps> = {
|
||||
stack: {
|
||||
...defaultProps.stack,
|
||||
GitConfig: {
|
||||
Authentication: {
|
||||
Username: 'testuser',
|
||||
@@ -625,26 +628,139 @@ describe('StackRedeployGitForm', () => {
|
||||
});
|
||||
|
||||
describe('Webhook configuration', () => {
|
||||
it('should generate webhook ID on initialization', () => {
|
||||
renderComponent();
|
||||
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: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 if available', () => {
|
||||
const propsWithWebhook = {
|
||||
...defaultProps,
|
||||
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({
|
||||
stack: {
|
||||
...defaultProps.stack,
|
||||
AutoUpdate: {
|
||||
...defaultProps.stack.AutoUpdate,
|
||||
Webhook: 'existing-webhook-id',
|
||||
},
|
||||
},
|
||||
};
|
||||
renderComponent(propsWithWebhook);
|
||||
});
|
||||
|
||||
expect(mockCreateWebhookId).toHaveBeenCalled();
|
||||
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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,144 @@
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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,6 +6,8 @@ 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 {
|
||||
@@ -19,7 +21,7 @@ export class StackViewModel implements IResource {
|
||||
|
||||
SwarmId: string;
|
||||
|
||||
Env: { name: string; value: string }[];
|
||||
Env: EnvVar[];
|
||||
|
||||
Option: { Prune: boolean; Force: boolean } | undefined;
|
||||
|
||||
|
||||
@@ -35,20 +35,25 @@ const defaultProps = {
|
||||
|
||||
const mockRepoOptions = [
|
||||
{
|
||||
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',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -129,8 +134,8 @@ describe('HelmRegistrySelect', () => {
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.click(select);
|
||||
|
||||
expect(screen.getByText('Bitnami')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stable')).toBeInTheDocument();
|
||||
screen.getAllByText('Bitnami').forEach((el) => expect(el).toBeVisible());
|
||||
screen.getAllByText('Stable').forEach((el) => expect(el).toBeVisible());
|
||||
});
|
||||
|
||||
it.skip('should call onRegistryChange when option is selected', async () => {
|
||||
|
||||
@@ -5,9 +5,11 @@ 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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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,6 +1,25 @@
|
||||
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,7 +10,6 @@ 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';
|
||||
@@ -57,24 +56,22 @@ export function useColumns() {
|
||||
{status.phase}
|
||||
</StatusBadge>
|
||||
{item.UnhealthyEventCount > 0 && (
|
||||
<TooltipWithChildren message="View events" position="top">
|
||||
<span className="inline-flex">
|
||||
<span className="inline-flex">
|
||||
<Badge type="warnSecondary">
|
||||
<Icon icon={AlertTriangle} className="!mr-1 h-3 w-3" />
|
||||
<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"
|
||||
>
|
||||
<Badge type="warnSecondary">
|
||||
<Icon
|
||||
icon={AlertTriangle}
|
||||
className="!mr-1 h-3 w-3"
|
||||
/>
|
||||
{item.UnhealthyEventCount}{' '}
|
||||
{pluralize(item.UnhealthyEventCount, 'warning')}
|
||||
</Badge>
|
||||
{item.UnhealthyEventCount}{' '}
|
||||
{pluralize(item.UnhealthyEventCount, 'warning')}
|
||||
</Link>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,19 @@ 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,
|
||||
@@ -18,7 +31,7 @@ export function PathSelector({
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
placeholder: string;
|
||||
model: GitFormModel;
|
||||
model: PathSelectorGitModel;
|
||||
dirOnly?: boolean;
|
||||
readOnly?: boolean;
|
||||
inputId: string;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector';
|
||||
import {
|
||||
PathSelector,
|
||||
PathSelectorGitModel,
|
||||
} from '@/react/portainer/gitops/ComposePathField/PathSelector';
|
||||
import { dummyGitForm } from '@/react/portainer/gitops/RelativePathFieldset/utils';
|
||||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
@@ -16,7 +18,7 @@ import { RelativePathModel, getPerDevConfigsFilterType } from './types';
|
||||
|
||||
interface Props {
|
||||
values: RelativePathModel;
|
||||
gitModel?: GitFormModel;
|
||||
gitModel?: PathSelectorGitModel;
|
||||
onChange: (value: RelativePathModel) => void;
|
||||
isEditing?: boolean;
|
||||
hideEdgeConfigs?: boolean;
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
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,4 +1,7 @@
|
||||
import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types';
|
||||
import {
|
||||
AuthTypeOption,
|
||||
GitCredential,
|
||||
} from '@/react/portainer/account/git-credentials/types';
|
||||
|
||||
export type AutoUpdateMechanism = 'Webhook' | 'Interval';
|
||||
export { type RelativePathModel } from './RelativePathFieldset/types';
|
||||
@@ -20,6 +23,7 @@ export interface AutoUpdateResponse {
|
||||
export interface GitAuthenticationResponse {
|
||||
Username?: string;
|
||||
Password?: string;
|
||||
AuthorizationType?: AuthTypeOption;
|
||||
GitCredentialID?: number;
|
||||
}
|
||||
|
||||
@@ -44,7 +48,7 @@ export type GitCredentialsModel = {
|
||||
RepositoryAuthentication?: boolean;
|
||||
RepositoryUsername?: string;
|
||||
RepositoryPassword?: string;
|
||||
RepositoryGitCredentialID?: number;
|
||||
RepositoryGitCredentialID?: GitCredential['id'];
|
||||
RepositoryAuthorizationType?: AuthTypeOption;
|
||||
};
|
||||
|
||||
@@ -55,7 +59,7 @@ export type GitNewCredentialModel = {
|
||||
|
||||
export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel;
|
||||
|
||||
export type DeployMethod = 'compose' | 'manifest';
|
||||
export type DeployMethod = 'compose' | 'manifest' | 'helm';
|
||||
|
||||
export interface GitFormModel extends GitAuthModel {
|
||||
RepositoryURL: string;
|
||||
@@ -101,6 +105,7 @@ export function toGitFormModel(
|
||||
),
|
||||
RepositoryUsername: Authentication?.Username,
|
||||
RepositoryPassword: Authentication?.Password,
|
||||
RepositoryAuthorizationType: Authentication?.AuthorizationType,
|
||||
RepositoryGitCredentialID: Authentication?.GitCredentialID,
|
||||
TLSSkipVerify,
|
||||
AutoUpdate: autoUpdate,
|
||||
|
||||
@@ -99,4 +99,7 @@ export const handlers = [
|
||||
message: 'Registry connection successful',
|
||||
})
|
||||
),
|
||||
http.put('/api/resource_controls/:id', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
),
|
||||
];
|
||||
|
||||
@@ -69,9 +69,14 @@ 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/portainer" \
|
||||
-o "../dist/${BINARY_NAME}" \
|
||||
./cmd/portainer/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.36.0",
|
||||
"version": "2.37.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -26,6 +26,8 @@ type InstallOptions struct {
|
||||
DryRun bool
|
||||
Timeout time.Duration
|
||||
KubernetesClusterAccess *KubernetesClusterAccess
|
||||
TakeOwnership bool
|
||||
CreateNamespace bool
|
||||
|
||||
// GitOps related options
|
||||
GitConfig *gittypes.RepoConfig
|
||||
|
||||
@@ -153,6 +153,8 @@ 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")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user