feat(edge/stacks): increase status transparency [EE-5554] (#9094)

pull/9209/head
Chaim Lev-Ari 1 year ago committed by GitHub
parent db61fb149b
commit 0bcb57568c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

2
.gitignore vendored

@ -11,7 +11,7 @@ storybook-static
*.DS_Store *.DS_Store
.eslintcache .eslintcache
__debug_bin __debug_bin*
api/docs api/docs
.idea .idea

@ -78,7 +78,6 @@ type (
EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error)
EdgeStackVersion(ID portainer.EdgeStackID) (int, bool) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
// Deprecated: Use UpdateEdgeStackFunc instead.
UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
DeleteEdgeStack(ID portainer.EdgeStackID) error DeleteEdgeStack(ID portainer.EdgeStackID) error

@ -2,8 +2,9 @@ package migrator
import ( import (
"os" "os"
"time"
"github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/chisel/crypto" "github.com/portainer/portainer/api/chisel/crypto"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -73,3 +74,77 @@ func (m *Migrator) convertSeedToPrivateKeyForDB100() error {
} }
return err return err
} }
func (m *Migrator) updateEdgeStackStatusForDB100() error {
log.Info().Msg("update edge stack status to have deployment steps")
edgeStacks, err := m.edgeStackService.EdgeStacks()
if err != nil {
return err
}
for _, edgeStack := range edgeStacks {
for environmentID, environmentStatus := range edgeStack.Status {
// skip if status is already updated
if len(environmentStatus.Status) > 0 {
continue
}
statusArray := []portainer.EdgeStackDeploymentStatus{}
if environmentStatus.Details.Pending {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusPending,
Time: time.Now().Unix(),
})
}
if environmentStatus.Details.Acknowledged {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusAcknowledged,
Time: time.Now().Unix(),
})
}
if environmentStatus.Details.Error {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusError,
Error: environmentStatus.Error,
Time: time.Now().Unix(),
})
}
if environmentStatus.Details.Ok {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRunning,
Time: time.Now().Unix(),
})
}
if environmentStatus.Details.ImagesPulled {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusImagesPulled,
Time: time.Now().Unix(),
})
}
if environmentStatus.Details.Remove {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRemoving,
Time: time.Now().Unix(),
})
}
environmentStatus.Status = statusArray
edgeStack.Status[environmentID] = environmentStatus
}
err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
if err != nil {
return err
}
}
return nil
}

@ -78,13 +78,13 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
switch status.Type { switch status.Type {
case portainer.EdgeStackStatusPending: case portainer.EdgeStackStatusPending:
status.Details.Pending = true status.Details.Pending = true
case portainer.EdgeStackStatusOk: case portainer.EdgeStackStatusDeploymentReceived:
status.Details.Ok = true status.Details.Ok = true
case portainer.EdgeStackStatusError: case portainer.EdgeStackStatusError:
status.Details.Error = true status.Details.Error = true
case portainer.EdgeStackStatusAcknowledged: case portainer.EdgeStackStatusAcknowledged:
status.Details.Acknowledged = true status.Details.Acknowledged = true
case portainer.EdgeStackStatusRemove: case portainer.EdgeStackStatusRemoved:
status.Details.Remove = true status.Details.Remove = true
case portainer.EdgeStackStatusRemoteUpdateSuccess: case portainer.EdgeStackStatusRemoteUpdateSuccess:
status.Details.RemoteUpdateSuccess = true status.Details.RemoteUpdateSuccess = true

@ -215,6 +215,7 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.19", m.addMigrations("2.19",
m.convertSeedToPrivateKeyForDB100, m.convertSeedToPrivateKeyForDB100,
m.migrateDockerDesktopExtentionSetting, m.migrateDockerDesktopExtentionSetting,
m.updateEdgeStackStatusForDB100,
) )
// Add new migrations below... // Add new migrations below...

@ -944,6 +944,6 @@
} }
], ],
"version": { "version": {
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" "VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
} }
} }

@ -3,6 +3,7 @@ package edgestacks
import ( import (
"errors" "errors"
"net/http" "net/http"
"time"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
@ -25,6 +26,7 @@ import (
// @failure 400 // @failure 400
// @failure 404 // @failure 404
// @failure 403 // @failure 403
// @deprecated
// @router /edge_stacks/{id}/status/{environmentId} [delete] // @router /edge_stacks/{id}/status/{environmentId} [delete]
func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
@ -69,7 +71,17 @@ func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stack
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database") return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
} }
delete(stack.Status, endpoint.ID) environmentStatus, ok := stack.Status[endpoint.ID]
if !ok {
environmentStatus = portainer.EdgeStackStatus{}
}
environmentStatus.Status = append(environmentStatus.Status, portainer.EdgeStackDeploymentStatus{
Time: time.Now().Unix(),
Type: portainer.EdgeStackStatusRemoved,
})
stack.Status[endpoint.ID] = environmentStatus
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack) err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
if err != nil { if err != nil {

@ -3,21 +3,24 @@ package edgestacks
import ( import (
"errors" "errors"
"net/http" "net/http"
"time"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/pkg/featureflags" "github.com/portainer/portainer/pkg/featureflags"
"github.com/rs/zerolog/log"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
) )
type updateStatusPayload struct { type updateStatusPayload struct {
Error string Error string
Status *portainer.EdgeStackStatusType Status *portainer.EdgeStackStatusType
EndpointID portainer.EndpointID EndpointID portainer.EndpointID
Time int64
} }
func (payload *updateStatusPayload) Validate(r *http.Request) error { func (payload *updateStatusPayload) Validate(r *http.Request) error {
@ -33,6 +36,10 @@ func (payload *updateStatusPayload) Validate(r *http.Request) error {
return errors.New("error message is mandatory when status is error") return errors.New("error message is mandatory when status is error")
} }
if payload.Time == 0 {
payload.Time = time.Now().Unix()
}
return nil return nil
} }
@ -43,6 +50,7 @@ func (payload *updateStatusPayload) Validate(r *http.Request) error {
// @accept json // @accept json
// @produce json // @produce json
// @param id path int true "EdgeStack Id" // @param id path int true "EdgeStack Id"
// @param body body updateStatusPayload true "EdgeStack status payload"
// @success 200 {object} portainer.EdgeStack // @success 200 {object} portainer.EdgeStack
// @failure 500 // @failure 500
// @failure 400 // @failure 400
@ -84,6 +92,21 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
} }
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) { func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// skip error because agent tries to report on deleted stack
log.Warn().
Err(err).
Int("stackID", int(stackID)).
Int("status", int(*payload.Status)).
Msg("Unable to find a stack inside the database, skipping error")
return nil, nil
}
return nil, err
}
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID) endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
if err != nil { if err != nil {
return nil, handler.handlerDBErr(err, "Unable to find an environment with the specified identifier inside the database") return nil, handler.handlerDBErr(err, "Unable to find an environment with the specified identifier inside the database")
@ -94,67 +117,50 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
return nil, httperror.Forbidden("Permission denied to access environment", err) return nil, httperror.Forbidden("Permission denied to access environment", err)
} }
var stack *portainer.EdgeStack status := *payload.Status
if featureflags.IsEnabled(portainer.FeatureNoTx) { log.Debug().
err = tx.EdgeStack().UpdateEdgeStackFunc(portainer.EdgeStackID(stackID), func(edgeStack *portainer.EdgeStack) { Int("stackID", int(stackID)).
details := edgeStack.Status[payload.EndpointID].Details Int("status", int(status)).
details.Pending = false Msg("Updating stack status")
switch *payload.Status { deploymentStatus := portainer.EdgeStackDeploymentStatus{
case portainer.EdgeStackStatusOk: Type: status,
details.Ok = true
case portainer.EdgeStackStatusError:
details.Error = true
case portainer.EdgeStackStatusAcknowledged:
details.Acknowledged = true
case portainer.EdgeStackStatusRemove:
details.Remove = true
case portainer.EdgeStackStatusImagesPulled:
details.ImagesPulled = true
}
edgeStack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
Details: details,
Error: payload.Error, Error: payload.Error,
EndpointID: payload.EndpointID, Time: payload.Time,
} }
if featureflags.IsEnabled(portainer.FeatureNoTx) {
err = tx.EdgeStack().UpdateEdgeStackFunc(stackID, func(edgeStack *portainer.EdgeStack) {
updateEnvStatus(payload.EndpointID, edgeStack, deploymentStatus)
stack = edgeStack stack = edgeStack
}) })
} else {
stack, err = tx.EdgeStack().EdgeStack(stackID)
if err != nil { if err != nil {
return nil, err return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
}
details := stack.Status[payload.EndpointID].Details
details.Pending = false
switch *payload.Status {
case portainer.EdgeStackStatusOk:
details.Ok = true
case portainer.EdgeStackStatusError:
details.Error = true
case portainer.EdgeStackStatusAcknowledged:
details.Acknowledged = true
case portainer.EdgeStackStatusRemove:
details.Remove = true
case portainer.EdgeStackStatusImagesPulled:
details.ImagesPulled = true
}
stack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
Details: details,
Error: payload.Error,
EndpointID: payload.EndpointID,
} }
} else {
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
err = tx.EdgeStack().UpdateEdgeStack(stackID, stack) err = tx.EdgeStack().UpdateEdgeStack(stackID, stack)
}
if err != nil { if err != nil {
return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database") return nil, handler.handlerDBErr(err, "Unable to persist the stack changes inside the database")
} }
}
return stack, nil return stack, nil
} }
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
environmentStatus, ok := stack.Status[environmentId]
if !ok {
environmentStatus = portainer.EdgeStackStatus{
EndpointID: environmentId,
Status: []portainer.EdgeStackDeploymentStatus{},
}
}
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
stack.Status[environmentId] = environmentStatus
}

@ -59,23 +59,31 @@ func TestUpdateStatusAndInspect(t *testing.T) {
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
} }
data := portainer.EdgeStack{} updatedStack := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data) err = json.NewDecoder(rec.Body).Decode(&updatedStack)
if err != nil { if err != nil {
t.Fatal("error decoding response:", err) t.Fatal("error decoding response:", err)
} }
if !data.Status[endpoint.ID].Details.Error { endpointStatus, ok := updatedStack.Status[payload.EndpointID]
t.Fatalf("expected EdgeStackStatusType %d, found %t", payload.Status, data.Status[endpoint.ID].Details.Error) if !ok {
t.Fatal("Missing status")
} }
if data.Status[endpoint.ID].Error != payload.Error { lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
t.Fatalf("expected EdgeStackStatusError %s, found %s", payload.Error, data.Status[endpoint.ID].Error)
if len(endpointStatus.Status) == len(edgeStack.Status[payload.EndpointID].Status) {
t.Fatal("expected status array to be updated")
}
if lastStatus.Type != *payload.Status {
t.Fatalf("expected EdgeStackStatusType %d, found %d", *payload.Status, lastStatus.Type)
} }
if data.Status[endpoint.ID].EndpointID != payload.EndpointID { if endpointStatus.EndpointID != portainer.EndpointID(payload.EndpointID) {
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID) t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
} }
} }
func TestUpdateStatusWithInvalidPayload(t *testing.T) { func TestUpdateStatusWithInvalidPayload(t *testing.T) {
handler, _ := setupHandler(t) handler, _ := setupHandler(t)
@ -85,7 +93,7 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
// Update edge stack status // Update edge stack status
statusError := portainer.EdgeStackStatusError statusError := portainer.EdgeStackStatusError
statusOk := portainer.EdgeStackStatusOk statusOk := portainer.EdgeStackStatusDeploymentReceived
cases := []struct { cases := []struct {
Name string Name string
Payload updateStatusPayload Payload updateStatusPayload

@ -1,6 +1,7 @@
package edgestacks package edgestacks
import ( import (
"os"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@ -41,20 +42,22 @@ func setupHandler(t *testing.T) (*Handler, string) {
t.Fatal(err) t.Fatal(err)
} }
edgeStacksService := edgestacks.NewService(store) tmpDir, err := os.MkdirTemp(t.TempDir(), "portainer-test")
if err != nil {
t.Fatal(err)
}
fs, err := filesystem.NewService(tmpDir, "")
if err != nil {
t.Fatal(err)
}
handler := NewHandler( handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService), security.NewRequestBouncer(store, jwtService, apiKeyService),
store, store,
edgeStacksService, edgestacks.NewService(store),
) )
tmpDir := t.TempDir()
fs, err := filesystem.NewService(tmpDir, "")
if err != nil {
t.Fatal(err)
}
handler.FileService = fs handler.FileService = fs
settings, err := handler.DataStore.Settings().Settings() settings, err := handler.DataStore.Settings().Settings()
@ -118,9 +121,7 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
edgeStack := portainer.EdgeStack{ edgeStack := portainer.EdgeStack{
ID: edgeStackID, ID: edgeStackID,
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)), Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{ Status: map[portainer.EndpointID]portainer.EdgeStackStatus{},
endpointID: {Details: portainer.EdgeStackStatusDetails{Ok: true}, Error: "", EndpointID: endpointID},
},
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID}, EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
ProjectPath: "/project/path", ProjectPath: "/project/path",

@ -2,7 +2,7 @@ package edgestacks
import ( import (
"net/http" "net/http"
"strconv" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
@ -10,12 +10,9 @@ import (
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/set" "github.com/portainer/portainer/api/internal/set"
"github.com/portainer/portainer/pkg/featureflags" "github.com/portainer/portainer/pkg/featureflags"
"github.com/rs/zerolog/log"
) )
type updateEdgeStackPayload struct { type updateEdgeStackPayload struct {
@ -116,24 +113,6 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
} }
entryPoint := stack.EntryPoint
manifestPath := stack.ManifestPath
deploymentType := stack.DeploymentType
if deploymentType != payload.DeploymentType {
// deployment type was changed - need to delete the old file
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
if err != nil {
log.Warn().Err(err).Msg("Unable to clear old files")
}
entryPoint = ""
manifestPath = ""
deploymentType = payload.DeploymentType
}
stackFolder := strconv.Itoa(int(stack.ID))
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType) hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
if err != nil { if err != nil {
return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err) return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err)
@ -142,50 +121,20 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil) return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
} }
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose { stack.NumDeployments = len(relatedEndpointIds)
if entryPoint == "" {
entryPoint = filesystem.ComposeFileDefaultName
}
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, []byte(payload.StackFileContent)) stack.UseManifestNamespaces = payload.UseManifestNamespaces
if err != nil {
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
tempManifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, entryPoint, relatedEndpointIds)
if err != nil {
return nil, httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
}
manifestPath = tempManifestPath
}
if deploymentType == portainer.EdgeStackDeploymentKubernetes { stack.EdgeGroups = groupsIds
if manifestPath == "" {
manifestPath = filesystem.ManifestFileDefaultName
}
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, []byte(payload.StackFileContent)) if payload.UpdateVersion {
err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds)
if err != nil { if err != nil {
return nil, httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err) return nil, httperror.InternalServerError("Unable to update stack version", err)
}
} }
err = tx.EdgeStack().UpdateEdgeStackFunc(stack.ID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments = len(relatedEndpointIds)
if payload.UpdateVersion {
edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
edgeStack.Version++
} }
edgeStack.UseManifestNamespaces = payload.UseManifestNamespaces err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
edgeStack.DeploymentType = deploymentType
edgeStack.EntryPoint = entryPoint
edgeStack.ManifestPath = manifestPath
edgeStack.EdgeGroups = groupsIds
})
if err != nil { if err != nil {
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err) return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
} }
@ -246,3 +195,26 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
return newRelatedEnvironmentIDs, endpointsToAdd, nil return newRelatedEnvironmentIDs, endpointsToAdd, nil
} }
func newStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIds []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
newStatus := make(map[portainer.EndpointID]portainer.EdgeStackStatus)
for _, endpointID := range relatedEnvironmentIds {
newEnvStatus := portainer.EdgeStackStatus{}
oldEnvStatus, ok := oldStatus[endpointID]
if ok {
newEnvStatus = oldEnvStatus
}
newEnvStatus.Status = []portainer.EdgeStackDeploymentStatus{
{
Time: time.Now().Unix(),
Type: portainer.EdgeStackStatusPending,
},
}
newStatus[endpointID] = newEnvStatus
}
return newStatus
}

@ -94,21 +94,21 @@ func TestUpdateAndInspect(t *testing.T) {
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
} }
data := portainer.EdgeStack{} updatedStack := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data) err = json.NewDecoder(rec.Body).Decode(&updatedStack)
if err != nil { if err != nil {
t.Fatal("error decoding response:", err) t.Fatal("error decoding response:", err)
} }
if payload.UpdateVersion && data.Version != edgeStack.Version+1 { if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
t.Fatalf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version) t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
} }
if data.DeploymentType != payload.DeploymentType { if updatedStack.DeploymentType != payload.DeploymentType {
t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, data.DeploymentType) t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, updatedStack.DeploymentType)
} }
if !reflect.DeepEqual(data.EdgeGroups, payload.EdgeGroups) { if !reflect.DeepEqual(updatedStack.EdgeGroups, payload.EdgeGroups) {
t.Fatalf("expected EdgeGroups to be equal") t.Fatalf("expected EdgeGroups to be equal")
} }
} }

@ -0,0 +1,59 @@
package edgestacks
import (
"fmt"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
edgestackutils "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/rs/zerolog/log"
)
func (handler *Handler) updateStackVersion(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
stack.Version = stack.Version + 1
stack.Status = edgestackutils.NewStatus(stack.Status, relatedEnvironmentsIDs)
return handler.storeStackFile(stack, deploymentType, config)
}
func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte) error {
if deploymentType != stack.DeploymentType {
// deployment type was changed - need to delete all old files
err := handler.FileService.RemoveDirectory(stack.ProjectPath)
if err != nil {
log.Warn().Err(err).Msg("Unable to clear old files")
}
stack.EntryPoint = ""
stack.ManifestPath = ""
stack.DeploymentType = deploymentType
}
stackFolder := strconv.Itoa(int(stack.ID))
entryPoint := ""
if deploymentType == portainer.EdgeStackDeploymentCompose {
if stack.EntryPoint == "" {
stack.EntryPoint = filesystem.ComposeFileDefaultName
}
entryPoint = stack.EntryPoint
}
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
if stack.ManifestPath == "" {
stack.ManifestPath = filesystem.ManifestFileDefaultName
}
entryPoint = stack.ManifestPath
}
_, err := handler.FileService.StoreEdgeStackFileFromBytesByVersion(stackFolder, entryPoint, stack.Version, config)
if err != nil {
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
}
return nil
}

@ -291,7 +291,7 @@ func TestEdgeStackStatus(t *testing.T) {
ID: edgeStackID, ID: edgeStackID,
Name: "test-edge-stack-17", Name: "test-edge-stack-17",
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{ Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
endpointID: {Details: portainer.EdgeStackStatusDetails{Ok: true}, Error: "", EndpointID: endpoint.ID}, endpointID: {},
}, },
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{1, 2}, EdgeGroups: []portainer.EdgeGroupID{1, 2},

@ -17,18 +17,6 @@ import (
"github.com/portainer/portainer/api/internal/unique" "github.com/portainer/portainer/api/internal/unique"
) )
type EdgeStackStatusFilter string
const (
statusFilterPending EdgeStackStatusFilter = "Pending"
statusFilterOk EdgeStackStatusFilter = "Ok"
statusFilterError EdgeStackStatusFilter = "Error"
statusFilterAcknowledged EdgeStackStatusFilter = "Acknowledged"
statusFilterRemove EdgeStackStatusFilter = "Remove"
statusFilterRemoteUpdateSuccess EdgeStackStatusFilter = "RemoteUpdateSuccess"
statusFilterImagesPulled EdgeStackStatusFilter = "ImagesPulled"
)
type EnvironmentsQuery struct { type EnvironmentsQuery struct {
search string search string
types []portainer.EndpointType types []portainer.EndpointType
@ -45,7 +33,7 @@ type EnvironmentsQuery struct {
agentVersions []string agentVersions []string
edgeCheckInPassedSeconds int edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID edgeStackId portainer.EdgeStackID
edgeStackStatus EdgeStackStatusFilter edgeStackStatus *portainer.EdgeStackStatusType
} }
func parseQuery(r *http.Request) (EnvironmentsQuery, error) { func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
@ -99,7 +87,18 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
edgeStackId, _ := request.RetrieveNumericQueryParameter(r, "edgeStackId", true) edgeStackId, _ := request.RetrieveNumericQueryParameter(r, "edgeStackId", true)
edgeStackStatus, _ := request.RetrieveQueryParameter(r, "edgeStackStatus", true) edgeStackStatusQuery, _ := request.RetrieveQueryParameter(r, "edgeStackStatus", true)
var edgeStackStatus *portainer.EdgeStackStatusType
if edgeStackStatusQuery != "" {
edgeStackStatusNumber, err := strconv.Atoi(edgeStackStatusQuery)
if err != nil ||
edgeStackStatusNumber < 0 ||
edgeStackStatusNumber > int(portainer.EdgeStackStatusRemoving) {
return EnvironmentsQuery{}, errors.New("invalid edgeStackStatus parameter")
}
edgeStackStatus = ptr(portainer.EdgeStackStatusType(edgeStackStatusNumber))
}
return EnvironmentsQuery{ return EnvironmentsQuery{
search: search, search: search,
@ -116,7 +115,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
agentVersions: agentVersions, agentVersions: agentVersions,
edgeCheckInPassedSeconds: edgeCheckInPassedSeconds, edgeCheckInPassedSeconds: edgeCheckInPassedSeconds,
edgeStackId: portainer.EdgeStackID(edgeStackId), edgeStackId: portainer.EdgeStackID(edgeStackId),
edgeStackStatus: EdgeStackStatusFilter(edgeStackStatus), edgeStackStatus: edgeStackStatus,
}, nil }, nil
} }
@ -213,30 +212,21 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
return filteredEndpoints, totalAvailableEndpoints, nil return filteredEndpoints, totalAvailableEndpoints, nil
} }
func endpointStatusInStackMatchesFilter(stackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter EdgeStackStatusFilter) bool { func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
status, ok := stackStatus[envId] status, ok := edgeStackStatus[envId]
// consider that if the env has no status in the stack it is in Pending state // consider that if the env has no status in the stack it is in Pending state
// workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase // workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase
if !ok && statusFilter == statusFilterPending { if !ok && statusFilter == portainer.EdgeStackStatusPending {
return true return true
} }
valueMap := map[EdgeStackStatusFilter]bool{ return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
statusFilterPending: status.Details.Pending, return s.Type == statusFilter
statusFilterOk: status.Details.Ok, })
statusFilterError: status.Details.Error,
statusFilterAcknowledged: status.Details.Acknowledged,
statusFilterRemove: status.Details.Remove,
statusFilterRemoteUpdateSuccess: status.Details.RemoteUpdateSuccess,
statusFilterImagesPulled: status.Details.ImagesPulled,
}
currentStatus, ok := valueMap[statusFilter]
return ok && currentStatus
} }
func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter EdgeStackStatusFilter, datastore dataservices.DataStore) ([]portainer.Endpoint, error) { func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter *portainer.EdgeStackStatusType, datastore dataservices.DataStore) ([]portainer.Endpoint, error) {
stack, err := datastore.EdgeStack().EdgeStack(edgeStackId) stack, err := datastore.EdgeStack().EdgeStack(edgeStackId)
if err != nil { if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database") return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
@ -258,10 +248,10 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
envIds = append(envIds, edgeGroup.Endpoints...) envIds = append(envIds, edgeGroup.Endpoints...)
} }
if statusFilter != "" { if statusFilter != nil {
n := 0 n := 0
for _, envId := range envIds { for _, envId := range envIds {
if endpointStatusInStackMatchesFilter(stack.Status, envId, statusFilter) { if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) {
envIds[n] = envId envIds[n] = envId
n++ n++
} }

@ -1,6 +1,7 @@
package endpoints package endpoints
func ptr[T any](i T) *T { return &i }
func BoolAddr(b bool) *bool { func BoolAddr(b bool) *bool {
boolVar := b return ptr(b)
return &boolVar
} }

@ -49,7 +49,7 @@ func (service *Service) BuildEdgeStack(
DeploymentType: deploymentType, DeploymentType: deploymentType,
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
EdgeGroups: edgeGroups, EdgeGroups: edgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus, 0),
Version: 1, Version: 1,
UseManifestNamespaces: useManifestNamespaces, UseManifestNamespaces: useManifestNamespaces,
}, nil }, nil

@ -0,0 +1,27 @@
package edgestacks
import (
portainer "github.com/portainer/portainer/api"
)
// NewStatus returns a new status object for an Edge stack
func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIDs []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
for _, environmentID := range relatedEnvironmentIDs {
newEnvStatus := portainer.EdgeStackStatus{
Status: []portainer.EdgeStackDeploymentStatus{},
EndpointID: portainer.EndpointID(environmentID),
}
oldEnvStatus, ok := oldStatus[environmentID]
if ok {
newEnvStatus.DeploymentInfo = oldEnvStatus.DeploymentInfo
}
status[environmentID] = newEnvStatus
}
return status
}

@ -2,14 +2,33 @@ package slices
// Contains is a generic function that returns true if the element is contained within the slice // Contains is a generic function that returns true if the element is contained within the slice
func Contains[T comparable](elems []T, v T) bool { func Contains[T comparable](elems []T, v T) bool {
return ContainsFunc(elems, func(s T) bool {
return s == v
})
}
// Contains is a generic function that returns true if the element is contained within the slice
func ContainsFunc[T any](elems []T, f func(T) bool) bool {
for _, s := range elems { for _, s := range elems {
if v == s { if f(s) {
return true return true
} }
} }
return false return false
} }
func Find[T any](elems []T, f func(T) bool) (T, bool) {
for _, s := range elems {
if f(s) {
return s, true
}
}
// return default value
var result T
return result, false
}
// IndexFunc returns the first index i satisfying f(s[i]), // IndexFunc returns the first index i satisfying f(s[i]),
// or -1 if none do. // or -1 if none do.
func IndexFunc[E any](s []E, f func(E) bool) int { func IndexFunc[E any](s []E, f func(E) bool) int {

@ -313,6 +313,7 @@ type (
ID EdgeStackID `json:"Id" example:"1"` ID EdgeStackID `json:"Id" example:"1"`
Name string `json:"Name"` Name string `json:"Name"`
Status map[EndpointID]EdgeStackStatus `json:"Status"` Status map[EndpointID]EdgeStackStatus `json:"Status"`
// StatusArray map[EndpointID][]EdgeStackStatus `json:"StatusArray"`
CreationDate int64 `json:"CreationDate"` CreationDate int64 `json:"CreationDate"`
EdgeGroups []EdgeGroupID `json:"EdgeGroups"` EdgeGroups []EdgeGroupID `json:"EdgeGroups"`
ProjectPath string `json:"ProjectPath"` ProjectPath string `json:"ProjectPath"`
@ -345,16 +346,26 @@ type (
//EdgeStackStatus represents an edge stack status //EdgeStackStatus represents an edge stack status
EdgeStackStatus struct { EdgeStackStatus struct {
Details EdgeStackStatusDetails `json:"Details"` Status []EdgeStackDeploymentStatus
Error string `json:"Error"` EndpointID EndpointID
EndpointID EndpointID `json:"EndpointID"`
// EE only feature // EE only feature
DeploymentInfo StackDeploymentInfo `json:"DeploymentInfo"` DeploymentInfo StackDeploymentInfo
// Deprecated
Details EdgeStackStatusDetails
// Deprecated
Error string
// Deprecated // Deprecated
Type EdgeStackStatusType `json:"Type"` Type EdgeStackStatusType `json:"Type"`
} }
// EdgeStackDeploymentStatus represents an edge stack deployment status
EdgeStackDeploymentStatus struct {
Time int64
Type EdgeStackStatusType
Error string
}
//EdgeStackStatusType represents an edge stack status type //EdgeStackStatusType represents an edge stack status type
EdgeStackStatusType int EdgeStackStatusType int
@ -1647,18 +1658,24 @@ const (
const ( const (
// EdgeStackStatusPending represents a pending edge stack // EdgeStackStatusPending represents a pending edge stack
EdgeStackStatusPending EdgeStackStatusType = iota EdgeStackStatusPending EdgeStackStatusType = iota
//EdgeStackStatusOk represents a successfully deployed edge stack //EdgeStackStatusDeploymentReceived represents an edge environment which received the edge stack deployment
EdgeStackStatusOk EdgeStackStatusDeploymentReceived
//EdgeStackStatusError represents an edge environment(endpoint) which failed to deploy its edge stack //EdgeStackStatusError represents an edge environment which failed to deploy its edge stack
EdgeStackStatusError EdgeStackStatusError
//EdgeStackStatusAcknowledged represents an acknowledged edge stack //EdgeStackStatusAcknowledged represents an acknowledged edge stack
EdgeStackStatusAcknowledged EdgeStackStatusAcknowledged
//EdgeStackStatusRemove represents a removed edge stack (status isn't persisted) //EdgeStackStatusRemoved represents a removed edge stack
EdgeStackStatusRemove EdgeStackStatusRemoved
// StatusRemoteUpdateSuccess represents a successfully updated edge stack // StatusRemoteUpdateSuccess represents a successfully updated edge stack
EdgeStackStatusRemoteUpdateSuccess EdgeStackStatusRemoteUpdateSuccess
// EdgeStackStatusImagesPulled represents a successfully images-pulling // EdgeStackStatusImagesPulled represents a successfully images-pulling
EdgeStackStatusImagesPulled EdgeStackStatusImagesPulled
// EdgeStackStatusRunning represents a running Edge stack
EdgeStackStatusRunning
// EdgeStackStatusDeploying represents an Edge stack which is being deployed
EdgeStackStatusDeploying
// EdgeStackStatusRemoving represents an Edge stack which is being removed
EdgeStackStatusRemoving
) )
const ( const (

@ -0,0 +1,5 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" width="14" height="14" rx="7" fill="#039855"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4729 4.31086L6.29623 8.34169L5.1879 7.15753C4.98373 6.96503 4.6629 6.95336 4.42957 7.11669C4.20207 7.28586 4.1379 7.58336 4.2779 7.82253L5.5904 9.95753C5.71873 10.1559 5.9404 10.2784 6.19123 10.2784C6.4304 10.2784 6.6579 10.1559 6.78623 9.95753C6.99623 9.68336 11.0037 4.90586 11.0037 4.90586C11.5287 4.36919 10.8929 3.89669 10.4729 4.30503V4.31086Z" fill="#0BA5EC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1403 4.30503C11.0118 4.12405 10.7084 4.07605 10.4729 4.30503V4.31086L10.3899 4.39096L6.29623 8.34169L5.1879 7.15753C4.98373 6.96503 4.6629 6.95336 4.42957 7.11669C4.20207 7.28586 4.1379 7.58336 4.2779 7.82253L5.5904 9.95753C5.69222 10.1149 5.85279 10.2245 6.04006 10.2631C6.08883 10.2731 6.13941 10.2784 6.19123 10.2784C6.31863 10.2784 6.44272 10.2436 6.55028 10.1811C6.64463 10.1263 6.72626 10.0502 6.78623 9.95753C6.78962 9.9531 6.794 9.94751 6.79933 9.94078C6.85798 9.86677 7.03221 9.65571 7.27843 9.35943C8.07017 8.40673 9.60624 6.57293 10.4372 5.5816C10.7807 5.17172 11.0037 4.90586 11.0037 4.90586C11.0111 4.89829 11.0183 4.89074 11.0253 4.8832C11.0268 4.88147 11.0284 4.87975 11.03 4.87803C11.2318 4.65583 11.2369 4.44719 11.1444 4.31086C11.143 4.3089 11.1417 4.30696 11.1403 4.30503Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,11 @@
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_0_118)">
<rect x="0.5" width="14" height="14" rx="7" fill="#F79009"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.81723 4.55883C5.11013 4.26593 5.585 4.26593 5.87789 4.55883L7.55801 6.23895L9.23813 4.55883C9.53103 4.26593 10.0059 4.26593 10.2988 4.55883C10.5917 4.85172 10.5917 5.32659 10.2988 5.61949L8.61867 7.29961L10.2988 8.97973C10.5917 9.27262 10.5917 9.74749 10.2988 10.0404C10.0059 10.3333 9.53103 10.3333 9.23813 10.0404L7.55801 8.36027L5.87789 10.0404C5.585 10.3333 5.11013 10.3333 4.81723 10.0404C4.52434 9.74749 4.52434 9.27262 4.81723 8.97973L6.49735 7.29961L4.81723 5.61949C4.52434 5.32659 4.52434 4.85172 4.81723 4.55883Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_0_118">
<rect x="0.5" width="14" height="14" rx="7" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 908 B

@ -13,9 +13,11 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable'; import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
import { EdgeStackStatus } from '@/react/edge/edge-stacks/ListView/EdgeStackStatus';
export const componentsModule = angular export const componentsModule = angular
.module('portainer.edge.react.components', []) .module('portainer.edge.react.components', [])
.component('edgeStacksDatatableStatus', r2a(EdgeStackStatus, ['edgeStack']))
.component( .component(
'edgeStackEnvironmentsDatatable', 'edgeStackEnvironmentsDatatable',
r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), []) r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), [])

@ -1,6 +1,7 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import './datatable.css'; import './datatable.css';
import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types'; import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
function isBetween(value, a, b) { function isBetween(value, a, b) {
return (value >= a && value <= b) || (value >= b && value <= a); return (value >= a && value <= b) || (value >= b && value <= a);
@ -14,6 +15,7 @@ angular.module('portainer.app').controller('GenericDatatableController', [
'PAGINATION_MAX_ITEMS', 'PAGINATION_MAX_ITEMS',
function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) { function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) {
this.RCO = RCO; this.RCO = RCO;
this.isBE = isBE;
this.state = { this.state = {
selectAll: false, selectAll: false,

@ -120,7 +120,7 @@ export const ngModule = angular
'fallbackImage', 'fallbackImage',
r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className']) r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className'])
) )
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size'])) .component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, [])) .component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
.component( .component(
'dashboardItem', 'dashboardItem',

@ -29,15 +29,15 @@ interface Props {
className?: string; className?: string;
size?: IconSize; size?: IconSize;
mode?: IconMode; mode?: IconMode;
spin?: boolean;
} }
export function Icon({ icon, className, mode, size }: Props) { export function Icon({ icon, className, mode, size, spin }: Props) {
const classes = clsx( const classes = clsx(className, 'icon inline-flex', {
className, [`icon-${mode}`]: mode,
'icon inline-flex', [`icon-${size}`]: size,
{ [`icon-${mode}`]: mode }, 'animate-spin-slow': spin,
{ [`icon-${size}`]: size } });
);
if (typeof icon !== 'string') { if (typeof icon !== 'string') {
const Icon = isValidElementType(icon) ? icon : null; const Icon = isValidElementType(icon) ? icon : null;

@ -6,6 +6,7 @@ import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { useParamState } from '@/react/hooks/useParamState'; import { useParamState } from '@/react/hooks/useParamState';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Datatable } from '@@/datatables'; import { Datatable } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState'; import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
@ -20,17 +21,29 @@ export function EnvironmentsDatatable() {
const { const {
params: { stackId }, params: { stackId },
} = useCurrentStateAndParams(); } = useCurrentStateAndParams();
const edgeStackQuery = useEdgeStack(stackId); const edgeStackQuery = useEdgeStack(stackId, {
refetchInterval(data) {
if (!data) {
return 0;
}
return Object.values(data.Status).some((status) =>
status.Status.every((s) => s.Type === StatusType.Running)
)
? 0
: 10000;
},
});
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [statusFilter, setStatusFilter] = useParamState<StatusType>( const [statusFilter, setStatusFilter] = useParamState<StatusType>(
'status', 'status',
parseStatusFilter (value) => (value ? parseInt(value, 10) : undefined)
); );
const tableState = useTableStateWithoutStorage('name'); const tableState = useTableStateWithoutStorage('name');
const endpointsQuery = useEnvironmentList({ const endpointsQuery = useEnvironmentList({
pageLimit: tableState.pageSize, pageLimit: tableState.pageSize,
page, page: page + 1,
search: tableState.search, search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name', sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc', order: tableState.sortBy.desc ? 'desc' : 'asc',
@ -38,27 +51,32 @@ export function EnvironmentsDatatable() {
edgeStackStatus: statusFilter, edgeStackStatus: statusFilter,
}); });
const currentFileVersion =
edgeStackQuery.data?.StackFileVersion?.toString() || '';
const gitConfigURL = edgeStackQuery.data?.GitConfig?.URL || '';
const gitConfigCommitHash = edgeStackQuery.data?.GitConfig?.ConfigHash || '';
const environments: Array<EdgeStackEnvironment> = useMemo( const environments: Array<EdgeStackEnvironment> = useMemo(
() => () =>
endpointsQuery.environments.map((env) => ({ endpointsQuery.environments.map(
...env, (env) =>
StackStatus:
edgeStackQuery.data?.Status[env.Id] ||
({ ({
Details: { ...env,
Pending: true, TargetFileVersion: currentFileVersion,
Acknowledged: false, GitConfigURL: gitConfigURL,
ImagesPulled: false, TargetCommitHash: gitConfigCommitHash,
Error: false, StackStatus: getEnvStackStatus(
Ok: false, env.Id,
RemoteUpdateSuccess: false, edgeStackQuery.data?.Status[env.Id]
Remove: false, ),
}, } satisfies EdgeStackEnvironment)
EndpointID: env.Id, ),
Error: '', [
} satisfies EdgeStackStatus), currentFileVersion,
})), edgeStackQuery.data?.Status,
[edgeStackQuery.data?.Status, endpointsQuery.environments] endpointsQuery.environments,
gitConfigCommitHash,
gitConfigURL,
]
); );
return ( return (
@ -81,11 +99,11 @@ export function EnvironmentsDatatable() {
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e || undefined)} onChange={(e) => setStatusFilter(e || undefined)}
options={[ options={[
{ value: 'Pending', label: 'Pending' }, { value: StatusType.Pending, label: 'Pending' },
{ value: 'Acknowledged', label: 'Acknowledged' }, { value: StatusType.Acknowledged, label: 'Acknowledged' },
{ value: 'ImagesPulled', label: 'Images pre-pulled' }, { value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
{ value: 'Ok', label: 'Deployed' }, { value: StatusType.Running, label: 'Deployed' },
{ value: 'Error', label: 'Failed' }, { value: StatusType.Error, label: 'Failed' },
]} ]}
/> />
</div> </div>
@ -95,19 +113,31 @@ export function EnvironmentsDatatable() {
); );
} }
function parseStatusFilter(status: string | undefined): StatusType | undefined { function getEnvStackStatus(
switch (status) { envId: EnvironmentId,
case 'Pending': envStatus: EdgeStackStatus | undefined
return 'Pending'; ) {
case 'Acknowledged': const pendingStatus = {
return 'Acknowledged'; Type: StatusType.Pending,
case 'ImagesPulled': Error: '',
return 'ImagesPulled'; Time: new Date().valueOf() / 1000,
case 'Ok': };
return 'Ok';
case 'Error': let status = envStatus;
return 'Error'; if (!status) {
default: status = {
return undefined; EndpointID: envId,
DeploymentInfo: {
ConfigHash: '',
FileVersion: 0,
},
Status: [],
};
} }
if (status.Status.length === 0) {
status.Status.push(pendingStatus);
}
return status;
} }

@ -2,13 +2,19 @@ import { CellContext, createColumnHelper } from '@tanstack/react-table';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import _ from 'lodash';
import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { getDashboardRoute } from '@/react/portainer/environments/utils';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { EdgeStackStatus } from '../../types'; import { DeploymentStatus, EdgeStackStatus, StatusType } from '../../types';
import { EnvironmentActions } from './EnvironmentActions'; import { EnvironmentActions } from './EnvironmentActions';
import { ActionStatus } from './ActionStatus'; import { ActionStatus } from './ActionStatus';
@ -16,20 +22,75 @@ import { EdgeStackEnvironment } from './types';
const columnHelper = createColumnHelper<EdgeStackEnvironment>(); const columnHelper = createColumnHelper<EdgeStackEnvironment>();
export const columns = [ export const columns = _.compact([
columnHelper.accessor('Name', { columnHelper.accessor('Name', {
id: 'name', id: 'name',
header: 'Name', header: 'Name',
cell({ row: { original: env } }) {
const { to, params } = getDashboardRoute(env);
return (
<Link to={to} params={params}>
{env.Name}
</Link>
);
},
}), }),
columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus), { columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus.Status), {
id: 'status', id: 'status',
header: 'Status', header: 'Status',
cell({ row: { original: env } }) {
return (
<ul className="list-none space-y-2">
{env.StackStatus.Status.map((s) => (
<li key={`status-${s.Type}-${s.Time}`}>
<Status value={s.Type} />
</li>
))}
</ul>
);
},
}),
columnHelper.accessor((env) => _.last(env.StackStatus.Status)?.Time, {
id: 'statusDate',
header: 'Time',
cell({ row: { original: env } }) {
return (
<ul className="list-none space-y-2">
{env.StackStatus.Status.map((s) => (
<li key={`time-${s.Type}-${s.Time}`}>
{isoDateFromTimestamp(s.Time)}
</li>
))}
</ul>
);
},
}), }),
columnHelper.accessor((env) => env.StackStatus.Error, { ...(isBE
? [
columnHelper.accessor((env) => endpointTargetVersionLabel(env), {
id: 'targetVersion',
header: 'Target version',
cell: TargetVersionCell,
}),
columnHelper.accessor(
(env) => endpointDeployedVersionLabel(env.StackStatus),
{
id: 'deployedVersion',
header: 'Deployed version',
cell: DeployedVersionCell,
}
),
]
: []),
columnHelper.accessor(
(env) =>
env.StackStatus.Status.find((s) => s.Type === StatusType.Error)?.Error,
{
id: 'error', id: 'error',
header: 'Error', header: 'Error',
cell: ErrorCell, cell: ErrorCell,
}), }
),
...(isBE ...(isBE
? [ ? [
columnHelper.display({ columnHelper.display({
@ -48,7 +109,7 @@ export const columns = [
}), }),
] ]
: []), : []),
]; ]);
function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) { function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@ -77,30 +138,151 @@ function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
); );
} }
function endpointStatusLabel(status: EdgeStackStatus) { function endpointStatusLabel(statusArray: Array<DeploymentStatus>) {
const details = (status && status.Details) || {};
const labels = []; const labels = [];
if (details.Acknowledged) { statusArray.forEach((status) => {
if (status.Type === StatusType.Acknowledged) {
labels.push('Acknowledged'); labels.push('Acknowledged');
} }
if (status.Type === StatusType.ImagesPulled) {
if (details.ImagesPulled) {
labels.push('Images pre-pulled'); labels.push('Images pre-pulled');
} }
if (status.Type === StatusType.Running) {
if (details.Ok) {
labels.push('Deployed'); labels.push('Deployed');
} }
if (status.Type === StatusType.Error) {
if (details.Error) {
labels.push('Failed'); labels.push('Failed');
} }
});
if (!labels.length) { if (!labels.length) {
labels.push('Pending'); labels.push('Pending');
} }
return labels.join(', '); return _.uniq(labels).join(', ');
}
function TargetVersionCell({
row,
getValue,
}: CellContext<EdgeStackEnvironment, string>) {
const value = getValue();
if (!value) {
return '';
}
return (
<>
{row.original.TargetCommitHash ? (
<div>
<a
href={`${row.original.GitConfigURL}/commit/${row.original.TargetCommitHash}`}
target="_blank"
rel="noreferrer"
>
{value}
</a>
</div>
) : (
<div>{value}</div>
)}
</>
);
}
function endpointTargetVersionLabel(env: EdgeStackEnvironment) {
if (env.TargetCommitHash) {
return env.TargetCommitHash.slice(0, 7).toString();
}
return env.TargetFileVersion.toString() || '';
}
function DeployedVersionCell({
row,
getValue,
}: CellContext<EdgeStackEnvironment, string>) {
const value = getValue();
if (!value || value === '0') {
return (
<div>
<Icon icon={UpdatesAvailable} className="!mr-2" />
</div>
);
}
let statusIcon = <Icon icon={UpToDate} className="!mr-2" />;
if (
(row.original.TargetCommitHash &&
row.original.TargetCommitHash.slice(0, 7) !== value) ||
(!row.original.TargetCommitHash && row.original.TargetFileVersion !== value)
) {
statusIcon = <Icon icon={UpdatesAvailable} className="!mr-2" />;
}
return (
<>
{row.original.TargetCommitHash ? (
<div>
{statusIcon}
<a
href={`${row.original.GitConfigURL}/commit/${row.original.TargetCommitHash}`}
target="_blank"
rel="noreferrer"
>
{value}
</a>
</div>
) : (
<div>
{statusIcon}
{value}
</div>
)}
</>
);
}
function endpointDeployedVersionLabel(status: EdgeStackStatus) {
if (status.DeploymentInfo?.ConfigHash) {
return status.DeploymentInfo?.ConfigHash.slice(0, 7).toString();
}
return status.DeploymentInfo?.FileVersion.toString() || '';
}
function Status({ value }: { value: StatusType }) {
const color = getStateColor(value);
return (
<div className="flex items-center gap-2">
<span
className={clsx('h-2 w-2 rounded-full', {
'bg-orange-5': color === 'orange',
'bg-green-5': color === 'green',
'bg-error-5': color === 'red',
})}
/>
<span>{_.startCase(StatusType[value])}</span>
</div>
);
}
function getStateColor(type: StatusType): 'orange' | 'green' | 'red' {
switch (type) {
case StatusType.Acknowledged:
case StatusType.ImagesPulled:
case StatusType.DeploymentReceived:
case StatusType.Running:
case StatusType.RemoteUpdateSuccess:
case StatusType.Removed:
return 'green';
case StatusType.Error:
return 'red';
case StatusType.Pending:
case StatusType.Deploying:
case StatusType.Removing:
default:
return 'orange';
}
} }

@ -4,4 +4,7 @@ import { EdgeStackStatus } from '../../types';
export type EdgeStackEnvironment = Environment & { export type EdgeStackEnvironment = Environment & {
StackStatus: EdgeStackStatus; StackStatus: EdgeStackStatus;
TargetFileVersion: string;
GitConfigURL: string;
TargetCommitHash: string;
}; };

@ -0,0 +1,87 @@
import _ from 'lodash';
import {
AlertTriangle,
CheckCircle,
type Icon as IconType,
Loader2,
XCircle,
} from 'lucide-react';
import { Icon, IconMode } from '@@/Icon';
import { DeploymentStatus, EdgeStack, StatusType } from '../types';
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
const status = Object.values(edgeStack.Status);
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
const { icon, label, mode, spin } = getStatus(
edgeStack.NumDeployments,
lastStatus
);
return (
<div className="mx-auto inline-flex items-center gap-2">
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
{label}
</div>
);
}
function getStatus(
numDeployments: number,
envStatus: Array<DeploymentStatus>
): {
label: string;
icon?: IconType;
spin?: boolean;
mode?: IconMode;
} {
if (envStatus.length < numDeployments) {
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
}
const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
if (allFailed) {
return {
label: 'Failed',
icon: XCircle,
mode: 'danger',
};
}
const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
if (allRunning) {
return {
label: 'Running',
icon: CheckCircle,
mode: 'success',
};
}
const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying);
const hasRunning = envStatus.some((s) => s.Type === StatusType.Running);
const hasFailed = envStatus.some((s) => s.Type === StatusType.Error);
if (hasRunning && hasFailed && !hasDeploying) {
return {
label: 'Partially Running',
icon: AlertTriangle,
mode: 'warning',
};
}
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
}

@ -38,10 +38,10 @@ export function DeploymentCounter({
return ( return (
<span <span
className={clsx(styles.root, { className={clsx(styles.root, {
[styles.statusOk]: type === 'Ok', [styles.statusOk]: type === StatusType.Running,
[styles.statusError]: type === 'Error', [styles.statusError]: type === StatusType.Error,
[styles.statusAcknowledged]: type === 'Acknowledged', [styles.statusAcknowledged]: type === StatusType.Acknowledged,
[styles.statusImagesPulled]: type === 'ImagesPulled', [styles.statusImagesPulled]: type === StatusType.ImagesPulled,
[styles.statusTotal]: type === undefined, [styles.statusTotal]: type === undefined,
})} })}
> >

@ -4,7 +4,7 @@ import { Datatable } from '@@/datatables';
import { useTableState } from '@@/datatables/useTableState'; import { useTableState } from '@@/datatables/useTableState';
import { useEdgeStacks } from '../../queries/useEdgeStacks'; import { useEdgeStacks } from '../../queries/useEdgeStacks';
import { EdgeStack } from '../../types'; import { EdgeStack, StatusType } from '../../types';
import { createStore } from './store'; import { createStore } from './store';
import { columns } from './columns'; import { columns } from './columns';
@ -51,11 +51,16 @@ export function EdgeStacksDatatable() {
function aggregateStackStatus(stackStatus: EdgeStack['Status']) { function aggregateStackStatus(stackStatus: EdgeStack['Status']) {
const aggregateStatus = { ok: 0, error: 0, acknowledged: 0, imagesPulled: 0 }; const aggregateStatus = { ok: 0, error: 0, acknowledged: 0, imagesPulled: 0 };
return Object.values(stackStatus).reduce((acc, envStatus) => { return Object.values(stackStatus).reduce(
acc.ok += Number(envStatus.Details.Ok); (acc, envStatus) =>
acc.error += Number(envStatus.Details.Error); envStatus.Status.reduce((acc, status) => {
acc.acknowledged += Number(envStatus.Details.Acknowledged); const { Type } = status;
acc.imagesPulled += Number(envStatus.Details.ImagesPulled); acc.ok += Number(Type === StatusType.Running);
acc.error += Number(Type === StatusType.Error);
acc.acknowledged += Number(Type === StatusType.Acknowledged);
acc.imagesPulled += Number(Type === StatusType.ImagesPulled);
return acc; return acc;
}, aggregateStatus); }, acc),
aggregateStatus
);
} }

@ -0,0 +1,87 @@
import _ from 'lodash';
import {
AlertTriangle,
CheckCircle,
type Icon as IconType,
Loader2,
XCircle,
} from 'lucide-react';
import { Icon, IconMode } from '@@/Icon';
import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
const status = Object.values(edgeStack.Status);
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
const { icon, label, mode, spin } = getStatus(
edgeStack.NumDeployments,
lastStatus
);
return (
<div className="mx-auto inline-flex items-center gap-2">
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
{label}
</div>
);
}
function getStatus(
numDeployments: number,
envStatus: Array<DeploymentStatus>
): {
label: string;
icon?: IconType;
spin?: boolean;
mode?: IconMode;
} {
if (envStatus.length < numDeployments) {
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
}
const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
if (allFailed) {
return {
label: 'Failed',
icon: XCircle,
mode: 'danger',
};
}
const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
if (allRunning) {
return {
label: 'Running',
icon: CheckCircle,
mode: 'success',
};
}
const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying);
const hasRunning = envStatus.some((s) => s.Type === StatusType.Running);
const hasFailed = envStatus.some((s) => s.Type === StatusType.Error);
if (hasRunning && hasFailed && !hasDeploying) {
return {
label: 'Partially Running',
icon: AlertTriangle,
mode: 'warning',
};
}
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
}

@ -6,6 +6,9 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { buildNameColumn } from '@@/datatables/NameCell'; import { buildNameColumn } from '@@/datatables/NameCell';
import { StatusType } from '../../types';
import { EdgeStackStatus } from '../EdgeStackStatus';
import { DecoratedEdgeStack } from './types'; import { DecoratedEdgeStack } from './types';
import { DeploymentCounter, DeploymentCounterLink } from './DeploymentCounter'; import { DeploymentCounter, DeploymentCounterLink } from './DeploymentCounter';
@ -25,7 +28,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => ( cell: ({ getValue, row }) => (
<DeploymentCounterLink <DeploymentCounterLink
count={getValue()} count={getValue()}
type="Acknowledged" type={StatusType.Acknowledged}
stackId={row.original.Id} stackId={row.original.Id}
/> />
), ),
@ -39,7 +42,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => ( cell: ({ getValue, row }) => (
<DeploymentCounterLink <DeploymentCounterLink
count={getValue()} count={getValue()}
type="ImagesPulled" type={StatusType.ImagesPulled}
stackId={row.original.Id} stackId={row.original.Id}
/> />
), ),
@ -54,7 +57,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => ( cell: ({ getValue, row }) => (
<DeploymentCounterLink <DeploymentCounterLink
count={getValue()} count={getValue()}
type="Ok" type={StatusType.Running}
stackId={row.original.Id} stackId={row.original.Id}
/> />
), ),
@ -69,7 +72,7 @@ export const columns = _.compact([
cell: ({ getValue, row }) => ( cell: ({ getValue, row }) => (
<DeploymentCounterLink <DeploymentCounterLink
count={getValue()} count={getValue()}
type="Error" type={StatusType.Error}
stackId={row.original.Id} stackId={row.original.Id}
/> />
), ),
@ -79,6 +82,19 @@ export const columns = _.compact([
className: '[&>*]:justify-center', className: '[&>*]:justify-center',
}, },
}), }),
columnHelper.accessor('Status', {
header: 'Status',
cell: ({ row }) => (
<div className="w-full text-center">
<EdgeStackStatus edgeStack={row.original} />
</div>
),
enableSorting: false,
enableHiding: false,
meta: {
className: '[&>*]:justify-center',
},
}),
columnHelper.accessor('NumDeployments', { columnHelper.accessor('NumDeployments', {
header: 'Deployments', header: 'Deployments',
cell: ({ getValue }) => ( cell: ({ getValue }) => (

@ -8,10 +8,24 @@ import { EdgeStack } from '../types';
import { buildUrl } from './buildUrl'; import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys'; import { queryKeys } from './query-keys';
export function useEdgeStack(id?: EdgeStack['Id']) { export function useEdgeStack(
id?: EdgeStack['Id'],
{
refetchInterval,
}: {
/**
* If set to a number, the query will continuously refetch at this frequency in milliseconds. If set to a function, the function will be executed with the latest data and query to compute a frequency Defaults to false.
*/
refetchInterval?:
| number
| false
| ((data?: Awaited<ReturnType<typeof getEdgeStack>>) => false | number);
} = {}
) {
return useQuery(id ? queryKeys.item(id) : [], () => getEdgeStack(id), { return useQuery(id ? queryKeys.item(id) : [], () => getEdgeStack(id), {
...withError('Failed loading Edge stack'), ...withError('Failed loading Edge stack'),
enabled: !!id, enabled: !!id,
refetchInterval,
}); });
} }

@ -6,6 +6,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeStack } from '../types'; import { EdgeStack } from '../types';
import { buildUrl } from './buildUrl'; import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys';
export function useEdgeStacks<T = Array<EdgeStack>>({ export function useEdgeStacks<T = Array<EdgeStack>>({
select, select,
@ -19,7 +20,7 @@ export function useEdgeStacks<T = Array<EdgeStack>>({
select?: (stacks: EdgeStack[]) => T; select?: (stacks: EdgeStack[]) => T;
refetchInterval?: number | false | ((data?: T) => false | number); refetchInterval?: number | false | ((data?: T) => false | number);
} = {}) { } = {}) {
return useQuery(['edge_stacks'], () => getEdgeStacks(), { return useQuery(queryKeys.base(), () => getEdgeStacks(), {
...withError('Failed loading Edge stack'), ...withError('Failed loading Edge stack'),
select, select,
refetchInterval, refetchInterval,

@ -9,22 +9,44 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
import { EdgeGroup } from '../edge-groups/types'; import { EdgeGroup } from '../edge-groups/types';
interface EdgeStackStatusDetails { export enum StatusType {
Pending: boolean; /** Pending represents a pending edge stack */
Ok: boolean; Pending,
Error: boolean; /** DeploymentReceived represents an edge environment which received the edge stack deployment */
Acknowledged: boolean; DeploymentReceived,
Remove: boolean; /** Error represents an edge environment which failed to deploy its edge stack */
RemoteUpdateSuccess: boolean; Error,
ImagesPulled: boolean; /** Acknowledged represents an acknowledged edge stack */
Acknowledged,
/** Removed represents a removed edge stack */
Removed,
/** StatusRemoteUpdateSuccess represents a successfully updated edge stack */
RemoteUpdateSuccess,
/** ImagesPulled represents a successfully images-pulling */
ImagesPulled,
/** Running represents a running Edge stack */
Running,
/** Deploying represents an Edge stack which is being deployed */
Deploying,
/** Removing represents an Edge stack which is being removed */
Removing,
} }
export type StatusType = keyof EdgeStackStatusDetails; export interface DeploymentStatus {
Type: StatusType;
Error: string;
Time: number;
}
interface EdgeStackDeploymentInfo {
FileVersion: number;
ConfigHash: string;
}
export interface EdgeStackStatus { export interface EdgeStackStatus {
Details: EdgeStackStatusDetails; Status: Array<DeploymentStatus>;
Error: string;
EndpointID: EnvironmentId; EndpointID: EnvironmentId;
DeploymentInfo?: EdgeStackDeploymentInfo;
} }
export enum DeploymentType { export enum DeploymentType {

@ -0,0 +1,16 @@
import { DeploymentStatus } from '../types';
/**
* returns the latest status object of each type
*/
export function uniqueStatus(statusArray: Array<DeploymentStatus> = []) {
// keep only the last status object of each type, assume that the last status is the most recent
return statusArray.reduce((acc, status) => {
const index = acc.findIndex((s) => s.Type === status.Type);
if (index === -1) {
return [...acc, status];
}
return [...acc.slice(0, index), ...acc.slice(index + 1), status];
}, [] as Array<DeploymentStatus>);
}

@ -3,6 +3,7 @@ package composeplugin
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@ -160,9 +161,13 @@ func (wrapper *PluginWrapper) command(command composeCommand, options libstack.O
Err(err). Err(err).
Msg("docker compose command failed") Msg("docker compose command failed")
if errOutput != "" {
return nil, errors.New(errOutput) return nil, errors.New(errOutput)
} }
return nil, fmt.Errorf("docker compose command failed: %w", err)
}
return output, nil return output, nil
} }

@ -2,7 +2,6 @@ package composeplugin
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -16,9 +15,9 @@ import (
) )
func checkPrerequisites(t *testing.T) { func checkPrerequisites(t *testing.T) {
if _, err := os.Stat("docker-compose"); errors.Is(err, os.ErrNotExist) { // if _, err := os.Stat("docker-compose"); errors.Is(err, os.ErrNotExist) {
t.Fatal("docker-compose binary not found, please run download.sh and re-run this test suite") // t.Fatal("docker-compose binary not found, please run download.sh and re-run this test suite")
} // }
} }
func setup(t *testing.T) libstack.Deployer { func setup(t *testing.T) libstack.Deployer {
@ -118,7 +117,11 @@ func createFile(dir, fileName, content string) (string, error) {
return "", err return "", err
} }
f.WriteString(content) _, err = f.WriteString(content)
if err != nil {
return "", err
}
f.Close() f.Close()
return filePath, nil return filePath, nil

@ -0,0 +1,171 @@
package composeplugin
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/portainer/portainer/pkg/libstack"
"github.com/rs/zerolog/log"
)
type publisher struct {
URL string
TargetPort int
PublishedPort int
Protocol string
}
type service struct {
ID string
Name string
Image string
Command string
Project string
Service string
Created int64
State string
Status string
Health string
ExitCode int
Publishers []publisher
}
// docker container state can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
func getServiceStatus(service service) (libstack.Status, string) {
log.Debug().
Str("service", service.Name).
Str("state", service.State).
Int("exitCode", service.ExitCode).
Msg("getServiceStatus")
switch service.State {
case "created", "restarting", "paused":
return libstack.StatusStarting, ""
case "running":
return libstack.StatusRunning, ""
case "removing":
return libstack.StatusRemoving, ""
case "exited", "dead":
if service.ExitCode != 0 {
return libstack.StatusError, fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
}
return libstack.StatusRemoved, ""
default:
return libstack.StatusUnknown, ""
}
}
func aggregateStatuses(services []service) (libstack.Status, string) {
servicesCount := len(services)
if servicesCount == 0 {
log.Debug().
Msg("no services found")
return libstack.StatusRemoved, ""
}
statusCounts := make(map[libstack.Status]int)
errorMessage := ""
for _, service := range services {
status, serviceError := getServiceStatus(service)
if serviceError != "" {
errorMessage = serviceError
}
statusCounts[status]++
}
log.Debug().
Interface("statusCounts", statusCounts).
Str("errorMessage", errorMessage).
Msg("check_status")
switch {
case errorMessage != "":
return libstack.StatusError, errorMessage
case statusCounts[libstack.StatusStarting] > 0:
return libstack.StatusStarting, ""
case statusCounts[libstack.StatusRemoving] > 0:
return libstack.StatusRemoving, ""
case statusCounts[libstack.StatusRunning] == servicesCount:
return libstack.StatusRunning, ""
case statusCounts[libstack.StatusStopped] == servicesCount:
return libstack.StatusStopped, ""
case statusCounts[libstack.StatusRemoved] == servicesCount:
return libstack.StatusRemoved, ""
default:
return libstack.StatusUnknown, ""
}
}
func (wrapper *PluginWrapper) WaitForStatus(ctx context.Context, name string, status libstack.Status) <-chan string {
errorMessageCh := make(chan string)
go func() {
for {
select {
case <-ctx.Done():
errorMessageCh <- fmt.Sprintf("failed to wait for status: %s", ctx.Err().Error())
default:
}
time.Sleep(1 * time.Second)
output, err := wrapper.command(newCommand([]string{"ps", "-a", "--format", "json"}, nil), libstack.Options{
ProjectName: name,
})
if len(output) == 0 {
log.Debug().
Str("project_name", name).
Msg("no output from docker compose ps")
continue
}
if err != nil {
log.Debug().
Str("project_name", name).
Err(err).
Msg("error from docker compose ps")
continue
}
var services []service
err = json.Unmarshal(output, &services)
if err != nil {
log.Debug().
Str("project_name", name).
Err(err).
Msg("failed to parse docker compose output")
continue
}
if len(services) == 0 && status == libstack.StatusRemoved {
errorMessageCh <- ""
return
}
aggregateStatus, errorMessage := aggregateStatuses(services)
if aggregateStatus == status {
errorMessageCh <- ""
return
}
if errorMessage != "" {
errorMessageCh <- errorMessage
return
}
log.Debug().
Str("project_name", name).
Str("status", string(aggregateStatus)).
Msg("waiting for status")
}
}()
return errorMessageCh
}

@ -0,0 +1,116 @@
package composeplugin
import (
"context"
"os"
"testing"
"time"
"github.com/portainer/portainer/pkg/libstack"
)
/*
1. starting = docker compose file that runs several services, one of them should be with status starting
2. running = docker compose file that runs successfully and returns status running
3. removing = run docker compose config, remove the stack, and return removing status
4. failed = run a valid docker compose file, but one of the services should fail to start (so "docker compose up" should run successfully, but one of the services should do something like `CMD ["exit", "1"]
5. removed = remove a compose stack and return status removed
*/
func ensureIntegrationTest(t *testing.T) {
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
t.Skip("skip an integration test")
}
}
func TestComposeProjectStatus(t *testing.T) {
ensureIntegrationTest(t)
testCases := []struct {
TestName string
ComposeFile string
ExpectedStatus libstack.Status
ExpectedStatusMessage bool
}{
{
TestName: "running",
ComposeFile: "status_test_files/running.yml",
ExpectedStatus: libstack.StatusRunning,
},
{
TestName: "failed",
ComposeFile: "status_test_files/failed.yml",
ExpectedStatus: libstack.StatusError,
ExpectedStatusMessage: true,
},
}
w := setup(t)
ctx := context.Background()
for _, testCase := range testCases {
t.Run(testCase.TestName, func(t *testing.T) {
projectName := testCase.TestName
err := w.Deploy(ctx, []string{testCase.ComposeFile}, libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
},
})
if err != nil {
t.Fatalf("[test: %s] Failed to deploy compose file: %v", testCase.TestName, err)
}
time.Sleep(5 * time.Second)
status, statusMessage, err := waitForStatus(w, ctx, projectName, libstack.StatusRunning)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}
if status != testCase.ExpectedStatus {
t.Fatalf("[test: %s] Expected status: %s, got: %s", testCase.TestName, testCase.ExpectedStatus, status)
}
if testCase.ExpectedStatusMessage && statusMessage == "" {
t.Fatalf("[test: %s] Expected status message but got empty", testCase.TestName)
}
err = w.Remove(ctx, projectName, nil, libstack.Options{})
if err != nil {
t.Fatalf("[test: %s] Failed to remove compose project: %v", testCase.TestName, err)
}
time.Sleep(20 * time.Second)
status, statusMessage, err = waitForStatus(w, ctx, projectName, libstack.StatusRemoved)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}
if status != libstack.StatusRemoved {
t.Fatalf("[test: %s] Expected stack to be removed, got %s", testCase.TestName, status)
}
if statusMessage != "" {
t.Fatalf("[test: %s] Expected empty status message: %s, got: %s", "", testCase.TestName, statusMessage)
}
})
}
}
func waitForStatus(deployer libstack.Deployer, ctx context.Context, stackName string, requiredStatus libstack.Status) (libstack.Status, string, error) {
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
defer cancel()
statusCh := deployer.WaitForStatus(ctx, stackName, requiredStatus)
result := <-statusCh
if result == "" {
return requiredStatus, "", nil
}
return libstack.StatusError, result, nil
}

@ -0,0 +1,7 @@
version: '3'
services:
web:
image: nginx:latest
failing-service:
image: busybox
command: ["false"]

@ -0,0 +1,4 @@
version: '3'
services:
web:
image: nginx:latest

@ -13,8 +13,21 @@ type Deployer interface {
Remove(ctx context.Context, projectName string, filePaths []string, options Options) error Remove(ctx context.Context, projectName string, filePaths []string, options Options) error
Pull(ctx context.Context, filePaths []string, options Options) error Pull(ctx context.Context, filePaths []string, options Options) error
Validate(ctx context.Context, filePaths []string, options Options) error Validate(ctx context.Context, filePaths []string, options Options) error
WaitForStatus(ctx context.Context, name string, status Status) <-chan string
} }
type Status string
const (
StatusUnknown Status = "unknown"
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusStopped Status = "stopped"
StatusError Status = "error"
StatusRemoving Status = "removing"
StatusRemoved Status = "removed"
)
type Options struct { type Options struct {
WorkingDir string WorkingDir string
Host string Host string

Loading…
Cancel
Save