Compare commits

...

20 Commits

Author SHA1 Message Date
Oscar Zhou
8869b91b71 version: bump version to 2.37.0 (#1501) 2025-12-11 11:00:02 +13:00
Steven Kang
2406d67bfc feat(fcm): initial release (#1153)
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Viktor Pettersson <viktor.grasljunga@gmail.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
Co-authored-by: Robbie Cowan <robert.cowan@portainer.io>
2025-12-09 08:05:38 +09:00
Oscar Zhou
f0266e9316 fix(stack/remote): fail to pull image in stack with relative path enabled [BE-12237] (#1493) 2025-12-09 08:59:19 +13:00
Chaim Lev-Ari
c08f42315e feat(docker/host): disable browse for non admin [BE-12438] (#1484) 2025-12-08 16:51:52 -03:00
Chaim Lev-Ari
d2649dac90 fix(docker/services): ignore missing EndpointSpec [BE-12460] (#1494) 2025-12-08 16:51:18 -03:00
LP B
300681055e fix(api): do not give away information on error (#1496) 2025-12-08 16:50:00 -03:00
andres-portainer
712dbc9396 fix(endpointedge): reject async edge environments from the edge job logs handler BE-12372 (#1488) 2025-12-08 15:05:32 -03:00
andres-portainer
f6b8e8615f fix(endpointedge): fix an incorrect documentation comment BE-12372 (#1486) 2025-12-08 11:59:53 -03:00
andres-portainer
4826c13848 fix(endpointedge): add a check for the relation of an environment and an edge job before updating the logs BE-12372 (#1487) 2025-12-08 11:59:40 -03:00
Yajith Dayarathna
80f497a185 chore(ci): minor ci workflow updates (#1491) 2025-12-08 14:12:24 +13:00
LP B
d2a9adb4be fix(compose): use project in compose start options (#1477) 2025-12-05 15:22:40 +01:00
Oscar Zhou
8675086441 fix(stack): "update the stack" button is disable in stakc deployed via web editor [BE-12456] (#1473) 2025-12-05 08:56:13 +13:00
Devon Steenberg
b79e784764 fix(stacks): stack updating with container_name [BE-12443] (#1453) 2025-12-02 09:32:03 +13:00
Chaim Lev-Ari
93ba3e700e fix(ui/code-editor): keep search panel in editor layer [BE-12429] (#1452) 2025-11-27 14:32:57 +02:00
Chaim Lev-Ari
bf6cb8d0b8 refactor(stacks): use formik in StackRedeployGitForm [BE-12430] (#1433) 2025-11-27 08:43:51 +02:00
Hannah Cooper
7010d7bf66 Update bug_report to include 2.33.5 and 2.36.0 (#1447) 2025-11-27 10:35:38 +13:00
Oscar Zhou
1a862157a0 fix(snapshot): prevent from returning SnapshotRaw data [BE-12431] (#1441) 2025-11-26 13:07:43 +13:00
Chaim Lev-Ari
532575cab5 refactor(stacks): migrate info tab to react [BE-12383] (#1415) 2025-11-25 13:17:26 +02:00
Chaim Lev-Ari
0794d0f89f refactor(docker/configs): migrate to react [BE-6541] (#1430) 2025-11-25 12:02:50 +02:00
Chaim Lev-Ari
e227ffd6d8 feat(stacks): create webhook id only if needed [BE-12392] (#1432) 2025-11-25 10:48:15 +02:00
104 changed files with 3886 additions and 1234 deletions

View File

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

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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]{

View 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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -74,7 +74,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment 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

View File

@@ -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)
}

View 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)
}

View File

@@ -40,18 +40,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment 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)

View File

@@ -97,13 +97,13 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
firstConn := endpoint.LastCheckInDate == 0
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment 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,
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

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

View File

@@ -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)
}
}

View 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))
}

View File

@@ -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)
}

View File

@@ -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"
)

View File

@@ -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
}
}

View File

@@ -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: {

View File

@@ -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>

View File

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

View File

@@ -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)), [

View 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;

View File

@@ -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)), [])

View File

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

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -10,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;

View File

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

View File

@@ -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;

View File

@@ -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)"

View File

@@ -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'),
});
}

View File

@@ -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,

View File

@@ -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');
}
}

View 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');
}
}

View File

@@ -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;

View File

@@ -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({

View File

@@ -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>

View File

@@ -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) {

View File

@@ -31,7 +31,7 @@ export function RadioGroup<T extends string | number = string>({
key={option.value}
className={
itemClassName ??
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal'
'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>

View File

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

View File

@@ -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>(

View File

@@ -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 />);
}

View File

@@ -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">

View File

@@ -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 />);
}

View File

@@ -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)]),
});
}

View File

@@ -3,18 +3,18 @@ import { createColumnHelper } from '@tanstack/react-table';
import { isoDate } from '@/portainer/filters/filters';
import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
import { 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>(),
];

View File

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

View 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();
});
});

View 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 />
</>
);
}

View 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);
}
}

View 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);
}

View 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),
};

View File

@@ -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) {

View File

@@ -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[]>(

View File

@@ -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');
}
}

View File

@@ -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();

View File

@@ -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();
});
});
});

View File

@@ -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"

View File

@@ -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,
},
};
}

View File

@@ -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>
);
}

View 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('^');
},
}
);
}
}

View File

@@ -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,
};
}

View 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 &quot;Associate to this environment&quot;
feature.
</span>
)}
</p>
</span>
</div>
</FormSection>
);
}

View File

@@ -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>
);
}

View File

@@ -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={() => {}}
/>
</>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,11 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { HttpResponse } from 'msw';
import _ from 'lodash';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { useUpdateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack';
import { useUpdateGitStackSettings } from '@/react/portainer/gitops/queries/useUpdateGitStackSettings';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
import {
@@ -13,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)} />);
}

View File

@@ -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();
},
});
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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(),
});
}

View File

@@ -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();
});
});
});

View File

@@ -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;
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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 ?? ''),
});

View File

@@ -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>
);
},
});

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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,

View File

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

View File

@@ -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/

View File

@@ -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"

View File

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

View File

@@ -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