diff --git a/api/gitops/workflows/mapping.go b/api/gitops/workflows/mapping.go new file mode 100644 index 000000000..ffde946a5 --- /dev/null +++ b/api/gitops/workflows/mapping.go @@ -0,0 +1,131 @@ +package workflows + +import ( + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" +) + +// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately +// because EE embeds a different GitConfig type that shadows the CE field. +func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig) Workflow { + status, msg := deriveStackStatus(s) + return Workflow{ + ID: int(s.ID), + Name: s.Name, + Type: TypeStack, + Platform: platformFromStackType(s.Type), + Status: status, + StatusMessage: msg, + GitConfig: gitConfig, + Target: Target{ + EndpointID: s.EndpointID, + Namespace: s.Namespace, + }, + CreationDate: s.CreationDate, + LastSyncDate: stackLastSyncDate(s), + } +} + +// MapEdgeStackToWorkflow converts an edge stack to a Workflow. gitConfig is passed separately +// because EE embeds a different GitConfig type that shadows the CE field. +func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID) Workflow { + status, msg := deriveEdgeStackStatus(statuses) + platform := DeploymentPlatformDockerStandalone + if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes { + platform = DeploymentPlatformKubernetes + } + return Workflow{ + ID: int(es.ID), + Name: es.Name, + Type: TypeEdgeStack, + Platform: platform, + Status: status, + StatusMessage: msg, + GitConfig: gitConfig, + Target: Target{ + EdgeGroupIDs: es.EdgeGroups, + GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints), + }, + CreationDate: es.CreationDate, + LastSyncDate: edgeStackLastSyncDate(statuses), + } +} + +func stackLastSyncDate(s portainer.Stack) int64 { + for i := len(s.DeploymentStatus) - 1; i >= 0; i-- { + if s.DeploymentStatus[i].Status == portainer.StackStatusActive { + return s.DeploymentStatus[i].Time + } + } + return 0 +} + +func edgeStackLastSyncDate(statuses []portainer.EdgeStackStatusForEnv) int64 { + var oldest int64 + for _, epStatus := range statuses { + last := endpointLastSyncDate(epStatus) + if last == 0 { + return 0 + } + if oldest == 0 || last < oldest { + oldest = last + } + } + return oldest +} + +func endpointLastSyncDate(epStatus portainer.EdgeStackStatusForEnv) int64 { + for i := len(epStatus.Status) - 1; i >= 0; i-- { + if isEdgeStackHealthyStatus(epStatus.Status[i].Type) { + return epStatus.Status[i].Time + } + } + return 0 +} + +func platformFromStackType(t portainer.StackType) DeploymentPlatform { + switch t { + case portainer.KubernetesStack: + return DeploymentPlatformKubernetes + case portainer.DockerSwarmStack: + return DeploymentPlatformDockerSwarm + default: + return DeploymentPlatformDockerStandalone + } +} + +func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool { + switch t { + case portainer.EdgeStackStatusRunning, + portainer.EdgeStackStatusRolledBack, + portainer.EdgeStackStatusCompleted, + portainer.EdgeStackStatusRemoved, + portainer.EdgeStackStatusRemoteUpdateSuccess: + return true + } + return false +} + +func edgeStackTargetStatuses( + groups []portainer.EdgeGroupID, + statuses []portainer.EdgeStackStatusForEnv, + groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, +) map[portainer.EdgeGroupID]Status { + epMap := make(map[portainer.EndpointID]Status, len(statuses)) + for _, s := range statuses { + ws, _ := endpointWorkflowStatus(s) + epMap[s.EndpointID] = ws + } + + result := make(map[portainer.EdgeGroupID]Status, len(groups)) + for _, gid := range groups { + gStatus := StatusUnknown + for _, epID := range groupEndpoints[gid] { + if ws := epMap[epID]; statusPriority(ws) > statusPriority(gStatus) { + gStatus = ws + } + } + result[gid] = gStatus + } + return result +} diff --git a/api/gitops/workflows/status.go b/api/gitops/workflows/status.go new file mode 100644 index 000000000..c77343281 --- /dev/null +++ b/api/gitops/workflows/status.go @@ -0,0 +1,100 @@ +package workflows + +import portainer "github.com/portainer/portainer/api" + +func deriveStackStatus(s portainer.Stack) (Status, string) { + if len(s.DeploymentStatus) == 0 { + return StatusHealthy, "" + } + last := s.DeploymentStatus[len(s.DeploymentStatus)-1] + switch last.Status { + case portainer.StackStatusActive: + return StatusHealthy, "" + case portainer.StackStatusError: + return StatusError, last.Message + case portainer.StackStatusDeploying: + return StatusSyncing, "" + case portainer.StackStatusInactive: + return StatusPaused, "" + default: + return StatusUnknown, "" + } +} + +func deriveEdgeStackStatus(statuses []portainer.EdgeStackStatusForEnv) (Status, string) { + result := StatusUnknown + for _, epStatus := range statuses { + ws, msg := endpointWorkflowStatus(epStatus) + if ws == StatusError { + return ws, msg + } + if statusPriority(ws) > statusPriority(result) { + result = ws + } + } + return result, "" +} + +func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, string) { + if len(epStatus.Status) == 0 { + return StatusUnknown, "" + } + last := epStatus.Status[len(epStatus.Status)-1] + switch last.Type { + case portainer.EdgeStackStatusError: + return StatusError, last.Error + case portainer.EdgeStackStatusDeploying, + portainer.EdgeStackStatusRollingBack, + portainer.EdgeStackStatusRemoving, + portainer.EdgeStackStatusPending, + portainer.EdgeStackStatusDeploymentReceived, + portainer.EdgeStackStatusAcknowledged, + portainer.EdgeStackStatusImagesPulled: + return StatusSyncing, "" + case portainer.EdgeStackStatusPausedDeploying: + return StatusPaused, "" + case portainer.EdgeStackStatusRunning, + portainer.EdgeStackStatusRolledBack, + portainer.EdgeStackStatusCompleted, + portainer.EdgeStackStatusRemoved, + portainer.EdgeStackStatusRemoteUpdateSuccess: + return StatusHealthy, "" + default: + return StatusUnknown, "" + } +} + +// CountByStatus counts workflows per status and returns a StatusSummary. +func CountByStatus(workflows []Workflow) StatusSummary { + var s StatusSummary + for _, w := range workflows { + switch w.Status { + case StatusHealthy: + s.Healthy++ + case StatusSyncing: + s.Syncing++ + case StatusError: + s.Error++ + case StatusPaused: + s.Paused++ + default: + s.Unknown++ + } + } + return s +} + +func statusPriority(s Status) int { + switch s { + case StatusError: + return 4 + case StatusSyncing: + return 3 + case StatusPaused: + return 2 + case StatusHealthy: + return 1 + default: + return 0 + } +} diff --git a/api/gitops/workflows/types.go b/api/gitops/workflows/types.go new file mode 100644 index 000000000..d310619c6 --- /dev/null +++ b/api/gitops/workflows/types.go @@ -0,0 +1,85 @@ +package workflows + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" +) + +type Status string + +const ( + StatusHealthy Status = "healthy" + StatusSyncing Status = "syncing" + StatusError Status = "error" + StatusPaused Status = "paused" + StatusUnknown Status = "unknown" +) + +type Type string + +const ( + TypeStack Type = "stack" + TypeEdgeStack Type = "edgeStack" +) + +type DeploymentPlatform string + +const ( + DeploymentPlatformDockerStandalone DeploymentPlatform = "dockerStandalone" + DeploymentPlatformDockerSwarm DeploymentPlatform = "dockerSwarm" + DeploymentPlatformKubernetes DeploymentPlatform = "kubernetes" +) + +func ParseStatus(s string) (Status, error) { + switch Status(s) { + case StatusHealthy, StatusSyncing, StatusError, StatusPaused, StatusUnknown: + return Status(s), nil + } + return "", fmt.Errorf("unknown status %q", s) +} + +func ParseType(s string) (Type, error) { + switch Type(s) { + case TypeStack, TypeEdgeStack: + return Type(s), nil + } + return "", fmt.Errorf("unknown type %q", s) +} + +func ParsePlatform(s string) (DeploymentPlatform, error) { + switch DeploymentPlatform(s) { + case DeploymentPlatformDockerStandalone, DeploymentPlatformDockerSwarm, DeploymentPlatformKubernetes: + return DeploymentPlatform(s), nil + } + return "", fmt.Errorf("unknown platform %q", s) +} + +type Target struct { + EndpointID portainer.EndpointID `json:"endpointId,omitempty"` + Namespace string `json:"namespace,omitempty"` + EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"` + GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"` +} + +type Workflow struct { + ID int `json:"id"` + Name string `json:"name"` + Type Type `json:"type"` + Platform DeploymentPlatform `json:"platform"` + Status Status `json:"status"` + StatusMessage string `json:"statusMessage,omitempty"` + GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"` + Target Target `json:"target"` + CreationDate int64 `json:"creationDate"` + LastSyncDate int64 `json:"lastSyncDate"` +} + +type StatusSummary struct { + Healthy int `json:"healthy"` + Syncing int `json:"syncing"` + Error int `json:"error"` + Paused int `json:"paused"` + Unknown int `json:"unknown"` +} diff --git a/api/http/handler/gitops/handler.go b/api/http/handler/gitops/handler.go index 764899405..d3eccfb87 100644 --- a/api/http/handler/gitops/handler.go +++ b/api/http/handler/gitops/handler.go @@ -9,6 +9,8 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/gorilla/mux" + + "github.com/portainer/portainer/api/http/handler/gitops/workflows" ) // Handler is the HTTP handler used to handle git repo operation @@ -32,5 +34,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor authenticatedRouter.Handle("/gitops/repo/file/preview", httperror.LoggerHandler(h.gitOperationRepoFilePreview)).Methods(http.MethodPost) + workflowsHandler := workflows.NewHandler(dataStore) + authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler) + return h } diff --git a/api/http/handler/gitops/workflows/filter.go b/api/http/handler/gitops/workflows/filter.go new file mode 100644 index 000000000..91fbcc840 --- /dev/null +++ b/api/http/handler/gitops/workflows/filter.go @@ -0,0 +1,66 @@ +package workflows + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +func endpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool { + switch stackType { + case portainer.DockerSwarmStack: + return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm + case portainer.DockerComposeStack: + return len(ep.Snapshots) == 0 || !ep.Snapshots[0].Swarm + default: + return true + } +} + +func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (map[portainer.EndpointID]portainer.Endpoint, error) { + ids := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) portainer.EndpointID { return s.EndpointID })) + + endpoints, err := tx.Endpoint().ReadAll(func(ep portainer.Endpoint) bool { return ids[ep.ID] }) + if err != nil { + return nil, err + } + + m := make(map[portainer.EndpointID]portainer.Endpoint, len(endpoints)) + for i := range endpoints { + if err := snapshot.FillSnapshotData(tx, &endpoints[i], false); err != nil { + return nil, fmt.Errorf("unable to fill snapshot data for endpoint %d: %w", endpoints[i].ID, err) + } + m[endpoints[i].ID] = endpoints[i] + } + + return m, nil +} + +func filterStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) { + if sc.IsAdmin { + return stacks, nil + } + + stackResourceIDSet := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) string { + return stackutils.ResourceControlID(s.EndpointID, s.Name) + })) + + resourceControls, err := tx.ResourceControl().ReadAll(func(rc portainer.ResourceControl) bool { + return rc.Type == portainer.StackResourceControl && stackResourceIDSet[rc.ResourceID] + }) + if err != nil { + return nil, err + } + + stacks = authorization.DecorateStacks(stacks, resourceControls) + + userTeamIDs := authorization.TeamIDs(sc.UserMemberships) + return authorization.FilterAuthorizedStacks(stacks, sc.UserID, userTeamIDs), nil +} diff --git a/api/http/handler/gitops/workflows/filter_test.go b/api/http/handler/gitops/workflows/filter_test.go new file mode 100644 index 000000000..7aaa13adf --- /dev/null +++ b/api/http/handler/gitops/workflows/filter_test.go @@ -0,0 +1,80 @@ +package workflows + +import ( + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/stacks/stackutils" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowsList_RBAC_NonAdminNoAccess(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ + ID: 1, + Username: "standard", + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + require.NoError(t, store.User().Create(user)) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"})) + + // Stack on endpoint 1 WITHOUT resource control — non-admin cannot see it + require.NoError(t, store.StackService.Create(&portainer.Stack{ + ID: 1, Name: "no-rc-stack", EndpointID: 1, + GitConfig: gitConfig("https://github.com/x/no-rc"), + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, "")) + + items := decodeWorkflows(t, rr) + assert.Empty(t, items, "non-admin without resource control access should see no stacks") +} + +func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ + ID: 1, + Username: "standard", + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + require.NoError(t, store.User().Create(user)) + + require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"})) + + const stackName = "rc-stack" + require.NoError(t, store.StackService.Create(&portainer.Stack{ + ID: 1, Name: stackName, EndpointID: 1, + GitConfig: gitConfig("https://github.com/x/rc"), + })) + + require.NoError(t, store.ResourceControl().Create(&portainer.ResourceControl{ + ID: 1, + ResourceID: stackutils.ResourceControlID(1, stackName), + Type: portainer.StackResourceControl, + UserAccesses: []portainer.UserResourceAccess{ + {UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel}, + }, + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, stackName, items[0].Name) +} diff --git a/api/http/handler/gitops/workflows/handler.go b/api/http/handler/gitops/workflows/handler.go new file mode 100644 index 000000000..3433b656e --- /dev/null +++ b/api/http/handler/gitops/workflows/handler.go @@ -0,0 +1,36 @@ +package workflows + +import ( + "net/http" + "time" + + gocache "github.com/patrickmn/go-cache" + "github.com/portainer/portainer/api/dataservices" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +const ( + cacheTTL = 30 * time.Second + cacheCleanupInterval = 10 * time.Minute +) + +type Handler struct { + *mux.Router + dataStore dataservices.DataStore + cache *gocache.Cache +} + +func NewHandler(dataStore dataservices.DataStore) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + dataStore: dataStore, + cache: gocache.New(cacheTTL, cacheCleanupInterval), + } + + h.Handle("/gitops/workflows", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) + h.Handle("/gitops/workflows/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet) + + return h +} diff --git a/api/http/handler/gitops/workflows/helpers_test.go b/api/http/handler/gitops/workflows/helpers_test.go new file mode 100644 index 000000000..8d49a2b25 --- /dev/null +++ b/api/http/handler/gitops/workflows/helpers_test.go @@ -0,0 +1,42 @@ +package workflows + +import ( + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + + "github.com/segmentio/encoding/json" + "github.com/stretchr/testify/require" +) + +// buildWorkflowsReq creates an HTTP GET request with security context pre-populated. +func buildWorkflowsReq(t *testing.T, userID portainer.UserID, role portainer.UserRole, query string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/gitops/workflows?"+query, nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: userID}) + req = req.WithContext(ctx) + ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{ + UserID: userID, + IsAdmin: security.IsAdminRole(role), + }) + return req.WithContext(ctx) +} + +// decodeWorkflows decodes a 200 JSON response into a slice of ce.Workflow. +func decodeWorkflows(t *testing.T, rr *httptest.ResponseRecorder) []ce.Workflow { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String()) + var items []ce.Workflow + require.NoError(t, json.NewDecoder(rr.Body).Decode(&items)) + return items +} + +// gitConfig is a convenience constructor for test RepoConfigs. +func gitConfig(url string) *gittypes.RepoConfig { + return &gittypes.RepoConfig{URL: url, ConfigFilePath: "docker-compose.yml"} +} diff --git a/api/http/handler/gitops/workflows/list.go b/api/http/handler/gitops/workflows/list.go new file mode 100644 index 000000000..fb4e8216e --- /dev/null +++ b/api/http/handler/gitops/workflows/list.go @@ -0,0 +1,191 @@ +package workflows + +import ( + "cmp" + "net/http" + "slices" + "strconv" + "strings" + + gocache "github.com/patrickmn/go-cache" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + svc "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/utils/filters" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsWorkflowsList +// @summary List all GitOps workflows +// @description Returns a unified list of all stacks that have GitOps (GitConfig) configured. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param search query string false "Search term (matches name or repository URL)" +// @param sort query string false "Sort field: name | type | status | creationDate | lastSyncDate" +// @param order query string false "Sort order: asc or desc" +// @param start query int false "Pagination start index" +// @param limit query int false "Pagination limit (0 = unlimited)" +// @param endpointIds query []int false "Filter by environment IDs (e.g. endpointIds[]=1&endpointIds[]=2)" +// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown" +// @param type query string false "Filter by type: stack" +// @param platform query string false "Filter by platform: dockerStandalone | dockerSwarm | kubernetes" +// @success 200 {array} svc.Workflow +// @failure 500 "Server error" +// @router /gitops/workflows [get] +func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + params := filters.ExtractListModifiersQueryParams(r) + + endpointIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds") + if err != nil { + return httperror.BadRequest("Invalid endpointIds parameter", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + key := cacheKey(securityContext, endpointIDs) + + items, err := h.getWorkflows(key, securityContext, endpointIDs) + if err != nil { + return httperror.InternalServerError("Unable to retrieve workflows", err) + } + + if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" { + s, err := svc.ParseStatus(status) + if err != nil { + return httperror.BadRequest("Invalid status parameter", err) + } + items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Status == s }) + } + + if workflowType, _ := request.RetrieveQueryParameter(r, "type", true); workflowType != "" { + t, err := svc.ParseType(workflowType) + if err != nil { + return httperror.BadRequest("Invalid type parameter", err) + } + items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Type == t }) + } + + if platform, _ := request.RetrieveQueryParameter(r, "platform", true); platform != "" { + p, err := svc.ParsePlatform(platform) + if err != nil { + return httperror.BadRequest("Invalid platform parameter", err) + } + items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Platform == p }) + } + + results := filters.SearchOrderAndPaginate(items, params, filters.Config[svc.Workflow]{ + SearchAccessors: []filters.SearchAccessor[svc.Workflow]{ + func(i svc.Workflow) (string, error) { return i.Name, nil }, + func(i svc.Workflow) (string, error) { + if i.GitConfig == nil { + return "", nil + } + return i.GitConfig.URL, nil + }, + }, + SortBindings: []filters.SortBinding[svc.Workflow]{ + {Key: "name", Fn: func(a, b svc.Workflow) int { return strings.Compare(a.Name, b.Name) }}, + {Key: "type", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Type), string(b.Type)) }}, + {Key: "status", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Status), string(b.Status)) }}, + {Key: "creationDate", Fn: func(a, b svc.Workflow) int { return cmp.Compare(a.CreationDate, b.CreationDate) }}, + {Key: "lastSyncDate", Fn: func(a, b svc.Workflow) int { return cmp.Compare(a.LastSyncDate, b.LastSyncDate) }, NullsLast: func(i svc.Workflow) bool { return i.LastSyncDate == 0 }}, + {Key: "platform", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Platform), string(b.Platform)) }}, + }, + }) + + filters.ApplyFilterResultsHeaders(&w, results) + return response.JSON(w, redactWorkflowCredentials(results.Items)) +} + +func redactWorkflowCredentials(items []svc.Workflow) []svc.Workflow { + for i := range items { + if items[i].GitConfig != nil && items[i].GitConfig.Authentication != nil { + gc := *items[i].GitConfig + auth := *gc.Authentication + auth.Password = "" + gc.Authentication = &auth + items[i].GitConfig = &gc + } + } + return items +} + +func (h *Handler) getWorkflows(key string, sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) ([]svc.Workflow, error) { + if cached, ok := h.cache.Get(key); ok { + return slices.Clone(cached.([]svc.Workflow)), nil + } + + result, err := h.fetchWorkflows(sc, set.ToSet(endpointIDs)) + if err != nil { + return nil, err + } + h.cache.Set(key, result, gocache.DefaultExpiration) + return slices.Clone(result), nil +} + +func (h *Handler) fetchWorkflows(sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) { + var items []svc.Workflow + err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { + return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID)) + }) + if err != nil { + return err + } + + endpointMap, err := buildEndpointMap(tx, stacks) + if err != nil { + return err + } + + stacks, err = filterStacksByAccess(tx, stacks, sc) + if err != nil { + return err + } + + for i := range stacks { + s := stacks[i] + + // TODO show kube stacks when there's a kube stacks view [BE-12867] + if s.Type == portainer.KubernetesStack { + continue + } + + if ep, ok := endpointMap[s.EndpointID]; ok && !endpointMatchesStackType(ep, s.Type) { + continue + } + items = append(items, svc.MapStackToWorkflow(s, s.GitConfig)) + } + + return nil + }) + + return items, err +} + +func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string { + ids := make([]string, len(endpointIDs)) + for i, id := range endpointIDs { + ids[i] = strconv.Itoa(int(id)) + } + slices.Sort(ids) + + teamIDs := make([]string, len(sc.UserMemberships)) + for i, membership := range sc.UserMemberships { + teamIDs[i] = strconv.Itoa(int(membership.TeamID)) + } + slices.Sort(teamIDs) + + return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(ids, ",") + ":" + strings.Join(teamIDs, ",") +} diff --git a/api/http/handler/gitops/workflows/list_test.go b/api/http/handler/gitops/workflows/list_test.go new file mode 100644 index 000000000..d6f5ec635 --- /dev/null +++ b/api/http/handler/gitops/workflows/list_test.go @@ -0,0 +1,304 @@ +package workflows + +import ( + "fmt" + "net/http/httptest" + "testing" + "testing/synctest" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + ce "github.com/portainer/portainer/api/gitops/workflows" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowsList_GitConfigFilter(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 1, Name: "gitops-stack", + GitConfig: gitConfig("https://github.com/example/repo"), + })) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"})) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "gitops-stack", items[0].Name) + assert.Equal(t, ce.TypeStack, items[0].Type) + assert.Equal(t, "https://github.com/example/repo", items[0].GitConfig.URL) + assert.Equal(t, "docker-compose.yml", items[0].GitConfig.ConfigFilePath) +} + +func TestWorkflowsList_EndpointIDsFilter(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i := 1; i <= 3; i++ { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: portainer.StackID(i), + Name: fmt.Sprintf("env%d-stack", i), + EndpointID: portainer.EndpointID(i), + GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/%d", i)), + })) + } + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1&endpointIds[]=2")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 2) + names := []string{items[0].Name, items[1].Name} + assert.Contains(t, names, "env1-stack") + assert.Contains(t, names, "env2-stack") +} + +func TestWorkflowsList_Pagination(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i := 1; i <= 5; i++ { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: portainer.StackID(i), + Name: fmt.Sprintf("stack-%d", i), + GitConfig: gitConfig("https://github.com/x/y"), + })) + } + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "start=0&limit=2")) + + items := decodeWorkflows(t, rr) + assert.Len(t, items, 2) + assert.Equal(t, "5", rr.Header().Get("X-Total-Count")) +} + +func TestWorkflowsList_Search(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for _, s := range []*portainer.Stack{ + {ID: 1, Name: "alpha", GitConfig: gitConfig("https://github.com/org/alpha")}, + {ID: 2, Name: "beta", GitConfig: gitConfig("https://github.com/org/beta")}, + } { + require.NoError(t, tx.Stack().Create(s)) + } + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=alpha")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "alpha", items[0].Name) +} + +func TestWorkflowsList_SearchByURL(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 1, Name: "stack-org1", + GitConfig: gitConfig("https://github.com/org1/repo"), + })) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 2, Name: "stack-org2", + GitConfig: gitConfig("https://github.com/org2/repo"), + })) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=org1")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, "stack-org1", items[0].Name) +} + +func TestWorkflowsList_Sort(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + for i, name := range []string{"gamma", "alpha", "beta"} { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: portainer.StackID(i + 1), + Name: name, + GitConfig: gitConfig("https://github.com/x/" + name), + })) + } + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 3) + assert.Equal(t, "gamma", items[0].Name) + assert.Equal(t, "beta", items[1].Name) + assert.Equal(t, "alpha", items[2].Name) +} + +// Uses testing/synctest to control time.Now() without real sleeps. +// The Handler is created outside the bubble so its go-cache cleanup goroutine +// does not join the bubble. Inside the bubble all time.Now() calls return +// fake time, so cache.Set stores a fake expiry and cache.Get compares +// against the same fake clock — consistent without touching real time. + +func TestWorkflowsList_Cache(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 1, Name: "initial-stack", + GitConfig: gitConfig("https://github.com/x/initial"), + })) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + // Create the handler outside the bubble so the go-cache cleanup goroutine + // is not part of the bubble and does not block synctest.Test from returning. + h := NewHandler(store) + + synctest.Test(t, func(t *testing.T) { + // First request at fake T=0: populates cache. + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + require.Len(t, decodeWorkflows(t, rr), 1) + + // Mutate the store while cache is still warm. + require.NoError(t, store.StackService.Create(&portainer.Stack{ + ID: 2, Name: "new-stack", + GitConfig: gitConfig("https://github.com/x/new"), + })) + + // Second request — same cache key, should return stale cached result. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + assert.Len(t, decodeWorkflows(t, rr), 1, "cache hit: new stack should not appear yet") + + // Advance fake clock past the cache TTL. synctest unblocks immediately + // since no other goroutines are in the bubble. + time.Sleep(cacheTTL + time.Second) + + // Third request — cache expired, should now fetch fresh data. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + assert.Len(t, decodeWorkflows(t, rr), 2, "after TTL expiry: both stacks should appear") + }) +} + +func TestWorkflowsList_CacheImmutableAfterSort(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + for i, name := range []string{"alpha", "beta", "gamma"} { + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create( + &portainer.Stack{ + ID: portainer.StackID(i + 1), + Name: name, + GitConfig: gitConfig("https://github.com/x/" + name), + }, + )) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + } + + h := NewHandler(store) + + // First request: no sort — cache miss, populates cache as [alpha, beta, gamma]. + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + items := decodeWorkflows(t, rr) + require.Len(t, items, 3) + require.Equal(t, "alpha", items[0].Name) + + // Second request: sort desc — cache hit, sorts the shared slice in-place to [gamma, beta, alpha]. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc")) + items = decodeWorkflows(t, rr) + require.Len(t, items, 3) + require.Equal(t, "gamma", items[0].Name) + + // Third request: no sort — should still return insertion order [alpha, beta, gamma], + // but without a defensive clone the mutated cache returns [gamma, beta, alpha]. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + items = decodeWorkflows(t, rr) + require.Len(t, items, 3) + assert.Equal(t, "alpha", items[0].Name, "sort must not mutate the cached slice") +} + +func TestWorkflowsList_CacheSeparateKeys(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 1, Name: "env1-stack", EndpointID: 1, + GitConfig: gitConfig("https://github.com/x/1"), + })) + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 2, Name: "env2-stack", EndpointID: 2, + GitConfig: gitConfig("https://github.com/x/2"), + })) + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + + rr1 := httptest.NewRecorder() + h.ServeHTTP(rr1, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1")) + items1 := decodeWorkflows(t, rr1) + require.Len(t, items1, 1) + assert.Equal(t, "env1-stack", items1[0].Name) + + rr2 := httptest.NewRecorder() + h.ServeHTTP(rr2, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=2")) + items2 := decodeWorkflows(t, rr2) + require.Len(t, items2, 1) + assert.Equal(t, "env2-stack", items2[0].Name) +} diff --git a/api/http/handler/gitops/workflows/status_test.go b/api/http/handler/gitops/workflows/status_test.go new file mode 100644 index 000000000..2b3f0c7e4 --- /dev/null +++ b/api/http/handler/gitops/workflows/status_test.go @@ -0,0 +1,84 @@ +package workflows + +import ( + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + ce "github.com/portainer/portainer/api/gitops/workflows" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowsList_StackStatusDerivation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + deployStatus []portainer.StackDeploymentStatus + expectedStatus ce.Status + }{ + { + name: "no deployment status → healthy", + expectedStatus: ce.StatusHealthy, + }, + { + name: "active → healthy", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusActive}}, + expectedStatus: ce.StatusHealthy, + }, + { + name: "error → error", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}}, + expectedStatus: ce.StatusError, + }, + { + name: "deploying → syncing", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}}, + expectedStatus: ce.StatusSyncing, + }, + { + name: "inactive → paused", + deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusInactive}}, + expectedStatus: ce.StatusPaused, + }, + { + name: "last entry wins", + deployStatus: []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying}, + {Status: portainer.StackStatusActive}, + }, + expectedStatus: ce.StatusHealthy, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.Stack().Create(&portainer.Stack{ + ID: 1, + Name: "status-stack", + DeploymentStatus: tc.deployStatus, + GitConfig: gitConfig("https://github.com/x/y"), + })) + + require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})) + return nil + })) + + h := NewHandler(store) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "")) + + items := decodeWorkflows(t, rr) + require.Len(t, items, 1) + assert.Equal(t, tc.expectedStatus, items[0].Status, tc.name) + }) + } +} diff --git a/api/http/handler/gitops/workflows/summary.go b/api/http/handler/gitops/workflows/summary.go new file mode 100644 index 000000000..66ccf3eb8 --- /dev/null +++ b/api/http/handler/gitops/workflows/summary.go @@ -0,0 +1,35 @@ +package workflows + +import ( + "net/http" + + svc "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsWorkflowsSummary +// @summary Summarize GitOps workflow status counts +// @description Returns a count of workflows per status across all environments. +// @description **Access policy**: authenticated +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} svc.StatusSummary +// @failure 500 "Server error" +// @router /gitops/workflows/summary [get] +func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + items, err := h.getWorkflows(cacheKey(securityContext, nil), securityContext, nil) + if err != nil { + return httperror.InternalServerError("Unable to retrieve workflows", err) + } + + return response.JSON(w, svc.CountByStatus(items)) +} diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go index a4bcf9cc8..310d86ef3 100644 --- a/api/http/handler/stacks/stack_list.go +++ b/api/http/handler/stacks/stack_list.go @@ -76,7 +76,7 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships) - stacks = authorization.FilterAuthorizedStacks(stacks, user, userTeamIDs) + stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs) } for _, stack := range stacks { diff --git a/api/http/utils/filters/sort.go b/api/http/utils/filters/sort.go index f7d937b4e..354a4a6fc 100644 --- a/api/http/utils/filters/sort.go +++ b/api/http/utils/filters/sort.go @@ -16,8 +16,9 @@ type SortQueryParams struct { type SortOption[T any] func(a, b T) int type SortBinding[T any] struct { - Key string - Fn SortOption[T] + Key string + Fn SortOption[T] + NullsLast func(T) bool // if set, items where this returns true always sort after all others } func sortFn[T any](items []T, params SortQueryParams, sorts []SortBinding[T]) []T { @@ -27,6 +28,9 @@ func sortFn[T any](items []T, params SortQueryParams, sorts []SortBinding[T]) [] if params.order == SortDesc { fn = reverSortFn(fn) } + if sort.NullsLast != nil { + fn = nullsLastWrap(fn, sort.NullsLast) + } slices.SortStableFunc(items, fn) } } @@ -38,3 +42,21 @@ func reverSortFn[T any](fn SortOption[T]) SortOption[T] { return -1 * fn(a, b) } } + +// nullsLastWrap wraps a comparator so that items where isNull returns true +// always sort after all others, regardless of sort direction. +func nullsLastWrap[T any](fn SortOption[T], isNull func(T) bool) SortOption[T] { + return func(a, b T) int { + aN, bN := isNull(a), isNull(b) + if aN && bN { + return 0 + } + if aN { + return 1 + } + if bN { + return -1 + } + return fn(a, b) + } +} diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go index dd9588272..d3e8e15b7 100644 --- a/api/internal/authorization/access_control.go +++ b/api/internal/authorization/access_control.go @@ -141,11 +141,11 @@ func DecorateCustomTemplates(templates []portainer.CustomTemplate, resourceContr } // FilterAuthorizedStacks returns a list of decorated stacks filtered through resource control access checks. -func FilterAuthorizedStacks(stacks []portainer.Stack, user *portainer.User, userTeamIDs []portainer.TeamID) []portainer.Stack { +func FilterAuthorizedStacks(stacks []portainer.Stack, userID portainer.UserID, userTeamIDs []portainer.TeamID) []portainer.Stack { authorizedStacks := make([]portainer.Stack, 0) for _, stack := range stacks { - if stack.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, stack.ResourceControl) { + if stack.ResourceControl != nil && UserCanAccessResource(userID, userTeamIDs, stack.ResourceControl) { authorizedStacks = append(authorizedStacks, stack) } } diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 4fc87fc83..b0a076916 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -298,6 +298,15 @@ angular docs: '/user/home', }, }; + var workflows = { + name: 'portainer.workflows', + url: '/workflows', + views: { + 'content@': { + component: 'workflowsView', + }, + }, + }; var init = { name: 'portainer.init', @@ -420,6 +429,7 @@ angular $stateRegistryProvider.register(groupAccess); $stateRegistryProvider.register(groupCreation); $stateRegistryProvider.register(home); + $stateRegistryProvider.register(workflows); $stateRegistryProvider.register(init); $stateRegistryProvider.register(initAdmin); $stateRegistryProvider.register(settings); diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts index 46cf3a667..0a9c40aee 100644 --- a/app/portainer/react/views/index.ts +++ b/app/portainer/react/views/index.ts @@ -10,6 +10,7 @@ import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeV import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel'; import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView'; import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView'; +import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView'; import { wizardModule } from './wizard'; import { teamsModule } from './teams'; @@ -66,4 +67,8 @@ export const viewsModule = angular withUIRouter(withReactQuery(withCurrentUser(CreateHelmRepositoriesView))), [] ) + ) + .component( + 'workflowsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(WorkflowsView))), []) ).name; diff --git a/app/react/common/api/pagination.types.ts b/app/react/common/api/pagination.types.ts index 390765251..f0e49b0d5 100644 --- a/app/react/common/api/pagination.types.ts +++ b/app/react/common/api/pagination.types.ts @@ -85,7 +85,7 @@ export function withPaginationQueryParams({ } export type PaginatedResults = { - data: T; + data: T | null; totalCount: number; totalAvailable: number; }; diff --git a/app/react/components/StatusSummaryBar/StatusSummaryBar.tsx b/app/react/components/StatusSummaryBar/StatusSummaryBar.tsx index 842b52656..f8ea813de 100644 --- a/app/react/components/StatusSummaryBar/StatusSummaryBar.tsx +++ b/app/react/components/StatusSummaryBar/StatusSummaryBar.tsx @@ -1,24 +1,24 @@ import { FilterBarButton, Color } from './FilterBarButton'; import { FilterBarActiveIndicator } from './FilterBarActiveIndicator'; -export interface StatusSegment { - key: string; +export interface StatusSegment { + key: TValue; label: string; count: number; color: Color; } -interface Props { +interface Props { total: number; - segments: StatusSegment[]; - value: string | null; - onChange: (filter: string | null) => void; + segments: Array>; + value: TValue | null; + onChange: (filter: TValue | null) => void; radioGroupName?: string; ariaLabel?: string; 'data-cy'?: string; } -export function StatusSummaryBar({ +export function StatusSummaryBar({ total, segments, value, @@ -26,11 +26,11 @@ export function StatusSummaryBar({ radioGroupName = 'status-summary-filter', ariaLabel = 'Filter by status', 'data-cy': dataCy = 'status-summary-bar', -}: Props) { +}: Props) { const isAllSelected = !value; const activeLabel = segments.find((s) => s.key === value)?.label; - function handleSegmentClick(key: string) { + function handleSegmentClick(key: TValue) { onChange(value === key ? null : key); } diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx index 698f10bae..bb972f93b 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx @@ -22,7 +22,7 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) { const jobResultsQuery = useJobResults(jobId, { ...queryOptionsFromTableState({ ...tableState }, sortOptions), refetchInterval(dataset) { - const anyCollecting = dataset?.data.some( + const anyCollecting = dataset?.data?.some( (r) => r.LogsStatus === LogsStatus.Pending ); diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowBadges.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowBadges.tsx new file mode 100644 index 000000000..179339d29 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowBadges.tsx @@ -0,0 +1,29 @@ +import { Badge, BadgeType } from '@@/Badge'; + +import { WorkflowStatus, WorkflowType } from './types'; + +export function StatusBadge({ status }: { status: WorkflowStatus }) { + const BADGE_TYPE: Record = { + healthy: 'success', + error: 'danger', + syncing: 'warn', + paused: 'muted', + unknown: 'muted', + }; + const LABELS: Record = { + healthy: 'Healthy', + error: 'Error', + syncing: 'Syncing', + paused: 'Paused', + unknown: 'Unknown', + }; + return ● {LABELS[status]}; +} + +export function TypeBadge({ type }: { type: WorkflowType }) { + const LABELS: Record = { + stack: 'Stack', + edgeStack: 'Edge Stack', + }; + return {LABELS[type]}; +} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx new file mode 100644 index 000000000..feea740ad --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx @@ -0,0 +1,91 @@ +import { AlertTriangle, GitCommit, WatchIcon } from 'lucide-react'; +import moment from 'moment'; + +import { StackType } from '@/react/common/stacks/types'; + +import { Icon } from '@@/Icon'; +import { Link } from '@@/Link'; +import { SortableListItem } from '@@/SortableList/SortableListItem'; + +import { Workflow } from './types'; +import { StatusBadge, TypeBadge } from './WorkflowBadges'; +import { WorkflowSubRow } from './WorkflowSubRow/WorkflowSubRow'; + +export function WorkflowCard({ item }: { item: Workflow }) { + const { to, params } = getStackLink(item); + const syncLabel = item.lastSyncDate + ? moment.unix(item.lastSyncDate).fromNow() + : '-'; + const syncTitle = item.type === 'edgeStack' ? 'Oldest sync' : 'Last sync'; + + return ( + +
+
+ +
+
+
+
+ + {item.name} + + + +
+
+ + + {syncTitle}: {syncLabel} + +
+
+ + {item.statusMessage && ( +
+ + {item.statusMessage} +
+ )} +
+
+
+ ); +} + +function getStackLink(item: Workflow): { to: string; params: object } { + if (item.type === 'edgeStack') { + return { to: 'edge.stacks.edit', params: { stackId: item.id } }; + } + if (item.platform === 'kubernetes') { + return { + to: 'kubernetes.applications.application', + params: { + endpointId: item.target.endpointId, + namespace: item.target.namespace, + name: item.name, + }, + }; + } + + const type = + item.platform === 'dockerSwarm' + ? StackType.DockerSwarm + : StackType.DockerCompose; + + return { + to: 'docker.stacks.stack', + params: { + endpointId: item.target.endpointId, + name: item.name, + id: item.id, + type, + regular: true, + }, + }; +} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/Block.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/Block.tsx new file mode 100644 index 000000000..bb796e145 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/Block.tsx @@ -0,0 +1,60 @@ +import clsx from 'clsx'; +import { ReactNode } from 'react'; + +import { WorkflowStatus } from '../types'; + +const STATUS_DOT_CLASSES: Record = { + healthy: 'bg-success-7', + error: 'bg-error-7', + syncing: 'bg-warning-7', + paused: 'bg-warning-5', + unknown: 'bg-gray-4', +}; + +const STATUS_BLOCK_CLASSES: Record = { + healthy: 'bg-success-1 th-dark:bg-success-11', + error: 'bg-error-1 th-dark:bg-error-11', + syncing: 'bg-warning-1 th-dark:bg-warning-11', + paused: 'bg-gray-2 th-dark:bg-gray-iron-11', + unknown: 'bg-gray-2 th-dark:bg-gray-iron-11', +}; + +export function Block({ + status, + className, + children, +}: { + status: WorkflowStatus; + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function Dot({ + status, + className, +}: { + status: WorkflowStatus; + className?: string; +}) { + return ( + + ); +} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/TargetCell.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/TargetCell.tsx new file mode 100644 index 000000000..6fdfa5a88 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/TargetCell.tsx @@ -0,0 +1,131 @@ +import { ReactNode } from 'react'; +import { LayoutGridIcon } from 'lucide-react'; + +import { useEnvironment } from '@/react/portainer/environments/queries'; +import { + getDashboardRoute, + getEnvironmentTypeIcon, +} from '@/react/portainer/environments/utils'; +import { useEdgeGroup } from '@/react/edge/edge-groups/queries/useEdgeGroup'; + +import { Icon } from '@@/Icon'; +import { Link } from '@@/Link'; + +import { WorkflowStatus, WorkflowTarget, WorkflowType } from '../types'; + +import { Block, Dot } from './Block'; + +export function TargetCell({ + target, + type, + status, +}: { + target: WorkflowTarget; + type: WorkflowType; + status: WorkflowStatus; +}) { + if (type === 'edgeStack') { + return ( +
+ {target.edgeGroupIds?.map((id) => ( + + ))} +
+ ); + } + + return ; +} + +function EdgeGroupTarget({ + id, + status, +}: { + id: number; + status: WorkflowStatus; +}) { + const { data } = useEdgeGroup(id); + const name = data?.Name ?? `Edge Group ${id}`; + return ( + + } + status={status} + > + + {name} + + + ); +} + +function StackTarget({ + target, + status, +}: { + target: WorkflowTarget; + status: WorkflowStatus; +}) { + const { data: environment } = useEnvironment(target.endpointId); + + if (!target.endpointId) { + return No target; + } + + const envName = environment?.Name ?? `Environment ${target.endpointId}`; + const icon = environment + ? getEnvironmentTypeIcon(environment.Type) + : 'circle'; + const label = target.namespace ? `${envName} / ${target.namespace}` : envName; + const dashboardRoute = environment + ? getDashboardRoute(environment) + : { to: '', params: {} }; + + return ( + } + status={status} + > + + {label} + + + ); +} + +function TargetRow({ + icon, + status, + children, +}: { + icon: ReactNode; + status: WorkflowStatus; + children: ReactNode; +}) { + return ( + + + {icon} + {children} + + ); +} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx new file mode 100644 index 000000000..d976d2622 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx @@ -0,0 +1,128 @@ +import clsx from 'clsx'; +import { ReactNode } from 'react'; + +import { Workflow, WorkflowStatus } from '../types'; + +import { Block, Dot } from './Block'; +import { TargetCell } from './TargetCell'; +import { deriveSubRowStatuses } from './status'; + +export function WorkflowSubRow({ item }: { item: Workflow }) { + const status = deriveSubRowStatuses(item); + + return ( +
+ + + + + + + + + + + + + + + +
SourceArtifactsTargets
+ {item.gitConfig && ( + + )} + + {item.gitConfig && ( + + )} + + +
+
+ ); +} + +function SourceCell({ + name, + url, + status, +}: { + name: string; + url: string; + status: WorkflowStatus; +}) { + return ( + + +
+

+ {name} +

+

{url}

+
+
+ ); +} + +function ArtifactCell({ + path, + status, +}: { + path: string; + status: WorkflowStatus; +}) { + return ( + + + {path} + + ); +} + +function Th({ children, divider }: { children: ReactNode; divider?: boolean }) { + return ( + + {children} + + ); +} + +function Td({ + children, + divider, + rowSpan, +}: { + children?: ReactNode; + divider?: boolean; + rowSpan?: number; +}) { + return ( + + {children} + + ); +} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.test.ts b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.test.ts new file mode 100644 index 000000000..7d7397308 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; + +import { Workflow, WorkflowStatus } from '../types'; + +import { deriveSubRowStatuses } from './status'; + +function makeItem(status: WorkflowStatus, statusMessage?: string): Workflow { + return { + id: 1, + name: 'test', + type: 'stack', + platform: 'dockerStandalone', + status, + statusMessage, + target: { endpointId: 1 }, + creationDate: 0, + lastSyncDate: 0, + }; +} + +describe('deriveSubRowStatuses', () => { + describe('paused', () => { + it('returns healthy source/artifact and paused target', () => { + expect(deriveSubRowStatuses(makeItem('paused'))).toEqual({ + source: 'healthy', + artifact: 'healthy', + target: 'paused', + }); + }); + }); + + describe('non-error statuses', () => { + it.each(['healthy', 'syncing', 'unknown'])( + 'propagates %s to all three phases', + (status) => { + expect(deriveSubRowStatuses(makeItem(status))).toEqual({ + source: status, + artifact: status, + target: status, + }); + } + ); + }); + + describe('error with git-related message', () => { + it.each([ + 'failed to clone repository', + 'could not fetch remote', + 'pull failed', + 'repository not found', + 'authentication failed', + 'invalid credential', + 'ssh: connect to host', + 'unable to access https://github.com', + 'could not read from remote repository', + ])('classifies "%s" as source error', (msg) => { + expect(deriveSubRowStatuses(makeItem('error', msg))).toEqual({ + source: 'error', + artifact: 'unknown', + target: 'unknown', + }); + }); + }); + + describe('error with artifact message', () => { + it.each([ + 'yaml: unmarshal errors', + 'stack config file is invalid: services must be a mapping', + 'failed to get stack file content', + 'bind-mount disabled for non administrator users', + 'privileged mode disabled for non administrator users', + 'pid host disabled for non administrator users', + 'device mapping disabled for non administrator users', + 'sysctl setting disabled for non administrator users', + 'security-opt setting disabled for non administrator users', + 'container capabilities disabled for non administrator users', + 'failed to parse compose file', + ])('classifies "%s" as artifact error', (msg) => { + expect(deriveSubRowStatuses(makeItem('error', msg))).toEqual({ + source: 'healthy', + artifact: 'error', + target: 'unknown', + }); + }); + }); + + describe('error with target message', () => { + it.each([ + 'container failed to start', + 'exit code 1', + 'out of memory', + 'OOMKilled', + undefined, + ])('classifies "%s" as target error', (msg) => { + expect(deriveSubRowStatuses(makeItem('error', msg))).toEqual({ + source: 'healthy', + artifact: 'healthy', + target: 'error', + }); + }); + }); +}); diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.ts b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.ts new file mode 100644 index 000000000..aea2c3cdd --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/status.ts @@ -0,0 +1,54 @@ +import { Workflow, WorkflowStatus } from '../types'; + +const GIT_ERROR_PATTERNS = [ + /clone/i, + /fetch/i, + /pull/i, + /repository/i, + /authentication/i, + /credential/i, + /ssh/i, + /unable to access/i, + /could not read/i, +]; + +const ARTIFACT_ERROR_PATTERNS = [ + /parse/i, + /yaml/i, + /stack config file/i, + /failed to get stack file/i, + /bind-mount/i, + /privileged mode/i, + /pid host/i, + /device mapping/i, + /sysctl/i, + /security-opt/i, + /container capabilities/i, +]; + +function classifyErrorPhase(msg: string): 'source' | 'artifact' | 'target' { + if (GIT_ERROR_PATTERNS.some((p) => p.test(msg))) return 'source'; + if (ARTIFACT_ERROR_PATTERNS.some((p) => p.test(msg))) return 'artifact'; + return 'target'; +} + +export function deriveSubRowStatuses(item: Workflow): { + source: WorkflowStatus; + artifact: WorkflowStatus; + target: WorkflowStatus; +} { + if (item.status === 'paused') { + return { source: 'healthy', artifact: 'healthy', target: 'paused' }; + } + if (item.status !== 'error') { + return { source: item.status, artifact: item.status, target: item.status }; + } + const phase = classifyErrorPhase(item.statusMessage ?? ''); + if (phase === 'source') { + return { source: 'error', artifact: 'unknown', target: 'unknown' }; + } + if (phase === 'artifact') { + return { source: 'healthy', artifact: 'error', target: 'unknown' }; + } + return { source: 'healthy', artifact: 'healthy', target: 'error' }; +} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx new file mode 100644 index 000000000..c12312623 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx @@ -0,0 +1,166 @@ +import { useMemo, useState } from 'react'; + +import { PageHeader } from '@@/PageHeader'; +import { StatusSummaryBar } from '@@/StatusSummaryBar/StatusSummaryBar'; +import { + SortableList, + SortableGroup, + SortOption, +} from '@@/SortableList/SortableList'; +import { useSortableListState } from '@@/SortableList/sortable-list.store'; + +import { useWorkflows } from '../queries/useWorkflows'; +import { useWorkflowsSummary } from '../queries/useWorkflowsSummary'; + +import { WorkflowCard } from './WorkflowCard'; +import { + DeploymentPlatform, + Workflow, + WorkflowStatus, + WorkflowType, +} from './types'; + +const STATUS_CONFIG: Array<{ + key: WorkflowStatus; + label: string; + color: 'error' | 'gray' | 'warning' | 'success'; +}> = [ + { key: 'error', label: 'Error', color: 'error' }, + { key: 'paused', label: 'Paused', color: 'gray' }, + { key: 'syncing', label: 'Syncing', color: 'warning' }, + { key: 'healthy', label: 'Healthy', color: 'success' }, + { key: 'unknown', label: 'Unknown', color: 'gray' }, +]; + +const SORT_OPTIONS: SortOption[] = [ + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status', grouped: true }, + { key: 'type', label: 'Type', grouped: true }, + { key: 'platform', label: 'Platform', grouped: true }, + { key: 'lastSyncDate', label: 'Last sync' }, +]; + +const GROUP_OPTIONS: Record> = { + status: STATUS_CONFIG, + type: [ + { key: 'stack', label: 'Stack' }, + { key: 'edgeStack', label: 'Edge Stack' }, + ], + platform: [ + { key: 'dockerStandalone', label: 'Docker Standalone' }, + { key: 'dockerSwarm', label: 'Docker Swarm' }, + { key: 'kubernetes', label: 'Kubernetes' }, + ], +}; + +const GROUP_FIELD: Record string> = { + status: (item) => item.status, + type: (item) => item.type, + platform: (item) => item.platform, +}; + +export function WorkflowsView() { + const [statusFilter, setStatusFilter] = useState(null); + const tableState = useSortableListState('workflows', 'name'); + const sortBy = tableState.sortBy?.id ?? 'name'; + + const workflowsQuery = useWorkflows({ + search: tableState.search || undefined, + sort: sortBy, + order: tableState.sortBy?.desc ? 'desc' : 'asc', + start: tableState.page * tableState.pageSize, + limit: tableState.pageSize, + status: statusFilter ?? groupFilter('status'), + type: groupFilter('type'), + platform: groupFilter('platform'), + }); + + const summaryQuery = useWorkflowsSummary(); + + const page = workflowsQuery.data?.data; + const totalCount = workflowsQuery.data?.totalCount ?? 0; + + const groups = useMemo(() => buildGroups(page, sortBy), [page, sortBy]); + + const statusSegments = STATUS_CONFIG.map((s) => ({ + ...s, + count: summaryQuery.data?.[s.key] ?? 0, + })); + + const groupOptions = useMemo( + () => ({ + ...GROUP_OPTIONS, + status: statusSegments, + }), + + [statusSegments] + ); + + const summaryTotal = summaryQuery.data + ? Object.values(summaryQuery.data).reduce((a, b) => a + b, 0) + : 0; + + return ( + <> + +
+ { + setStatusFilter(v); + tableState.setPage(0); + }} + radioGroupName="workflows-status" + /> + item.id} + showGroupHeaders + emptyMessage="No workflows found" + searchPlaceholder="Search" + renderItem={(item) => } + data-cy="workflows-list" + /> +
+ + ); + + function groupFilter(key: string) { + return sortBy === key ? (tableState.groupFilter as T | null) : undefined; + } +} + +function buildGroups( + items: Workflow[] | null = [], + sortBy: string +): SortableGroup[] { + if (!items) { + return []; + } + + const options = GROUP_OPTIONS[sortBy]; + + if (!options) { + return items.length > 0 ? [{ key: 'all', label: 'All', items }] : []; + } + + const getField = GROUP_FIELD[sortBy]; + return options + .map(({ key, label }) => ({ + key, + label, + items: items.filter((item) => getField(item) === key), + })) + .filter((g) => g.items.length > 0); +} diff --git a/app/react/portainer/gitops/WorkflowsView/types.ts b/app/react/portainer/gitops/WorkflowsView/types.ts new file mode 100644 index 000000000..481513dca --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/types.ts @@ -0,0 +1,33 @@ +import { RepoConfigResponse } from '@/react/portainer/gitops/types'; + +export type WorkflowStatus = + | 'healthy' + | 'error' + | 'syncing' + | 'paused' + | 'unknown'; +export type WorkflowType = 'stack' | 'edgeStack'; +export type DeploymentPlatform = + | 'dockerStandalone' + | 'dockerSwarm' + | 'kubernetes'; + +export interface WorkflowTarget { + endpointId?: number; + namespace?: string; + edgeGroupIds?: number[]; + groupStatus?: Record; +} + +export interface Workflow { + id: number; + name: string; + type: WorkflowType; + platform: DeploymentPlatform; + status: WorkflowStatus; + statusMessage?: string; + gitConfig?: RepoConfigResponse; + target: WorkflowTarget; + creationDate: number; + lastSyncDate: number; +} diff --git a/app/react/portainer/gitops/queries/query-keys.ts b/app/react/portainer/gitops/queries/query-keys.ts new file mode 100644 index 000000000..53156a97b --- /dev/null +++ b/app/react/portainer/gitops/queries/query-keys.ts @@ -0,0 +1,5 @@ +export const workflowQueryKeys = { + all: ['gitops', 'workflows'] as const, + list: (params: object) => [...workflowQueryKeys.all, 'list', params] as const, + summary: () => [...workflowQueryKeys.all, 'summary'] as const, +}; diff --git a/app/react/portainer/gitops/queries/useWorkflows.ts b/app/react/portainer/gitops/queries/useWorkflows.ts new file mode 100644 index 000000000..74a8f4331 --- /dev/null +++ b/app/react/portainer/gitops/queries/useWorkflows.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios from '@/portainer/services/axios/axios'; +import { withGlobalError } from '@/react-tools/react-query'; +import { withPaginationHeaders } from '@/react/common/api/pagination.types'; + +import { + DeploymentPlatform, + Workflow, + WorkflowStatus, + WorkflowType, +} from '../WorkflowsView/types'; + +import { workflowQueryKeys } from './query-keys'; + +export interface WorkflowsParams { + search?: string; + sort?: string; + order?: 'asc' | 'desc'; + start?: number; + limit?: number; + status?: WorkflowStatus | null; + type?: WorkflowType | null; + platform?: DeploymentPlatform | null; +} + +async function getWorkflows(params: WorkflowsParams) { + const response = await axios.get('/gitops/workflows', { + params, + }); + return withPaginationHeaders(response); +} + +export function useWorkflows(params: WorkflowsParams) { + return useQuery({ + queryKey: workflowQueryKeys.list(params), + queryFn: () => getWorkflows(params), + ...withGlobalError('Failed loading workflows'), + }); +} diff --git a/app/react/portainer/gitops/queries/useWorkflowsSummary.ts b/app/react/portainer/gitops/queries/useWorkflowsSummary.ts new file mode 100644 index 000000000..6bfc985fa --- /dev/null +++ b/app/react/portainer/gitops/queries/useWorkflowsSummary.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios from '@/portainer/services/axios/axios'; +import { withGlobalError } from '@/react-tools/react-query'; + +import { workflowQueryKeys } from './query-keys'; + +export interface WorkflowSummary { + healthy: number; + error: number; + syncing: number; + paused: number; + unknown: number; +} + +async function getWorkflowsSummary(): Promise { + const { data } = await axios.get( + '/gitops/workflows/summary' + ); + return data; +} + +export function useWorkflowsSummary() { + return useQuery({ + queryKey: workflowQueryKeys.summary(), + queryFn: getWorkflowsSummary, + ...withGlobalError('Failed loading workflow summary'), + }); +} diff --git a/app/react/sidebar/Sidebar.tsx b/app/react/sidebar/Sidebar.tsx index 05b8677a9..7cee81037 100644 --- a/app/react/sidebar/Sidebar.tsx +++ b/app/react/sidebar/Sidebar.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { Home } from 'lucide-react'; +import { GitBranch, Home } from 'lucide-react'; import { useIsEdgeAdmin, useIsPureAdmin } from '@/react/hooks/useUser'; import { useIsCurrentUserTeamLeader } from '@/portainer/users/queries'; @@ -67,6 +67,12 @@ function InnerSidebar() { label="Home" data-cy="portainerSidebar-home" /> + {isAdmin && }