refactor(gitops): support multiple artifacts per workflow [BE-13197] (#3156)

This commit is contained in:
Chaim Lev-Ari
2026-07-14 20:16:50 +03:00
committed by GitHub
parent 0e9d7e3b5e
commit 7edef01302
46 changed files with 977 additions and 740 deletions
+68 -39
View File
@@ -3461,27 +3461,35 @@ paths:
/gitops/workflows:
get:
description: >-
Returns a unified list of all stacks that have GitOps (GitConfig)
configured.
Returns a list of GitOps workflows, each with its aggregated status and
the artifacts it contains.
**Access policy**: authenticated
operationId: GitOpsWorkflowsList
parameters:
- description: Search term (matches name or repository URL)
- description: Search term (matches workflow name)
in: query
name: search
schema:
type: string
- description: "Sort field: name | type | status | creationDate | lastSyncDate"
- description: Sort field
in: query
name: sort
schema:
type: string
- description: "Sort order: asc or desc"
enum:
- name
- status
- creationDate
- lastSyncDate
- description: Sort order
in: query
name: order
schema:
type: string
enum:
- asc
- desc
- description: Pagination start index
in: query
name: start
@@ -3501,21 +3509,33 @@ paths:
type: array
items:
type: integer
- description: "Filter by status: healthy | syncing | error | paused | unknown"
- description: Filter by status
in: query
name: status
schema:
type: string
- description: "Filter by type: stack"
enum:
- healthy
- syncing
- error
- paused
- unknown
- description: Keep workflows that have at least one artifact of this type
in: query
name: type
schema:
type: string
- description: "Filter by platform: dockerStandalone | dockerSwarm | kubernetes"
enum:
- stack
- description: Keep workflows that have at least one artifact on this platform
in: query
name: platform
schema:
type: string
enum:
- dockerStandalone
- dockerSwarm
- kubernetes
responses:
"200":
description: OK
@@ -3525,6 +3545,8 @@ paths:
items:
$ref: "#/components/schemas/workflows.Workflow"
type: array
"400":
description: Invalid request
"500":
description: Server error
security:
@@ -3556,7 +3578,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/workflows.WorkflowDetail"
$ref: "#/components/schemas/workflows.Workflow"
"400":
description: Invalid request
"404":
@@ -16715,7 +16737,7 @@ components:
type: integer
workflows:
items:
$ref: "#/components/schemas/workflows.Workflow"
$ref: "#/components/schemas/workflows.SourceWorkflow"
type: array
required:
- connection
@@ -21361,6 +21383,38 @@ components:
- DeploymentPlatformDockerStandalone
- DeploymentPlatformDockerSwarm
- DeploymentPlatformKubernetes
workflows.SourceWorkflow:
properties:
autoUpdate:
$ref: "#/components/schemas/portainer.AutoUpdateSettings"
creationDate:
type: integer
gitConfig:
$ref: "#/components/schemas/gittypes.RepoConfig"
id:
type: integer
lastSyncDate:
type: integer
name:
type: string
platform:
$ref: "#/components/schemas/workflows.DeploymentPlatform"
sourceId:
type: integer
status:
$ref: "#/components/schemas/workflows.WorkflowStatusObject"
target:
$ref: "#/components/schemas/workflows.Target"
type:
$ref: "#/components/schemas/workflows.Type"
required:
- id
- name
- platform
- status
- target
- type
type: object
workflows.Status:
enum:
- healthy
@@ -21417,49 +21471,24 @@ components:
- TypeEdgeStack
workflows.Workflow:
properties:
autoUpdate:
$ref: "#/components/schemas/portainer.AutoUpdateSettings"
artifacts:
items:
$ref: "#/components/schemas/workflows.ArtifactDetail"
type: array
creationDate:
type: integer
gitConfig:
$ref: "#/components/schemas/gittypes.RepoConfig"
id:
type: integer
lastSyncDate:
type: integer
name:
type: string
platform:
$ref: "#/components/schemas/workflows.DeploymentPlatform"
sourceId:
type: integer
status:
$ref: "#/components/schemas/workflows.WorkflowStatusObject"
target:
$ref: "#/components/schemas/workflows.Target"
type:
$ref: "#/components/schemas/workflows.Type"
required:
- id
- name
- platform
- status
- target
- type
type: object
workflows.WorkflowDetail:
properties:
artifacts:
items:
$ref: "#/components/schemas/workflows.ArtifactDetail"
type: array
id:
type: integer
name:
type: string
required:
- id
- name
type: object
workflows.WorkflowPhaseStatus:
properties:
+67 -38
View File
@@ -5767,7 +5767,7 @@ definitions:
type: integer
workflows:
items:
$ref: '#/definitions/workflows.Workflow'
$ref: '#/definitions/workflows.SourceWorkflow'
type: array
required:
- connection
@@ -9594,6 +9594,38 @@ definitions:
- DeploymentPlatformDockerStandalone
- DeploymentPlatformDockerSwarm
- DeploymentPlatformKubernetes
workflows.SourceWorkflow:
properties:
autoUpdate:
$ref: '#/definitions/portainer.AutoUpdateSettings'
creationDate:
type: integer
gitConfig:
$ref: '#/definitions/gittypes.RepoConfig'
id:
type: integer
lastSyncDate:
type: integer
name:
type: string
platform:
$ref: '#/definitions/workflows.DeploymentPlatform'
sourceId:
type: integer
status:
$ref: '#/definitions/workflows.WorkflowStatusObject'
target:
$ref: '#/definitions/workflows.Target'
type:
$ref: '#/definitions/workflows.Type'
required:
- id
- name
- platform
- status
- target
- type
type: object
workflows.Status:
enum:
- healthy
@@ -9650,49 +9682,24 @@ definitions:
- TypeEdgeStack
workflows.Workflow:
properties:
autoUpdate:
$ref: '#/definitions/portainer.AutoUpdateSettings'
artifacts:
items:
$ref: '#/definitions/workflows.ArtifactDetail'
type: array
creationDate:
type: integer
gitConfig:
$ref: '#/definitions/gittypes.RepoConfig'
id:
type: integer
lastSyncDate:
type: integer
name:
type: string
platform:
$ref: '#/definitions/workflows.DeploymentPlatform'
sourceId:
type: integer
status:
$ref: '#/definitions/workflows.WorkflowStatusObject'
target:
$ref: '#/definitions/workflows.Target'
type:
$ref: '#/definitions/workflows.Type'
required:
- id
- name
- platform
- status
- target
- type
type: object
workflows.WorkflowDetail:
properties:
artifacts:
items:
$ref: '#/definitions/workflows.ArtifactDetail'
type: array
id:
type: integer
name:
type: string
required:
- id
- name
type: object
workflows.WorkflowPhaseStatus:
properties:
@@ -13029,19 +13036,27 @@ paths:
/gitops/workflows:
get:
description: |-
Returns a unified list of all stacks that have GitOps (GitConfig) configured.
Returns a list of GitOps workflows, each with its aggregated status and the artifacts it contains.
**Access policy**: authenticated
operationId: GitOpsWorkflowsList
parameters:
- description: Search term (matches name or repository URL)
- description: Search term (matches workflow name)
in: query
name: search
type: string
- description: 'Sort field: name | type | status | creationDate | lastSyncDate'
- description: Sort field
enum:
- name
- status
- creationDate
- lastSyncDate
in: query
name: sort
type: string
- description: 'Sort order: asc or desc'
- description: Sort order
enum:
- asc
- desc
in: query
name: order
type: string
@@ -13060,15 +13075,27 @@ paths:
type: integer
name: endpointIds
type: array
- description: 'Filter by status: healthy | syncing | error | paused | unknown'
- description: Filter by status
enum:
- healthy
- syncing
- error
- paused
- unknown
in: query
name: status
type: string
- description: 'Filter by type: stack'
- description: Keep workflows that have at least one artifact of this type
enum:
- stack
in: query
name: type
type: string
- description: 'Filter by platform: dockerStandalone | dockerSwarm | kubernetes'
- description: Keep workflows that have at least one artifact on this platform
enum:
- dockerStandalone
- dockerSwarm
- kubernetes
in: query
name: platform
type: string
@@ -13081,6 +13108,8 @@ paths:
items:
$ref: '#/definitions/workflows.Workflow'
type: array
"400":
description: Invalid request
"500":
description: Server error
security:
@@ -13108,7 +13137,7 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/workflows.WorkflowDetail'
$ref: '#/definitions/workflows.Workflow'
"400":
description: Invalid request
"404":
+86 -71
View File
@@ -1,31 +1,95 @@
package workflows
import (
"slices"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/source"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
)
// FetchWorkflows returns all GitOps workflows visible to the given user.
// FetchWorkflows returns all GitOps workflows visible to the given user
func FetchWorkflows(
tx dataservices.DataStoreTx,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
endpointIDSet set.Set[portainer.EndpointID],
) ([]Workflow, error) {
gitConfigs := map[portainer.StackID]*gittypes.RepoConfig{}
sourceIDs := map[portainer.StackID]portainer.SourceID{}
sourcePhases := map[portainer.StackID]WorkflowPhaseStatus{}
artifactPhases := map[portainer.StackID]WorkflowPhaseStatus{}
userContext := source.NewUserContext(sc.User, sc.UserMemberships)
allWorkflows, err := tx.Workflow().ReadAll()
if err != nil {
return nil, err
}
stackIDSet := make(set.Set[portainer.StackID])
sourceIDSet := make(set.Set[portainer.SourceID])
for _, wf := range allWorkflows {
for _, a := range wf.Artifacts {
if a.StackID != 0 {
stackIDSet.Add(a.StackID)
}
for _, f := range a.Files {
sourceIDSet.Add(f.SourceID)
}
}
}
stackMap, err := loadAccessibleStackMap(tx, k8sFactory, sc, stackIDSet, endpointIDSet)
if err != nil {
return nil, err
}
sourceMap, err := LoadSourceMap(tx, userContext, sourceIDSet)
if err != nil {
return nil, err
}
items := make([]Workflow, 0, len(allWorkflows))
for _, wf := range allWorkflows {
artifacts := make([]ArtifactDetail, 0, len(wf.Artifacts))
for _, a := range wf.Artifacts {
if a.StackID == 0 {
continue // edge-stack artifacts are resolved by the EE implementation
}
stack, ok := stackMap[a.StackID]
if !ok {
continue // filtered out by access control or endpoint scope
}
if stack.Type == portainer.KubernetesStack && !HasAccessibleSource(a.Files, sourceMap) {
continue
}
sourcePhase, artifactPhase := ArtifactPhases(a.Files, sourceMap)
artifacts = append(artifacts, MapStackToArtifactDetail(stack, a.Files, sourcePhase, artifactPhase))
}
if ShouldHideWorkflow(wf, artifacts, endpointIDSet) {
continue
}
items = append(items, BuildWorkflow(wf, artifacts))
}
return items, nil
}
// loadAccessibleStackMap batch-loads the stacks referenced by workflows, applies the same endpoint
// scope, Docker UAC, and Kubernetes namespace RBAC filtering as the detail path, and returns the
// accessible stacks keyed by ID.
func loadAccessibleStackMap(
tx dataservices.DataStoreTx,
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
stackIDSet set.Set[portainer.StackID],
endpointIDSet set.Set[portainer.EndpointID],
) (map[portainer.StackID]portainer.Stack, error) {
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.WorkflowID != 0 && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
return stackIDSet.Contains(s.ID) && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
})
if err != nil {
return nil, err
@@ -41,77 +105,28 @@ func FetchWorkflows(
return nil, err
}
// First pass: filter by endpoint/stack-type match and collect workflow IDs.
preFiltered := make([]portainer.Stack, 0, len(stacks))
workflowIDSet := make(set.Set[portainer.WorkflowID], len(stacks))
for _, stack := range stacks {
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
continue
}
preFiltered = append(preFiltered, stack)
workflowIDSet.Add(stack.WorkflowID)
}
workflowMap, sourceMap, err := LoadWorkflowAndSourceMaps(tx, userContext, workflowIDSet)
if err != nil {
return nil, err
}
// Second pass: build filtered list using in-memory lookups.
var filtered []portainer.Stack
for _, stack := range preFiltered {
wf := workflowMap[stack.WorkflowID]
hasAccessibleSource := false
outer:
for _, as := range wf.Artifacts {
if as.StackID != stack.ID {
continue
}
for _, f := range as.Files {
src, ok := sourceMap[f.SourceID]
if !ok {
continue
}
hasAccessibleSource = true
if src.Type == portainer.SourceTypeGit {
gitConfigs[stack.ID] = MergeSourceAndFile(&src, &f)
sourceIDs[stack.ID] = src.ID
sourcePhases[stack.ID] = SourceStatusToPhase(f.RefStatus, f.RefError)
artifactPhases[stack.ID] = SourceStatusToPhase(f.PathStatus, f.PathError)
break outer
}
}
}
if stack.Type == portainer.KubernetesStack && !hasAccessibleSource {
continue
}
filtered = append(filtered, stack)
}
stacks = filtered
accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap)
if err != nil {
return nil, err
}
stacks, err = filterK8SStacks(stacks, endpointMap, k8sFactory, accessMap)
if err != nil {
return nil, err
}
items := make([]Workflow, 0, len(stacks))
result := make(map[portainer.StackID]portainer.Stack, len(stacks))
for _, stack := range stacks {
gitConfig := gitConfigs[stack.ID]
items = append(items, MapStackToWorkflow(stack, sourceIDs[stack.ID], gitConfig, sourcePhases[stack.ID], artifactPhases[stack.ID]))
if ep, ok := endpointMap[stack.EndpointID]; ok && !EndpointMatchesStackType(ep, stack.Type) {
continue
}
if stack.Type == portainer.KubernetesStack {
access := accessMap[stack.EndpointID]
if !access.IsKubeAdmin && !slices.Contains(access.NonAdminNamespaces, stack.Namespace) {
continue
}
}
result[stack.ID] = stack
}
return items, nil
return result, nil
}
// SourceStats holds aggregated statistics for a GitOps source.
+104 -1
View File
@@ -30,7 +30,7 @@ func mustCreateGitWorkflow(t *testing.T, tx dataservices.DataStoreTx, stack *por
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: cfg.URL, Authentication: cfg.Authentication, TLSSkipVerify: cfg.TLSSkipVerify}}
require.NoError(t, tx.Source().Create(adminUserContext, src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
wf := &portainer.Workflow{Name: stack.Name, Artifacts: []portainer.Artifact{{
StackID: stack.ID,
Files: []portainer.ArtifactFile{{SourceID: src.ID}},
}}}
@@ -187,6 +187,109 @@ func TestFetchWorkflows_NilEndpointSetReturnsAll(t *testing.T) {
require.Len(t, items, 3)
}
func TestFetchWorkflows_GroupsMultipleArtifactsUnderOneWorkflow(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
src := &portainer.Source{Type: portainer.SourceTypeGit, Git: &gittypes.GitSource{URL: "https://github.com/x/repo"}}
require.NoError(t, tx.Source().Create(adminUserContext, src))
wf := &portainer.Workflow{Name: "multi", Artifacts: []portainer.Artifact{
{StackID: 1, Files: []portainer.ArtifactFile{{SourceID: src.ID}}},
{StackID: 2, Files: []portainer.ArtifactFile{{SourceID: src.ID}}},
}}
require.NoError(t, tx.Workflow().Create(wf))
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 1, Name: "stack-a", WorkflowID: wf.ID}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "stack-b", WorkflowID: wf.ID}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
return err
}))
require.Len(t, items, 1)
require.Equal(t, "multi", items[0].Name)
require.Len(t, items[0].Artifacts, 2)
names := []string{items[0].Artifacts[0].Name, items[0].Artifacts[1].Name}
require.Contains(t, names, "stack-a")
require.Contains(t, names, "stack-b")
}
func TestFetchWorkflows_ShowsWorkflowWithNoArtifacts(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.Workflow().Create(&portainer.Workflow{Name: "empty"}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
return err
}))
require.Len(t, items, 1)
require.Equal(t, "empty", items[0].Name)
require.Empty(t, items[0].Artifacts)
}
func TestFetchWorkflows_HidesWorkflowWhenAllArtifactsFiltered(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// A workflow whose only artifact references a stack that does not exist:
// the artifact is filtered out, so the workflow must be hidden.
wf := &portainer.Workflow{Name: "dangling", Artifacts: []portainer.Artifact{
{StackID: 999, Files: []portainer.ArtifactFile{{SourceID: 1}}},
}}
require.NoError(t, tx.Workflow().Create(wf))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(tx, nil, adminContext(), nil)
return err
}))
require.Empty(t, items)
}
func TestFetchWorkflows_HidesEmptyWorkflowWhenEndpointFilterActive(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.Workflow().Create(&portainer.Workflow{Name: "empty"}))
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var items []Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
items, err = FetchWorkflows(tx, nil, adminContext(), set.ToSet([]portainer.EndpointID{1}))
return err
}))
require.Empty(t, items)
}
func TestFetchSourceStats_ReturnsAllSources(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
+9
View File
@@ -20,6 +20,15 @@ import (
"github.com/rs/zerolog/log"
)
// HasAccessibleSource reports whether any of the artifact's files has a source visible in
// sourceMap.
func HasAccessibleSource(files []portainer.ArtifactFile, sourceMap map[portainer.SourceID]portainer.Source) bool {
return slicesx.Some(files, func(f portainer.ArtifactFile) bool {
_, ok := sourceMap[f.SourceID]
return ok
})
}
// EndpointMatchesStackType reports whether ep is a valid target for stackType.
func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
switch stackType {
+54 -6
View File
@@ -30,11 +30,11 @@ func BuildGroupEndpoints(tx dataservices.DataStoreTx, groups []portainer.EdgeGro
return m, nil
}
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
// MapStackToSourceWorkflow converts a stack to a SourceWorkflow. gitConfig is passed separately
// because EE embeds a different GitConfig type that shadows the CE field.
// source and artifact are the pre-computed git phase statuses from the caller.
func MapStackToWorkflow(s portainer.Stack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
return Workflow{
func MapStackToSourceWorkflow(s portainer.Stack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) SourceWorkflow {
return SourceWorkflow{
ID: s.WorkflowID,
Name: s.Name,
Type: TypeStack,
@@ -56,15 +56,15 @@ func MapStackToWorkflow(s portainer.Stack, sourceID portainer.SourceID, gitConfi
}
}
// MapEdgeStackToWorkflow converts an edge stack to a Workflow. gitConfig is passed separately
// MapEdgeStackToSourceWorkflow converts an edge stack to a SourceWorkflow. gitConfig is passed separately
// because EE embeds a different GitConfig type that shadows the CE field.
// source and artifact are the pre-computed git phase statuses from the caller.
func MapEdgeStackToWorkflow(wfID portainer.WorkflowID, es portainer.EdgeStack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow {
func MapEdgeStackToSourceWorkflow(wfID portainer.WorkflowID, es portainer.EdgeStack, sourceID portainer.SourceID, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) SourceWorkflow {
platform := DeploymentPlatformDockerStandalone
if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
platform = DeploymentPlatformKubernetes
}
return Workflow{
return SourceWorkflow{
ID: wfID,
Name: es.Name,
Type: TypeEdgeStack,
@@ -227,6 +227,54 @@ func edgeStackTargetStatuses(
return result
}
// BuildWorkflow assembles a Workflow from a domain workflow and its resolved, access-filtered
// artifacts, aggregating the workflow-level status and dates across those artifacts.
func BuildWorkflow(wf portainer.Workflow, artifacts []ArtifactDetail) Workflow {
creation, lastSync := SummaryDates(artifacts)
return Workflow{
ID: wf.ID,
Name: workflowName(wf),
Status: aggregateWorkflowStatus(artifacts),
Artifacts: artifacts,
CreationDate: creation,
LastSyncDate: lastSync,
}
}
// workflowName returns the workflow's stored name, falling back to a placeholder when it has
// none (e.g. a Kubernetes stack deployed without a stack name).
func workflowName(wf portainer.Workflow) string {
if wf.Name != "" {
return wf.Name
}
return "Unnamed workflow"
}
// ShouldHideWorkflow reports whether a workflow must be hidden from the list: it had configured
// artifacts but every one was filtered out (existence-hiding, mirroring the detail endpoint), or it
// has no artifacts while an endpoint filter is active (an empty workflow cannot match an endpoint).
func ShouldHideWorkflow(wf portainer.Workflow, artifacts []ArtifactDetail, endpointIDSet set.Set[portainer.EndpointID]) bool {
if len(artifacts) > 0 {
return false
}
return len(wf.Artifacts) > 0 || len(endpointIDSet) > 0
}
// SummaryDates derives workflow-level dates from its artifacts: the earliest artifact creation
// date and the most recent artifact sync date. Zero values are ignored.
func SummaryDates(artifacts []ArtifactDetail) (creation, lastSync int64) {
for _, a := range artifacts {
if a.CreationDate != 0 && (creation == 0 || a.CreationDate < creation) {
creation = a.CreationDate
}
if a.LastSyncDate > lastSync {
lastSync = a.LastSyncDate
}
}
return creation, lastSync
}
func mapFilesToFileDetails(files []portainer.ArtifactFile) []ArtifactFileDetail {
return slicesx.Map(files, func(file portainer.ArtifactFile) ArtifactFileDetail {
return ArtifactFileDetail{
+24 -6
View File
@@ -151,7 +151,7 @@ func TestEdgeStackTargetStatuses(t *testing.T) {
})
}
func TestMapEdgeStackToWorkflow_DockerPlatform(t *testing.T) {
func TestMapEdgeStackToSourceWorkflow_DockerPlatform(t *testing.T) {
t.Parallel()
es := portainer.EdgeStack{
@@ -163,7 +163,7 @@ func TestMapEdgeStackToWorkflow_DockerPlatform(t *testing.T) {
}
cfg := &gittypes.RepoConfig{URL: "https://github.com/x/repo"}
w := MapEdgeStackToWorkflow(2, es, 7, cfg, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{1: {10}}, WorkflowPhaseStatus{Status: StatusHealthy}, WorkflowPhaseStatus{Status: StatusHealthy})
w := MapEdgeStackToSourceWorkflow(2, es, 7, cfg, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{1: {10}}, WorkflowPhaseStatus{Status: StatusHealthy}, WorkflowPhaseStatus{Status: StatusHealthy})
require.Equal(t, portainer.WorkflowID(2), w.ID)
require.Equal(t, es.Name, w.Name)
@@ -175,7 +175,7 @@ func TestMapEdgeStackToWorkflow_DockerPlatform(t *testing.T) {
require.Equal(t, []portainer.EdgeGroupID{1}, w.Target.EdgeGroupIDs)
}
func TestMapEdgeStackToWorkflow_KubernetesPlatform(t *testing.T) {
func TestMapEdgeStackToSourceWorkflow_KubernetesPlatform(t *testing.T) {
t.Parallel()
es := portainer.EdgeStack{
@@ -185,12 +185,12 @@ func TestMapEdgeStackToWorkflow_KubernetesPlatform(t *testing.T) {
EdgeGroups: []portainer.EdgeGroupID{1},
}
w := MapEdgeStackToWorkflow(1, es, 0, nil, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{}, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
w := MapEdgeStackToSourceWorkflow(1, es, 0, nil, nil, map[portainer.EdgeGroupID][]portainer.EndpointID{}, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
require.Equal(t, DeploymentPlatformKubernetes, w.Platform)
}
func TestMapEdgeStackToWorkflow_GroupStatusesAndResolvedEndpoints(t *testing.T) {
func TestMapEdgeStackToSourceWorkflow_GroupStatusesAndResolvedEndpoints(t *testing.T) {
t.Parallel()
statuses := []portainer.EdgeStackStatusForEnv{
@@ -207,13 +207,31 @@ func TestMapEdgeStackToWorkflow_GroupStatusesAndResolvedEndpoints(t *testing.T)
EdgeGroups: []portainer.EdgeGroupID{1, 2},
}
w := MapEdgeStackToWorkflow(5, es, 0, nil, statuses, groupEndpoints, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
w := MapEdgeStackToSourceWorkflow(5, es, 0, nil, statuses, groupEndpoints, WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown})
require.Equal(t, StatusHealthy, w.Target.GroupStatus[1])
require.Equal(t, StatusError, w.Target.GroupStatus[2])
require.Len(t, w.Target.ResolvedEndpointIDs, 2)
}
func TestBuildWorkflow_NameFallback(t *testing.T) {
t.Parallel()
t.Run("uses workflow name when set", func(t *testing.T) {
t.Parallel()
wf := portainer.Workflow{ID: 1, Name: "my-stack"}
result := BuildWorkflow(wf, nil)
require.Equal(t, "my-stack", result.Name)
})
t.Run("falls back to placeholder when workflow name is empty", func(t *testing.T) {
t.Parallel()
wf := portainer.Workflow{ID: 1}
result := BuildWorkflow(wf, nil)
require.Equal(t, "Unnamed workflow", result.Name)
})
}
func TestPlatformFromStackType(t *testing.T) {
t.Parallel()
+3 -29
View File
@@ -347,32 +347,6 @@ func LoadWorkflowMap(tx gitSourceStore, ids set.Set[portainer.WorkflowID]) (map[
return result, nil
}
// LoadWorkflowAndSourceMaps fetches workflows by their IDs and the sources they reference,
// collecting source IDs in a single pass over the workflows.
func LoadWorkflowAndSourceMaps(tx gitSourceStore, userContext source.UserContext, ids set.Set[portainer.WorkflowID]) (map[portainer.WorkflowID]portainer.Workflow, map[portainer.SourceID]portainer.Source, error) {
wfMap := make(map[portainer.WorkflowID]portainer.Workflow, len(ids))
sourceIDs := make(set.Set[portainer.SourceID])
for id := range ids {
wf, err := tx.Workflow().Read(id)
if err != nil {
return nil, nil, err
}
wfMap[id] = *wf
for _, as := range wf.Artifacts {
for _, f := range as.Files {
sourceIDs.Add(f.SourceID)
}
}
}
srcMap, err := loadSourceMap(tx, userContext, sourceIDs)
if err != nil {
return nil, nil, err
}
return wfMap, srcMap, nil
}
// LoadWorkflowSources collects all unique SourceIDs referenced by wf and returns them as a map.
// This avoids reading the same Source record more than once when files share a SourceID.
func LoadWorkflowSources(tx gitSourceStore, userContext source.UserContext, wf *portainer.Workflow) (map[portainer.SourceID]portainer.Source, error) {
@@ -383,11 +357,11 @@ func LoadWorkflowSources(tx gitSourceStore, userContext source.UserContext, wf *
}
}
return loadSourceMap(tx, userContext, ids)
return LoadSourceMap(tx, userContext, ids)
}
// loadSourceMap fetches sources by their IDs and returns them keyed by ID.
func loadSourceMap(tx gitSourceStore, userContext source.UserContext, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) {
// LoadSourceMap fetches sources by their IDs and returns them keyed by ID.
func LoadSourceMap(tx gitSourceStore, userContext source.UserContext, ids set.Set[portainer.SourceID]) (map[portainer.SourceID]portainer.Source, error) {
sources, err := tx.Source().ReadAll(userContext, func(s portainer.Source) bool {
return ids.Contains(s.ID)
})
+37 -9
View File
@@ -114,19 +114,47 @@ func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, s
}
}
// EffectiveStatus returns the highest-priority status across all three phases of a workflow.
func EffectiveStatus(w Workflow) Status {
s := w.Status.Target.Status
if statusPriority(w.Status.Source.Status) > statusPriority(s) {
s = w.Status.Source.Status
// effectiveStatusOf returns the highest-priority status across the three phases of a status object.
func effectiveStatusOf(s WorkflowStatusObject) Status {
effective := s.Target.Status
if statusPriority(s.Source.Status) > statusPriority(effective) {
effective = s.Source.Status
}
if statusPriority(w.Status.Artifact.Status) > statusPriority(s) {
s = w.Status.Artifact.Status
if statusPriority(s.Artifact.Status) > statusPriority(effective) {
effective = s.Artifact.Status
}
return s
return effective
}
// CountByStatus counts workflows per effective status and returns a StatusSummary.
// EffectiveStatus returns the highest-priority status across the three aggregate phases of a
// workflow.
func EffectiveStatus(w Workflow) Status {
return effectiveStatusOf(w.Status)
}
// aggregateWorkflowStatus folds each phase (source, artifact, target) worst-wins across all of a
// workflow's artifacts. A workflow with no artifacts yields an all-unknown status object.
func aggregateWorkflowStatus(artifacts []ArtifactDetail) WorkflowStatusObject {
agg := WorkflowStatusObject{
Source: WorkflowPhaseStatus{Status: StatusUnknown},
Artifact: WorkflowPhaseStatus{Status: StatusUnknown},
Target: WorkflowPhaseStatus{Status: StatusUnknown},
}
for _, a := range artifacts {
if statusPriority(a.Status.Source.Status) > statusPriority(agg.Source.Status) {
agg.Source = a.Status.Source
}
if statusPriority(a.Status.Artifact.Status) > statusPriority(agg.Artifact.Status) {
agg.Artifact = a.Status.Artifact
}
if statusPriority(a.Status.Target.Status) > statusPriority(agg.Target.Status) {
agg.Target = a.Status.Target
}
}
return agg
}
// CountByStatus counts workflows per effective aggregate status.
func CountByStatus(workflows []Workflow) StatusSummary {
var s StatusSummary
for _, w := range workflows {
+41 -63
View File
@@ -7,11 +7,11 @@ import (
"github.com/stretchr/testify/assert"
)
func TestEffectiveStatus(t *testing.T) {
func TestAggregateWorkflowStatus(t *testing.T) {
t.Parallel()
makeWorkflow := func(source, artifact, target Status) Workflow {
return Workflow{
artifact := func(source, artifact, target Status) ArtifactDetail {
return ArtifactDetail{
Status: WorkflowStatusObject{
Source: WorkflowPhaseStatus{Status: source},
Artifact: WorkflowPhaseStatus{Status: artifact},
@@ -20,78 +20,56 @@ func TestEffectiveStatus(t *testing.T) {
}
}
cases := []struct {
name string
w Workflow
want Status
}{
{"all healthy", makeWorkflow(StatusHealthy, StatusHealthy, StatusHealthy), StatusHealthy},
{"all unknown", makeWorkflow(StatusUnknown, StatusUnknown, StatusUnknown), StatusUnknown},
{"source error wins over syncing target", makeWorkflow(StatusError, StatusSyncing, StatusHealthy), StatusError},
{"artifact error wins over syncing target", makeWorkflow(StatusHealthy, StatusError, StatusSyncing), StatusError},
{"target error wins over healthy phases", makeWorkflow(StatusHealthy, StatusHealthy, StatusError), StatusError},
{"syncing beats paused and healthy", makeWorkflow(StatusPaused, StatusSyncing, StatusHealthy), StatusSyncing},
{"paused beats healthy", makeWorkflow(StatusHealthy, StatusPaused, StatusHealthy), StatusPaused},
{"healthy beats unknown", makeWorkflow(StatusUnknown, StatusHealthy, StatusUnknown), StatusHealthy},
}
t.Run("no artifacts yields unknown phases", func(t *testing.T) {
t.Parallel()
agg := aggregateWorkflowStatus(nil)
assert.Equal(t, StatusUnknown, effectiveStatusOf(agg))
})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, EffectiveStatus(tc.w))
t.Run("each phase takes the worst across artifacts", func(t *testing.T) {
t.Parallel()
agg := aggregateWorkflowStatus([]ArtifactDetail{
artifact(StatusHealthy, StatusSyncing, StatusHealthy),
artifact(StatusError, StatusHealthy, StatusPaused),
})
}
assert.Equal(t, StatusError, agg.Source.Status)
assert.Equal(t, StatusSyncing, agg.Artifact.Status)
assert.Equal(t, StatusPaused, agg.Target.Status)
})
}
func TestCountByStatus(t *testing.T) {
func TestCountSummaryByStatus(t *testing.T) {
t.Parallel()
makeW := func(s Status) Workflow {
return Workflow{
Status: WorkflowStatusObject{
Source: WorkflowPhaseStatus{Status: s},
Artifact: WorkflowPhaseStatus{Status: s},
Target: WorkflowPhaseStatus{Status: s},
},
}
summary := func(status Status) Workflow {
return Workflow{Status: WorkflowStatusObject{
Source: WorkflowPhaseStatus{Status: status},
Artifact: WorkflowPhaseStatus{Status: status},
Target: WorkflowPhaseStatus{Status: status},
}}
}
t.Run("empty list", func(t *testing.T) {
t.Parallel()
assert.Equal(t, StatusSummary{}, CountByStatus(nil))
got := CountByStatus([]Workflow{
summary(StatusHealthy),
summary(StatusHealthy),
summary(StatusError),
summary(StatusUnknown),
})
t.Run("single healthy", func(t *testing.T) {
t.Parallel()
assert.Equal(t, StatusSummary{Healthy: 1}, CountByStatus([]Workflow{makeW(StatusHealthy)}))
})
assert.Equal(t, StatusSummary{Healthy: 2, Error: 1, Unknown: 1}, got)
}
t.Run("mixed statuses", func(t *testing.T) {
t.Parallel()
workflows := []Workflow{
makeW(StatusHealthy),
makeW(StatusError),
makeW(StatusSyncing),
makeW(StatusPaused),
makeW(StatusUnknown),
makeW(StatusError),
}
assert.Equal(t, StatusSummary{Healthy: 1, Error: 2, Syncing: 1, Paused: 1, Unknown: 1}, CountByStatus(workflows))
})
func TestCountSummaryByStatus_NonTargetPhaseOverride(t *testing.T) {
t.Parallel()
t.Run("error phase overrides healthy target", func(t *testing.T) {
t.Parallel()
w := Workflow{
Status: WorkflowStatusObject{
Source: WorkflowPhaseStatus{Status: StatusError},
Artifact: WorkflowPhaseStatus{Status: StatusUnknown},
Target: WorkflowPhaseStatus{Status: StatusHealthy},
},
}
s := CountByStatus([]Workflow{w})
assert.Equal(t, 1, s.Error)
assert.Equal(t, 0, s.Healthy)
})
overridden := Workflow{Status: WorkflowStatusObject{
Source: WorkflowPhaseStatus{Status: StatusError},
Target: WorkflowPhaseStatus{Status: StatusHealthy},
}}
got := CountByStatus([]Workflow{overridden})
assert.Equal(t, StatusSummary{Error: 1}, got)
}
func TestDeriveEdgeStackTargetState(t *testing.T) {
+12 -1
View File
@@ -79,7 +79,8 @@ type WorkflowStatusObject struct {
Target WorkflowPhaseStatus `json:"target"`
}
type Workflow struct {
// SourceWorkflow is the per-stack/edge-stack workflow shape embedded in a source's detail response.
type SourceWorkflow struct {
ID portainer.WorkflowID `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Type Type `json:"type" validate:"required"`
@@ -93,6 +94,16 @@ type Workflow struct {
LastSyncDate int64 `json:"lastSyncDate"`
}
// Workflow is the API representation of a workflow, used by both the list and detail endpoints.
type Workflow struct {
ID portainer.WorkflowID `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Status WorkflowStatusObject `json:"status" validate:"required"`
Artifacts []ArtifactDetail `json:"artifacts"`
CreationDate int64 `json:"creationDate"`
LastSyncDate int64 `json:"lastSyncDate"`
}
type StatusSummary struct {
Healthy int `json:"healthy"`
Syncing int `json:"syncing"`
+3 -3
View File
@@ -11,7 +11,7 @@ import (
)
// FetchSourceWorkflows returns the workflows and stats for a single source.
func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([]ce.Workflow, ce.SourceStats, error) {
func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([]ce.SourceWorkflow, ce.SourceStats, error) {
wfs, err := tx.Workflow().ReadAll(func(wf portainer.Workflow) bool {
return slices.ContainsFunc(wf.Artifacts, func(artifact portainer.Artifact) bool {
return slices.ContainsFunc(artifact.Files, func(f portainer.ArtifactFile) bool {
@@ -56,7 +56,7 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
}
unknown := ce.WorkflowPhaseStatus{Status: ce.StatusUnknown}
items := make([]ce.Workflow, 0, len(stacks))
items := make([]ce.SourceWorkflow, 0, len(stacks))
stats := ce.SourceStats{EndpointIDs: set.Set[portainer.EndpointID]{}}
for _, stack := range stacks {
@@ -66,7 +66,7 @@ func FetchSourceWorkflows(tx dataservices.DataStoreTx, src *portainer.Source) ([
cfg.ConfigFilePath = file.Path
cfg.ConfigHash = file.Hash
}
items = append(items, ce.MapStackToWorkflow(stack, src.ID, cfg, unknown, unknown))
items = append(items, ce.MapStackToSourceWorkflow(stack, src.ID, cfg, unknown, unknown))
stats.WorkflowCount++
if stack.EndpointID != 0 {
stats.EndpointIDs.Add(stack.EndpointID)
+6 -6
View File
@@ -38,10 +38,10 @@ type SourceAccess struct {
// SourceDetail extends Source with connection settings and linked workflows.
type SourceDetail struct {
Source
Connection connectionInfo `json:"connection" validate:"required"`
AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []workflows.Workflow `json:"workflows"`
Access SourceAccess `json:"access"`
Connection connectionInfo `json:"connection" validate:"required"`
AutoUpdate *AutoUpdateInfo `json:"autoUpdate,omitempty"`
Workflows []workflows.SourceWorkflow `json:"workflows"`
Access SourceAccess `json:"access"`
}
// @id GitOpsSourceGet
@@ -73,7 +73,7 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
sourceID := portainer.SourceID(srcID)
var source *portainer.Source
var sourceWfs []workflows.Workflow
var sourceWfs []workflows.SourceWorkflow
var stats workflows.SourceStats
err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
@@ -102,7 +102,7 @@ func (h *Handler) getSource(w http.ResponseWriter, r *http.Request) *httperror.H
return response.JSON(w, detail)
}
func BuildSourceDetail(baseSource Source, cfg *gittypes.GitSource, sourceWfs []workflows.Workflow, access SourceAccess) SourceDetail {
func BuildSourceDetail(baseSource Source, cfg *gittypes.GitSource, sourceWfs []workflows.SourceWorkflow, access SourceAccess) SourceDetail {
var autoUpdate *AutoUpdateInfo
if len(sourceWfs) > 0 {
autoUpdate = BuildAutoUpdateInfo(sourceWfs[0].AutoUpdate)
+2 -2
View File
@@ -50,8 +50,8 @@ func (h *Handler) buildSource(src *portainer.Source, stats ce.SourceStats) Sourc
}
}
func redactWorkflowCredentials(wfs []ce.Workflow) []ce.Workflow {
redacted := make([]ce.Workflow, len(wfs))
func redactWorkflowCredentials(wfs []ce.SourceWorkflow) []ce.SourceWorkflow {
redacted := make([]ce.SourceWorkflow, len(wfs))
for i, wf := range wfs {
redacted[i] = wf
if wf.GitConfig != nil && wf.GitConfig.Authentication != nil {
@@ -16,7 +16,7 @@ func TestRedactWorkflowCredentials(t *testing.T) {
t.Run("clears password and preserves username", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{
wfs := []ce.SourceWorkflow{{GitConfig: &gittypes.RepoConfig{
Authentication: &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"},
}}}
got := redactWorkflowCredentials(wfs)
@@ -27,7 +27,7 @@ func TestRedactWorkflowCredentials(t *testing.T) {
t.Run("does not mutate the original slice", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{
wfs := []ce.SourceWorkflow{{GitConfig: &gittypes.RepoConfig{
Authentication: &gittypes.GitAuthentication{Password: "s3cr3t"},
}}}
_ = redactWorkflowCredentials(wfs)
@@ -36,12 +36,12 @@ func TestRedactWorkflowCredentials(t *testing.T) {
t.Run("nil GitConfig is safe", func(t *testing.T) {
t.Parallel()
assert.NotPanics(t, func() { redactWorkflowCredentials([]ce.Workflow{{}}) })
assert.NotPanics(t, func() { redactWorkflowCredentials([]ce.SourceWorkflow{{}}) })
})
t.Run("nil Authentication is safe", func(t *testing.T) {
t.Parallel()
wfs := []ce.Workflow{{GitConfig: &gittypes.RepoConfig{}}}
wfs := []ce.SourceWorkflow{{GitConfig: &gittypes.RepoConfig{}}}
assert.NotPanics(t, func() { redactWorkflowCredentials(wfs) })
})
}
+4 -16
View File
@@ -34,7 +34,7 @@ func fetchWorkflowByID(
k8sFactory *cli.ClientFactory,
sc *security.RestrictedRequestContext,
workflowID portainer.WorkflowID,
) (*WorkflowDetail, error) {
) (*svc.Workflow, error) {
wf, err := tx.Workflow().Read(workflowID)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrNotFound, err)
@@ -65,11 +65,8 @@ func fetchWorkflowByID(
return nil, ErrNotFound
}
return &WorkflowDetail{
ID: int(wf.ID),
Name: wf.Name,
Artifacts: artifacts,
}, nil
result := svc.BuildWorkflow(*wf, artifacts)
return &result, nil
}
// fetchArtifactDetail resolves a single Artifact to its backing Stack or EdgeStack.
@@ -170,7 +167,7 @@ func checkStackAccessible(
return ErrResourceNotAccessible
}
if (!access.IsKubeAdmin && !slices.Contains(access.NonAdminNamespaces, stack.Namespace)) || !HasAccessibleSource(artifact.Files, sourceMap) {
if (!access.IsKubeAdmin && !slices.Contains(access.NonAdminNamespaces, stack.Namespace)) || !svc.HasAccessibleSource(artifact.Files, sourceMap) {
return ErrResourceNotAccessible
}
@@ -208,12 +205,3 @@ func fetchEdgeStackArtifact(
detail := svc.MapEdgeStackToArtifactDetail(edgeStack, artifact.Files, statuses, groupEndpoints, sourcePhase, artifactPhase)
return &detail, nil
}
// HasAccessibleSource reports whether any of the artifact's files has a source visible in
// sourceMap.
func HasAccessibleSource(files []portainer.ArtifactFile, sourceMap map[portainer.SourceID]portainer.Source) bool {
return slices.ContainsFunc(files, func(f portainer.ArtifactFile) bool {
_, ok := sourceMap[f.SourceID]
return ok
})
}
@@ -64,7 +64,7 @@ func TestFetchWorkflowByID_SingleStackArtifact(t *testing.T) {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
var detail *ce.Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
@@ -92,7 +92,7 @@ func TestFetchWorkflowByID_EdgeStackArtifact_Admin(t *testing.T) {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
var detail *ce.Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
@@ -126,7 +126,7 @@ func TestFetchWorkflowByID_EdgeStackArtifactFilteredForNonAdmin_SiblingStackSurv
return tx.User().Create(&portainer.User{ID: 2, Role: portainer.StandardUserRole})
}))
var detail *WorkflowDetail
var detail *ce.Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, nonAdminContext(), wfID)
@@ -184,7 +184,7 @@ func TestFetchWorkflowByID_K8sStackWithAccessibleSourceIsReturned(t *testing.T)
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
var detail *ce.Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
@@ -208,7 +208,7 @@ func TestFetchWorkflowByID_ZeroArtifactsIsNotAnError(t *testing.T) {
return tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole})
}))
var detail *WorkflowDetail
var detail *ce.Workflow
require.NoError(t, store.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, nil, adminContext(), wfID)
+2 -9
View File
@@ -13,13 +13,6 @@ import (
"github.com/portainer/portainer/pkg/libhttp/response"
)
// WorkflowDetail is the response for GET /gitops/workflows/{id}
type WorkflowDetail struct {
ID int `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Artifacts []svc.ArtifactDetail `json:"artifacts,omitempty"`
}
// @id GitOpsWorkflowGet
// @summary Get a GitOps workflow by ID
// @description Returns the detail view of a single GitOps workflow, with one entry per backing
@@ -30,7 +23,7 @@ type WorkflowDetail struct {
// @security jwt
// @produce json
// @param id path int true "Workflow identifier"
// @success 200 {object} WorkflowDetail
// @success 200 {object} svc.Workflow
// @failure 400 "Invalid request"
// @failure 404 "Workflow not found"
// @failure 500 "Server error"
@@ -46,7 +39,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) *httperror.Handler
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var detail *WorkflowDetail
var detail *svc.Workflow
err = h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
detail, err = fetchWorkflowByID(tx, h.k8sFactory, securityContext, portainer.WorkflowID(id))
@@ -11,6 +11,7 @@ import (
"github.com/portainer/portainer/api/dataservices/source"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
@@ -34,10 +35,10 @@ func buildWorkflowGetReq(t *testing.T, userID portainer.UserID, role portainer.U
return req.WithContext(ctx)
}
func decodeWorkflowDetail(t *testing.T, rr *httptest.ResponseRecorder) WorkflowDetail {
func decodeWorkflow(t *testing.T, rr *httptest.ResponseRecorder) workflows.Workflow {
t.Helper()
require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String())
var detail WorkflowDetail
var detail workflows.Workflow
require.NoError(t, json.NewDecoder(rr.Body).Decode(&detail))
return detail
}
@@ -69,8 +70,8 @@ func TestWorkflowGet_MultiFileStackArtifact(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, strconv.Itoa(int(wfID))))
detail := decodeWorkflowDetail(t, rr)
assert.Equal(t, int(wfID), detail.ID)
detail := decodeWorkflow(t, rr)
assert.Equal(t, wfID, detail.ID)
require.Len(t, detail.Artifacts, 1)
assert.Equal(t, "gitops-stack", detail.Artifacts[0].Name)
assert.Len(t, detail.Artifacts[0].Files, 2)
@@ -92,7 +93,7 @@ func TestWorkflowGet_ZeroArtifactWorkflowReturnsEmptyArray(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowGetReq(t, 1, portainer.AdministratorRole, strconv.Itoa(int(wfID))))
detail := decodeWorkflowDetail(t, rr)
detail := decodeWorkflow(t, rr)
assert.Equal(t, "empty-workflow", detail.Name)
assert.Empty(t, detail.Artifacts)
}
@@ -57,7 +57,7 @@ func createGitStack(t *testing.T, tx dataservices.DataStoreTx, stack *portainer.
src := &portainer.Source{Git: &gittypes.GitSource{URL: stack.GitConfig.URL, Authentication: stack.GitConfig.Authentication, TLSSkipVerify: stack.GitConfig.TLSSkipVerify}, Type: portainer.SourceTypeGit}
require.NoError(t, tx.Source().Create(source.InsecureNewAdminContext(), src))
wf := &portainer.Workflow{Artifacts: []portainer.Artifact{{
wf := &portainer.Workflow{Name: stack.Name, Artifacts: []portainer.Artifact{{
StackID: stack.ID,
Files: []portainer.ArtifactFile{{
SourceID: src.ID,
+19 -32
View File
@@ -2,7 +2,6 @@ package workflows
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
@@ -23,22 +22,23 @@ import (
// @id GitOpsWorkflowsList
// @summary List all GitOps workflows
// @description Returns a unified list of all stacks that have GitOps (GitConfig) configured.
// @description Returns a list of GitOps workflows, each with its aggregated status and the artifacts it contains.
// @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 search query string false "Search term (matches workflow name)"
// @param sort query string false "Sort field" Enums(name,status,creationDate,lastSyncDate)
// @param order query string false "Sort order" Enums(asc,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"
// @param status query string false "Filter by status" Enums(healthy,syncing,error,paused,unknown)
// @param type query string false "Keep workflows that have at least one artifact of this type" Enums(stack)
// @param platform query string false "Keep workflows that have at least one artifact on this platform" Enums(dockerStandalone,dockerSwarm,kubernetes)
// @success 200 {array} svc.Workflow
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /gitops/workflows [get]
func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@@ -56,7 +56,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle
key := cacheKey(securityContext, endpointIDs)
items, err := h.getWorkflows(r.Context(), key, securityContext, endpointIDs)
items, err := h.getWorkflows(key, securityContext, endpointIDs)
if err != nil {
return httperror.InternalServerError("Unable to retrieve workflows", err)
}
@@ -74,7 +74,9 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle
if err != nil {
return httperror.BadRequest("Invalid type parameter", err)
}
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Type == t })
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool {
return hasArtifactMatching(i, func(a svc.ArtifactDetail) bool { return a.Type == t })
})
}
if platform, _ := request.RetrieveQueryParameter(r, "platform", true); platform != "" {
@@ -82,49 +84,34 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.Handle
if err != nil {
return httperror.BadRequest("Invalid platform parameter", err)
}
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Platform == p })
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool {
return hasArtifactMatching(i, func(a svc.ArtifactDetail) bool { return a.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(svc.EffectiveStatus(a)), string(svc.EffectiveStatus(b)))
}},
{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))
return response.JSON(w, 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 hasArtifactMatching(w svc.Workflow, pred func(svc.ArtifactDetail) bool) bool {
return slicesx.Some(w.Artifacts, pred)
}
func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) ([]svc.Workflow, error) {
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
}
+21 -16
View File
@@ -39,9 +39,10 @@ func TestWorkflowsList_GitConfigFilter(t *testing.T) {
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)
require.Len(t, items[0].Artifacts, 1)
assert.Equal(t, ce.TypeStack, items[0].Artifacts[0].Type)
require.Len(t, items[0].Artifacts[0].Files, 1)
assert.Equal(t, "docker-compose.yml", items[0].Artifacts[0].Files[0].Path)
}
func TestWorkflowsList_EndpointIDsFilter(t *testing.T) {
@@ -123,18 +124,18 @@ func TestWorkflowsList_Search(t *testing.T) {
assert.Equal(t, "alpha", items[0].Name)
}
func TestWorkflowsList_SearchByURL(t *testing.T) {
func TestWorkflowsList_SearchMatchesNameOnly(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
createGitStack(t, tx, &portainer.Stack{
ID: 1, Name: "stack-org1",
GitConfig: gitConfig("https://github.com/org1/repo"),
ID: 1, Name: "alpha",
GitConfig: gitConfig("https://github.com/needle/repo"),
})
createGitStack(t, tx, &portainer.Stack{
ID: 2, Name: "stack-org2",
GitConfig: gitConfig("https://github.com/org2/repo"),
ID: 2, Name: "beta",
GitConfig: gitConfig("https://github.com/other/repo"),
})
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
@@ -142,12 +143,18 @@ func TestWorkflowsList_SearchByURL(t *testing.T) {
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=org1"))
// Searching by a term that only appears in the git URL must not match — search is name-only.
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=needle"))
require.Empty(t, decodeWorkflows(t, rr))
// Searching by name matches.
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, "stack-org1", items[0].Name)
assert.Equal(t, "alpha", items[0].Name)
}
func TestWorkflowsList_Sort(t *testing.T) {
@@ -356,7 +363,7 @@ func TestWorkflowsList_InvalidFilterParams(t *testing.T) {
}
}
func TestWorkflowsList_RedactsCredentials(t *testing.T) {
func TestWorkflowsList_DoesNotLeakCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
@@ -377,8 +384,6 @@ func TestWorkflowsList_RedactsCredentials(t *testing.T) {
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
require.NotNil(t, items[0].GitConfig)
require.NotNil(t, items[0].GitConfig.Authentication)
assert.Equal(t, "user", items[0].GitConfig.Authentication.Username)
assert.Empty(t, items[0].GitConfig.Authentication.Password)
// Source credentials must never be part of the workflow summary payload.
assert.NotContains(t, rr.Body.String(), "s3cr3t")
}
+1 -1
View File
@@ -26,7 +26,7 @@ func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.Han
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
items, err := h.getWorkflows(r.Context(), cacheKey(securityContext, nil), securityContext, nil)
items, err := h.getWorkflows(cacheKey(securityContext, nil), securityContext, nil)
if err != nil {
return httperror.InternalServerError("Unable to retrieve workflows", err)
}
@@ -4423,7 +4423,7 @@ export const gitOpsSourcesTest = <ThrowOnError extends boolean = true>(
/**
* List all GitOps workflows
*
* Returns a unified list of all stacks that have GitOps (GitConfig) configured.
* Returns a list of GitOps workflows, each with its aggregated status and the artifacts it contains.
* **Access policy**: authenticated
*/
export const gitOpsWorkflowsList = <ThrowOnError extends boolean = true>(
@@ -4394,7 +4394,7 @@ export type SourcesSourceDetail = {
type: SourcesSourceType;
url: string;
usedBy?: number;
workflows?: Array<WorkflowsWorkflow>;
workflows?: Array<WorkflowsSourceWorkflow>;
};
export const SourcesSourceType = {
@@ -8249,6 +8249,20 @@ export const WorkflowsDeploymentPlatform = {
export type WorkflowsDeploymentPlatform =
(typeof WorkflowsDeploymentPlatform)[keyof typeof WorkflowsDeploymentPlatform];
export type WorkflowsSourceWorkflow = {
autoUpdate?: PortainerAutoUpdateSettings;
creationDate?: number;
gitConfig?: GittypesRepoConfig;
id: number;
lastSyncDate?: number;
name: string;
platform: WorkflowsDeploymentPlatform;
sourceId?: number;
status: WorkflowsWorkflowStatusObject;
target: WorkflowsTarget;
type: WorkflowsType;
};
export const WorkflowsStatus = {
/**
* StatusHealthy
@@ -8306,23 +8320,12 @@ export const WorkflowsType = {
export type WorkflowsType = (typeof WorkflowsType)[keyof typeof WorkflowsType];
export type WorkflowsWorkflow = {
autoUpdate?: PortainerAutoUpdateSettings;
artifacts?: Array<WorkflowsArtifactDetail>;
creationDate?: number;
gitConfig?: GittypesRepoConfig;
id: number;
lastSyncDate?: number;
name: string;
platform: WorkflowsDeploymentPlatform;
sourceId?: number;
status: WorkflowsWorkflowStatusObject;
target: WorkflowsTarget;
type: WorkflowsType;
};
export type WorkflowsWorkflowDetail = {
artifacts?: Array<WorkflowsArtifactDetail>;
id: number;
name: string;
};
export type WorkflowsWorkflowPhaseStatus = {
@@ -11795,17 +11798,17 @@ export type GitOpsWorkflowsListData = {
path?: never;
query?: {
/**
* Search term (matches name or repository URL)
* Search term (matches workflow name)
*/
search?: string;
/**
* Sort field: name | type | status | creationDate | lastSyncDate
* Sort field
*/
sort?: string;
sort?: 'name' | 'status' | 'creationDate' | 'lastSyncDate';
/**
* Sort order: asc or desc
* Sort order
*/
order?: string;
order?: 'asc' | 'desc';
/**
* Pagination start index
*/
@@ -11819,22 +11822,26 @@ export type GitOpsWorkflowsListData = {
*/
endpointIds?: Array<number>;
/**
* Filter by status: healthy | syncing | error | paused | unknown
* Filter by status
*/
status?: string;
status?: 'healthy' | 'syncing' | 'error' | 'paused' | 'unknown';
/**
* Filter by type: stack
* Keep workflows that have at least one artifact of this type
*/
type?: string;
type?: 'stack';
/**
* Filter by platform: dockerStandalone | dockerSwarm | kubernetes
* Keep workflows that have at least one artifact on this platform
*/
platform?: string;
platform?: 'dockerStandalone' | 'dockerSwarm' | 'kubernetes';
};
url: '/gitops/workflows';
};
export type GitOpsWorkflowsListErrors = {
/**
* Invalid request
*/
400: unknown;
/**
* Server error
*/
@@ -11882,7 +11889,7 @@ export type GitOpsWorkflowGetResponses = {
/**
* OK
*/
200: WorkflowsWorkflowDetail;
200: WorkflowsWorkflow;
};
export type GitOpsWorkflowGetResponse =
@@ -3370,7 +3370,7 @@ export const zWorkflowsArtifactDetail = z.object({
type: zWorkflowsType,
});
export const zWorkflowsWorkflow = z.object({
export const zWorkflowsSourceWorkflow = z.object({
autoUpdate: zPortainerAutoUpdateSettings.optional(),
creationDate: z.int().optional(),
gitConfig: zGittypesRepoConfig.optional(),
@@ -3398,13 +3398,16 @@ export const zSourcesSourceDetail = z.object({
type: zSourcesSourceType,
url: z.string(),
usedBy: z.int().optional(),
workflows: z.array(zWorkflowsWorkflow).optional(),
workflows: z.array(zWorkflowsSourceWorkflow).optional(),
});
export const zWorkflowsWorkflowDetail = z.object({
export const zWorkflowsWorkflow = z.object({
artifacts: z.array(zWorkflowsArtifactDetail).optional(),
creationDate: z.int().optional(),
id: z.int(),
lastSyncDate: z.int().optional(),
name: z.string(),
status: zWorkflowsWorkflowStatusObject,
});
/**
@@ -4442,14 +4445,18 @@ export const zGitOpsSourcesTestResponse = zSourcesConnectionTestResult;
export const zGitOpsWorkflowsListQuery = z.object({
search: z.string().optional(),
sort: z.string().optional(),
order: z.string().optional(),
sort: z.enum(['name', 'status', 'creationDate', 'lastSyncDate']).optional(),
order: z.enum(['asc', 'desc']).optional(),
start: z.int().optional(),
limit: z.int().optional(),
endpointIds: z.array(z.int()).optional(),
status: z.string().optional(),
type: z.string().optional(),
platform: z.string().optional(),
status: z
.enum(['healthy', 'syncing', 'error', 'paused', 'unknown'])
.optional(),
type: z.enum(['stack']).optional(),
platform: z
.enum(['dockerStandalone', 'dockerSwarm', 'kubernetes'])
.optional(),
});
/**
@@ -4464,7 +4471,7 @@ export const zGitOpsWorkflowGetPath = z.object({
/**
* OK
*/
export const zGitOpsWorkflowGetResponse = zWorkflowsWorkflowDetail;
export const zGitOpsWorkflowGetResponse = zWorkflowsWorkflow;
/**
* OK
@@ -1,6 +1,7 @@
export const workflowQueryKeys = {
all: ['gitops', 'workflows'] as const,
list: (params: object) => [...workflowQueryKeys.all, 'list', params] as const,
list: (params?: object) =>
[...workflowQueryKeys.all, 'list', params] as const,
summary: () => [...workflowQueryKeys.all, 'summary'] as const,
detail: (id: number) => [...workflowQueryKeys.all, 'detail', id] as const,
};
@@ -1,40 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import axios from '@/portainer/services/axios/axios';
import { withError } from '@/react-tools/react-query';
import { withPaginationHeaders } from '@/react/common/api/pagination.types';
import {
DeploymentPlatform,
Workflow,
WorkflowStatus,
WorkflowType,
} from '../workflows/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<Workflow[]>('/gitops/workflows', {
params,
});
return withPaginationHeaders(response);
}
export function useWorkflows(params: WorkflowsParams) {
return useQuery({
queryKey: workflowQueryKeys.list(params),
queryFn: () => getWorkflows(params),
...withError('Failed loading workflows'),
});
}
@@ -7,13 +7,14 @@ import { Card } from '@@/primitives/Card';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { Workflow, WorkflowTarget, WorkflowType } from '../../workflows/types';
import { WorkflowTarget, WorkflowType } from '../../workflows/types';
import { StatusBadge } from '../../components/StatusBadge';
import { getWorkflowLink } from '../../workflows/utils';
import { effectiveWorkflowStatus } from '../../workflows/status';
import { SourceWorkflow } from '../queries/useSource';
interface Props {
workflows: Workflow[];
workflows: SourceWorkflow[];
}
export function WorkflowsTab({ workflows }: Props) {
@@ -41,7 +42,7 @@ export function WorkflowsTab({ workflows }: Props) {
);
}
function WorkflowsList({ workflows }: { workflows: Array<Workflow> }) {
function WorkflowsList({ workflows }: { workflows: Array<SourceWorkflow> }) {
return (
<div className="space-y-2">
{workflows.map((wf) => (
@@ -51,7 +52,7 @@ function WorkflowsList({ workflows }: { workflows: Array<Workflow> }) {
);
}
function WorkflowCard({ item }: { item: Workflow }) {
function WorkflowCard({ item }: { item: SourceWorkflow }) {
const { to, params } = getWorkflowLink(item);
return (
@@ -1,11 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import {
type SourcesGitAuthInfo,
type SourcesConnectionInfo,
type SourcesAutoUpdateInfo,
type SourcesSourceDetail,
WorkflowsWorkflow,
WorkflowsSourceWorkflow,
WorkflowsWorkflowStatusObject,
WorkflowsStatus,
WorkflowsWorkflowPhaseStatus,
@@ -23,20 +21,27 @@ import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types'
import { Source } from '../types';
import {
Workflow,
WorkflowPhaseStatus,
WorkflowStatus,
WorkflowStatusObject,
WorkflowTarget,
} from '../../workflows/types';
import { sourceQueryKeys } from './query-keys';
export type GitAuthInfo = SourcesGitAuthInfo;
export type ConnectionInfo = SourcesConnectionInfo;
export type AutoUpdateInfo = SourcesAutoUpdateInfo;
export type SourceWorkflow = WorkflowsSourceWorkflow & {
status: WorkflowStatusObject;
sourceId?: number;
gitConfig?: RepoConfigResponse;
target: WorkflowTarget;
creationDate: number;
lastSyncDate: number;
};
export type SourceDetail = Omit<SourcesSourceDetail, 'workflows' | 'usedBy'> & {
workflows: Array<Workflow>;
workflows: Array<SourceWorkflow>;
usedBy: number;
};
@@ -67,7 +72,7 @@ export async function getSource(id: Source['id']): Promise<SourceDetail> {
usedBy: source.usedBy ?? 0,
};
function toWorkflow(workflow: WorkflowsWorkflow): Workflow {
function toWorkflow(workflow: WorkflowsSourceWorkflow): SourceWorkflow {
return {
...workflow,
creationDate: workflow.creationDate ?? 0,
@@ -9,7 +9,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { UserViewModel } from '@/portainer/models/user';
import { WorkflowDetail } from '../types';
import { Workflow } from '../types';
import {
mockWorkflowHealthy,
mockWorkflowEmpty,
@@ -78,7 +78,7 @@ describe('ItemView', () => {
});
});
function renderComponent(workflow: WorkflowDetail = mockWorkflowHealthy) {
function renderComponent(workflow: Workflow = mockWorkflowHealthy) {
useCurrentStateAndParams.mockReturnValue({
params: { workflowId: workflow.id },
});
@@ -6,7 +6,7 @@ import { Alert } from '@@/Alert';
import { Tab, WidgetTabs, useCurrentTabIndex } from '@@/Widget/WidgetTabs';
import { useWorkflow } from '../queries/useWorkflow';
import { WorkflowDetail } from '../types';
import { Workflow } from '../types';
import { WorkflowResourceHeader } from './WorkflowResourceHeader';
import { OverviewTab } from './OverviewTab';
@@ -52,7 +52,7 @@ export function ItemView() {
return <PageContent workflow={workflow} />;
}
function PageContent({ workflow }: { workflow: WorkflowDetail }) {
function PageContent({ workflow }: { workflow: Workflow }) {
const workflowTabs: Tab[] = [
{
name: 'Overview',
@@ -4,7 +4,7 @@ import { Card } from '@@/primitives/Card';
import { Icon } from '@@/Icon';
import { useWorkflowSources } from '../queries/useWorkflowSources';
import { WorkflowDetail } from '../types';
import { Workflow } from '../types';
import { StacksSection } from './StacksSection';
import { TargetsSection } from './TargetsSection';
@@ -12,7 +12,7 @@ import { FilesSection } from './FilesSection';
import { SourcesSection } from './SourcesSection';
interface Props {
workflow: WorkflowDetail;
workflow: Workflow;
}
export function OverviewTab({ workflow }: Props) {
@@ -10,18 +10,18 @@ import { Button } from '@@/buttons';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { WorkflowDetail } from '../types';
import { Workflow } from '../types';
import { StatusBadge } from '../../components/StatusBadge';
import { computeTargetRollup, effectiveWorkflowDetailStatus } from '../status';
import { computeTargetRollup, effectiveWorkflowStatus } from '../status';
const COMING_SOON_MESSAGE = 'Coming soon';
interface Props {
workflow: WorkflowDetail;
workflow: Workflow;
}
export function WorkflowResourceHeader({ workflow }: Props) {
const status = effectiveWorkflowDetailStatus(workflow);
const status = effectiveWorkflowStatus(workflow);
const rollup = computeTargetRollup(workflow);
return (
@@ -7,14 +7,15 @@ import {
SortableGroup,
SortOption,
} from '@@/SortableList/SortableList';
import { asEnum } from '@@/datatables/useTableStateFromUrl';
import { useWorkflows } from '../../queries/useWorkflows';
import { useWorkflows } from '../queries/useWorkflows';
import { useWorkflowsSummary } from '../../queries/useWorkflowsSummary';
import { Workflow, WorkflowStatus } from '../types';
import { effectiveWorkflowStatus } from '../status';
import { WorkflowCard } from './WorkflowCard';
import { useListState } from './useListState';
import { useListState, SORT_KEYS } from './useListState';
const STATUS_CONFIG: Array<{
key: WorkflowStatus;
@@ -31,28 +32,17 @@ const STATUS_CONFIG: Array<{
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 SORT_KEY_SET = new Set(SORT_KEYS);
const GROUP_OPTIONS: Record<string, Array<{ key: string; label: string }>> = {
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, (item: Workflow) => string> = {
status: (item: Workflow) => effectiveWorkflowStatus(item).status,
type: (item) => item.type,
platform: (item) => item.platform,
status: (item) => effectiveWorkflowStatus(item).status,
};
export function ListView() {
@@ -62,13 +52,11 @@ export function ListView() {
const workflowsQuery = useWorkflows({
search: tableState.search || undefined,
sort: sortBy,
sort: asEnum(sortBy, SORT_KEY_SET) ?? 'name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
start: tableState.page * tableState.pageSize,
limit: tableState.pageSize,
status: tableState.status ?? undefined,
type: tableState.type ?? undefined,
platform: tableState.platform ?? undefined,
});
const summaryQuery = useWorkflowsSummary();
@@ -118,7 +106,7 @@ export function ListView() {
groups={groups}
totalCount={totalCount}
isLoading={workflowsQuery.isLoading}
getItemKey={(item) => `${item.type}-${item.id}`}
getItemKey={(item) => `workflow-${item.id}`}
showGroupHeaders
emptyMessage="No workflows found"
searchPlaceholder="Search"
@@ -1,14 +1,14 @@
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, WorkflowType } from '../types';
import { StatusBadge, TypeBadge } from '../../components/StatusBadge';
import { StatusBadge } from '../../components/StatusBadge';
import { useWorkflowSources } from '../queries/useWorkflowSources';
import { getWorkflowLink } from '../utils';
import { Workflow } from '../types';
import { effectiveWorkflowStatus } from '../status';
import { WorkflowSubRow } from './WorkflowSubRow/WorkflowSubRow';
@@ -19,6 +19,9 @@ export function WorkflowCard({ item }: { item: Workflow }) {
const { status: effectiveStatus, error: errorMessage } =
effectiveWorkflowStatus(item);
const sources = useWorkflowSources(item.artifacts);
const showArtifactHeaders = item.artifacts.length > 1;
return (
<SortableListItem>
<div className="flex gap-4">
@@ -37,11 +40,19 @@ export function WorkflowCard({ item }: { item: Workflow }) {
{item.name}
</Link>
<StatusBadge status={effectiveStatus} />
<TypeBadge type={item.type} />
</div>
<SyncLabel type={item.type} date={item.lastSyncDate} />
<SyncLabel date={item.lastSyncDate} />
</div>
<div className="space-y-3">
{item.artifacts.map((artifact) => (
<WorkflowSubRow
key={`${artifact.type}_${artifact.id}`}
artifact={artifact}
sources={sources}
showHeader={showArtifactHeaders}
/>
))}
</div>
<WorkflowSubRow item={item} />
{errorMessage && (
<div className="mt-2.5 flex items-center gap-1.5 text-xs text-error-8">
<Icon icon={AlertTriangle} size="sm" className="shrink-0" />
@@ -54,48 +65,13 @@ export function WorkflowCard({ item }: { item: Workflow }) {
);
}
function SyncLabel({ type, date }: { type: WorkflowType; date: number }) {
function SyncLabel({ date }: { date: number | undefined }) {
const syncLabel = date ? moment.unix(date).fromNow() : '-';
const syncTitle = type === 'edgeStack' ? 'Oldest sync' : 'Last sync';
return (
<div className="flex items-center gap-1.5 text-xs text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
<Icon icon={WatchIcon} size="xs" />
<span>
{syncTitle}: {syncLabel}
</span>
<span>Last sync: {syncLabel}</span>
</div>
);
}
function getWorkflowLink(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,
},
};
}
@@ -3,15 +3,43 @@ import { ReactNode } from 'react';
import { Link } from '@@/Link';
import { Workflow, WorkflowStatus } from '../../types';
import { TypeBadge, PlatformBadge } from '../../../components/StatusBadge';
import {
WorkflowSourcesResult,
SourceQueryResult,
} from '../../queries/useWorkflowSources';
import {
WorkflowArtifact,
WorkflowArtifactFile,
WorkflowStatus,
} from '../../types';
import { getDeployedStackLink, getSourceLink } from '../../utils';
import { Block, Dot } from './Block';
import { TargetCell } from './TargetCell';
export function WorkflowSubRow({ item }: { item: Workflow }) {
export function WorkflowSubRow({
artifact,
sources,
showHeader,
}: {
artifact: WorkflowArtifact;
sources: WorkflowSourcesResult;
showHeader: boolean;
}) {
const artifactSources = getArtifactSources(artifact, sources);
return (
<div className="overflow-hidden rounded border border-solid border-gray-3 text-xs th-dark:border-gray-9">
{showHeader && (
<div className="flex items-center gap-2 border-0 border-b border-solid border-gray-3 bg-gray-2 px-4 py-2 th-dark:border-gray-9 th-dark:bg-gray-iron-11">
<span className="font-semibold text-gray-9 th-highcontrast:text-white th-dark:text-white">
{artifact.name}
</span>
<TypeBadge type={artifact.type} />
<PlatformBadge platform={artifact.platform} />
</div>
)}
<table className="w-full table-fixed border-collapse">
<thead className="border-0 border-b border-solid border-gray-3 bg-gray-2 th-dark:border-gray-9 th-dark:bg-gray-iron-11">
<tr>
@@ -23,29 +51,33 @@ export function WorkflowSubRow({ item }: { item: Workflow }) {
<tbody>
<tr>
<Td>
{item.gitConfig && (
<SourceCell
sourceId={item.sourceId}
name={item.name}
url={item.gitConfig.URL}
status={item.status.source.status}
/>
)}
<div className="flex flex-col gap-1.5">
{artifactSources.length > 0 ? (
artifactSources.map(({ sourceId, query }) => (
<SourceCell
key={sourceId}
sourceId={sourceId}
query={query}
status={artifact.status.source.status}
/>
))
) : (
<span className="text-gray-5">No source</span>
)}
</div>
</Td>
<Td divider>
{item.gitConfig && (
<ArtifactCell
item={item}
path={item.gitConfig.ConfigFilePath}
status={item.status.artifact.status}
/>
)}
<ArtifactCell
artifact={artifact}
files={artifact.files}
status={artifact.status.artifact.status}
/>
</Td>
<Td divider rowSpan={9999}>
<Td divider>
<TargetCell
target={item.target}
type={item.type}
status={item.status.target.status}
target={artifact.target}
type={artifact.type}
status={artifact.status.target.status}
/>
</Td>
</tr>
@@ -57,33 +89,31 @@ export function WorkflowSubRow({ item }: { item: Workflow }) {
function SourceCell({
sourceId,
name,
url,
query,
status,
}: {
sourceId: number | undefined;
name: string;
url: string;
sourceId: number;
query: SourceQueryResult;
status: WorkflowStatus;
}) {
const source = query.data;
const content = (
<Block status={status} className="flex items-start gap-2">
<Dot status={status} className="mt-1.5" />
<div className="min-w-0">
<p className="m-0 font-semibold text-gray-9 th-highcontrast:text-white th-dark:text-white">
{name}
</p>
<p className="m-0 mt-0.5 break-all text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
{url}
{source?.name ?? 'Unknown source'}
</p>
{source?.url && (
<p className="m-0 mt-0.5 break-all text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
{source.url}
</p>
)}
</div>
</Block>
);
if (sourceId === undefined) {
return content;
}
const sourceLink = getSourceLink(sourceId);
return (
<Link
@@ -98,24 +128,35 @@ function SourceCell({
}
function ArtifactCell({
item,
path,
artifact,
files,
status,
}: {
item: Workflow;
path: string;
artifact: WorkflowArtifact;
files: WorkflowArtifactFile[];
status: WorkflowStatus;
}) {
const content = (
<Block status={status} className="flex items-center gap-2">
<Dot status={status} />
<span className="font-mono text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-4">
{path}
</span>
<Block status={status} className="flex flex-col gap-1">
{files.length > 0 ? (
files.map((file, index) => (
<div
key={`${file.sourceId}-${index}`}
className="flex items-center gap-2"
>
<Dot status={status} />
<span className="break-all font-mono text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-4">
{file.path}
</span>
</div>
))
) : (
<span className="text-gray-5">No files</span>
)}
</Block>
);
const stackLink = getDeployedStackLink(item);
const stackLink = getDeployedStackLink(artifact);
if (!stackLink) {
return content;
}
@@ -124,7 +165,7 @@ function ArtifactCell({
<Link
to={stackLink.to}
params={stackLink.params}
data-cy={`workflow-artifact-link-${item.id}`}
data-cy={`workflow-artifact-link-${artifact.id}`}
className="block no-underline hover:no-underline"
>
{content}
@@ -149,11 +190,9 @@ function Th({ children, divider }: { children: ReactNode; divider?: boolean }) {
function Td({
children,
divider,
rowSpan,
}: {
children?: ReactNode;
divider?: boolean;
rowSpan?: number;
}) {
return (
<td
@@ -162,9 +201,19 @@ function Td({
divider &&
'border-0 border-l border-solid border-gray-3 th-dark:border-gray-8'
)}
rowSpan={rowSpan}
>
{children}
</td>
);
}
function getArtifactSources(
artifact: WorkflowArtifact,
sources: WorkflowSourcesResult
): WorkflowSourcesResult {
const artifactSourceIds = new Set(
artifact.files.map((file) => file.sourceId)
);
return sources.filter(({ sourceId }) => artifactSourceIds.has(sourceId));
}
@@ -4,7 +4,7 @@ import {
useTableStateFromUrl,
} from '@@/datatables/useTableStateFromUrl';
import { WorkflowStatus, WorkflowType, DeploymentPlatform } from '../types';
import { WorkflowStatus } from '../types';
const DEFAULT_SORT = 'name' as const;
@@ -15,37 +15,21 @@ const WORKFLOW_STATUSES = new Set<WorkflowStatus>([
'paused',
'unknown',
]);
const WORKFLOW_TYPES = new Set<WorkflowType>(['stack', 'edgeStack']);
const DEPLOYMENT_PLATFORMS = new Set<DeploymentPlatform>([
'dockerStandalone',
'dockerSwarm',
'kubernetes',
]);
const SORT_KEYS = [
'name',
'status',
'type',
'platform',
'lastSyncDate',
] as const;
export const SORT_KEYS = ['name', 'status', 'lastSyncDate'] as const;
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }, { key: 'platform' }];
const DIMENSIONS = [{ key: 'status' }];
export function useListState() {
return useTableStateFromUrl({
localStorageKey: 'workflows',
defaultSort: DEFAULT_SORT,
persistedExtraKeys: ['status', 'type', 'platform'],
persistedExtraKeys: ['status'],
parseExtra: (params) => ({
status: asEnum(params.status, WORKFLOW_STATUSES),
type: asEnum(params.type, WORKFLOW_TYPES),
platform: asEnum(params.platform, DEPLOYMENT_PLATFORMS),
}),
buildExtra: (urlState, setUrlState) => ({
status: urlState.status,
type: urlState.type,
platform: urlState.platform,
setStatus: (v: WorkflowStatus | null) =>
setUrlState({ status: v, page: 0 }),
...buildGroupSortExtras({
@@ -0,0 +1,55 @@
import {
WorkflowsArtifactDetail,
WorkflowsArtifactFileDetail,
WorkflowsWorkflow,
WorkflowsWorkflowPhaseStatus,
WorkflowsWorkflowStatusObject,
} from '@api/types.gen';
import {
Workflow,
WorkflowArtifact,
WorkflowPhaseStatus,
WorkflowStatusObject,
} from '../types';
export function toWorkflow(workflow: WorkflowsWorkflow): Workflow {
return {
...workflow,
status: toStatusObject(workflow.status),
artifacts: workflow.artifacts?.map(toArtifact) ?? [],
};
}
function toArtifact(artifact: WorkflowsArtifactDetail): WorkflowArtifact {
return {
...artifact,
status: toStatusObject(artifact.status),
files: artifact.files?.filter(hasSourceId) ?? [],
};
}
function toStatusObject(
statusObj: WorkflowsWorkflowStatusObject | undefined
): WorkflowStatusObject {
return {
artifact: toPhaseStatus(statusObj?.artifact),
source: toPhaseStatus(statusObj?.source),
target: toPhaseStatus(statusObj?.target),
};
}
function toPhaseStatus(
status: WorkflowsWorkflowPhaseStatus | undefined
): WorkflowPhaseStatus {
return {
status: status?.status || 'unknown',
error: status?.error,
};
}
function hasSourceId(
file: WorkflowsArtifactFileDetail
): file is WorkflowsArtifactFileDetail & { sourceId: number } {
return !!file.sourceId;
}
@@ -1,78 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import {
WorkflowsArtifactDetail,
WorkflowsArtifactFileDetail,
WorkflowsWorkflowDetail,
WorkflowsWorkflowPhaseStatus,
WorkflowsWorkflowStatusObject,
} from '@api/types.gen';
import { gitOpsWorkflowGet } from '@api/sdk.gen';
import { withError } from '@/react-tools/react-query';
import {
WorkflowArtifact,
WorkflowArtifactFile,
WorkflowDetail,
WorkflowPhaseStatus,
WorkflowStatusObject,
} from '../types';
import { Workflow } from '../types';
import { workflowQueryKeys } from '../../queries/query-keys';
async function getWorkflow(id: number): Promise<WorkflowDetail> {
import { toWorkflow } from './mappers';
async function getWorkflow(id: number): Promise<Workflow> {
const response = await gitOpsWorkflowGet({ path: { id } });
return toWorkflowDetail(response.data);
function toWorkflowDetail(wf: WorkflowsWorkflowDetail): WorkflowDetail {
return {
...wf,
artifacts: wf.artifacts?.map(toArtifact) || [],
};
}
function toArtifact(artifact: WorkflowsArtifactDetail): WorkflowArtifact {
return {
...artifact,
status: toArtifactStatusObj(artifact.status),
files: (artifact.files ?? []).filter(hasSourceId).map(toArtifactFile),
};
}
function hasSourceId(
file: WorkflowsArtifactFileDetail
): file is WorkflowsArtifactFileDetail & { sourceId: number } {
return !!file.sourceId;
}
function toArtifactFile(
file: WorkflowsArtifactFileDetail & { sourceId: number }
): WorkflowArtifactFile {
return {
...file,
sourceId: file.sourceId,
};
}
function toArtifactStatusObj(
statusObj: WorkflowsWorkflowStatusObject | undefined
): WorkflowStatusObject {
return {
artifact: toStatus(statusObj?.artifact),
source: toStatus(statusObj?.source),
target: toStatus(statusObj?.target),
};
}
function toStatus(
status: WorkflowsWorkflowPhaseStatus | undefined
): WorkflowPhaseStatus {
return {
status: status?.status || 'unknown',
error: status?.error,
};
}
return toWorkflow(response.data);
}
export function useWorkflow(id: number | undefined) {
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import { gitOpsWorkflowsList } from '@api/sdk.gen';
import { GitOpsWorkflowsListData } from '@api/types.gen';
import { withError } from '@/react-tools/react-query';
import {
withPaginationHeaders,
PaginatedResults,
} from '@/react/common/api/pagination.types';
import { Workflow } from '../types';
import { workflowQueryKeys } from '../../queries/query-keys';
import { toWorkflow } from './mappers';
export type WorkflowsParams = GitOpsWorkflowsListData['query'];
async function getWorkflows(
params?: WorkflowsParams
): Promise<PaginatedResults<Workflow[]>> {
const { data, headers } = await gitOpsWorkflowsList({
query: params,
});
return withPaginationHeaders({
data: (data ?? []).map(toWorkflow),
headers,
});
}
export function useWorkflows(params?: WorkflowsParams) {
return useQuery({
queryKey: workflowQueryKeys.list(params),
queryFn: () => getWorkflows(params),
...withError('Failed loading workflows'),
});
}
@@ -7,7 +7,11 @@ import {
mockWorkflowMultiArtifact,
mockWorkflowEmpty,
} from './test-utils/workflow.mock';
import { Workflow, WorkflowPhaseStatus, WorkflowStatus } from './types';
import {
WorkflowPhaseStatus,
WorkflowStatus,
WorkflowStatusObject,
} from './types';
import {
effectiveWorkflowStatus,
worstPhaseStatus,
@@ -22,65 +26,65 @@ describe('effectiveWorkflowStatus', () => {
'paused',
'healthy',
'unknown',
])('all phases %s → %s', (status) => {
const item = makeWorkflow(
makePhase(status),
makePhase(status),
makePhase(status)
])('all phases %s → %s', (statusType) => {
const status = makeWorkflowStatus(
makePhase(statusType),
makePhase(statusType),
makePhase(statusType)
);
expect(effectiveWorkflowStatus(item).status).toBe(status);
expect(effectiveWorkflowStatus({ status }).status).toBe(statusType);
});
});
describe('priority order', () => {
it('error beats syncing and healthy', () => {
const item = makeWorkflow(
const status = makeWorkflowStatus(
makePhase('error'),
makePhase('syncing'),
makePhase('healthy')
);
expect(effectiveWorkflowStatus(item).status).toBe('error');
expect(effectiveWorkflowStatus({ status }).status).toBe('error');
});
it('syncing beats paused and healthy', () => {
const item = makeWorkflow(
const status = makeWorkflowStatus(
makePhase('paused'),
makePhase('syncing'),
makePhase('healthy')
);
expect(effectiveWorkflowStatus(item).status).toBe('syncing');
expect(effectiveWorkflowStatus({ status }).status).toBe('syncing');
});
it('paused beats healthy and unknown', () => {
const item = makeWorkflow(
const status = makeWorkflowStatus(
makePhase('healthy'),
makePhase('unknown'),
makePhase('paused')
);
expect(effectiveWorkflowStatus(item).status).toBe('paused');
expect(effectiveWorkflowStatus({ status }).status).toBe('paused');
});
});
describe('error message', () => {
it('includes error from the winning phase', () => {
const item = makeWorkflow(
const status = makeWorkflowStatus(
makePhase('error', 'git clone failed'),
makePhase('healthy'),
makePhase('healthy')
);
expect(effectiveWorkflowStatus(item)).toEqual({
expect(effectiveWorkflowStatus({ status })).toEqual({
status: 'error',
error: 'git clone failed',
});
});
it('no error when winning phase has no error', () => {
const item = makeWorkflow(
const status = makeWorkflowStatus(
makePhase('syncing'),
makePhase('healthy'),
makePhase('healthy')
);
expect(effectiveWorkflowStatus(item).error).toBeUndefined();
expect(effectiveWorkflowStatus({ status }).error).toBeUndefined();
});
});
});
@@ -157,21 +161,12 @@ describe('computeTargetRollup', () => {
});
});
function makeWorkflow(
function makeWorkflowStatus(
source: WorkflowPhaseStatus,
artifact: WorkflowPhaseStatus,
target: WorkflowPhaseStatus
): Workflow {
return {
id: 1,
name: 'test',
type: 'stack',
platform: 'dockerStandalone',
status: { source, artifact, target },
target: { endpointId: 1 },
creationDate: 0,
lastSyncDate: 0,
};
): WorkflowStatusObject {
return { source, artifact, target };
}
function makePhase(
+6 -24
View File
@@ -1,9 +1,9 @@
import {
WorkflowStatus,
WorkflowPhaseStatus,
Workflow,
WorkflowStatusObject,
WorkflowArtifact,
WorkflowDetail,
Workflow,
} from './types';
const STATUS_PRIORITY: Record<WorkflowStatus, number> = {
@@ -25,7 +25,9 @@ export function worstPhaseStatus(
);
}
export function effectiveWorkflowStatus(item: Workflow): WorkflowPhaseStatus {
export function effectiveWorkflowStatus(item: {
status: WorkflowStatusObject;
}): WorkflowPhaseStatus {
return worstPhaseStatus([
item.status.source,
item.status.artifact,
@@ -33,26 +35,6 @@ export function effectiveWorkflowStatus(item: Workflow): WorkflowPhaseStatus {
]);
}
function effectiveArtifactStatus(
artifact: WorkflowArtifact
): WorkflowPhaseStatus {
return worstPhaseStatus([
artifact.status.source,
artifact.status.artifact,
artifact.status.target,
]);
}
export function effectiveWorkflowDetailStatus(
workflow: WorkflowDetail
): WorkflowPhaseStatus {
if (workflow.artifacts.length === 0) {
return { status: 'unknown' };
}
return worstPhaseStatus(workflow.artifacts.map(effectiveArtifactStatus));
}
export type TargetRollupTone = 'success' | 'danger' | 'warning' | 'muted';
export interface TargetRollup {
@@ -78,7 +60,7 @@ export function computeArtifactTargetCount(artifact: WorkflowArtifact): number {
return artifactTargetStatuses(artifact).length;
}
export function computeTargetRollup(workflow: WorkflowDetail): TargetRollup {
export function computeTargetRollup(workflow: Workflow): TargetRollup {
if (workflow.artifacts.length === 0) {
return { synced: 0, total: 0, tone: 'muted' };
}
@@ -1,8 +1,13 @@
import { WorkflowDetail } from '../types';
import { Workflow } from '../types';
export const mockWorkflowHealthy: WorkflowDetail = {
export const mockWorkflowHealthy: Workflow = {
id: 1,
name: 'healthy-workflow',
status: {
source: { status: 'healthy' },
artifact: { status: 'healthy' },
target: { status: 'healthy' },
},
artifacts: [
{
id: 101,
@@ -24,9 +29,17 @@ export const mockWorkflowHealthy: WorkflowDetail = {
],
};
export const mockWorkflowSourceError: WorkflowDetail = {
export const mockWorkflowSourceError: Workflow = {
id: 2,
name: 'broken-source-stack',
status: {
source: {
status: 'error',
error: 'authentication failed: git clone error',
},
artifact: { status: 'unknown' },
target: { status: 'unknown' },
},
artifacts: [
{
id: 102,
@@ -51,9 +64,17 @@ export const mockWorkflowSourceError: WorkflowDetail = {
],
};
export const mockWorkflowArtifactError: WorkflowDetail = {
export const mockWorkflowArtifactError: Workflow = {
id: 3,
name: 'invalid-compose-stack',
status: {
source: { status: 'healthy' },
artifact: {
status: 'error',
error: 'invalid compose file: yaml: line 4: did not find expected key',
},
target: { status: 'unknown' },
},
artifacts: [
{
id: 103,
@@ -77,9 +98,17 @@ export const mockWorkflowArtifactError: WorkflowDetail = {
],
};
export const mockWorkflowTargetError: WorkflowDetail = {
export const mockWorkflowTargetError: Workflow = {
id: 4,
name: 'unreachable-endpoint-stack',
status: {
source: { status: 'healthy' },
artifact: { status: 'healthy' },
target: {
status: 'error',
error: 'failed to deploy stack to endpoint: connection refused',
},
},
artifacts: [
{
id: 104,
@@ -104,9 +133,17 @@ export const mockWorkflowTargetError: WorkflowDetail = {
],
};
export const mockWorkflowEdgeMixed: WorkflowDetail = {
export const mockWorkflowEdgeMixed: Workflow = {
id: 5,
name: 'edge-stack',
status: {
source: { status: 'healthy' },
artifact: { status: 'healthy' },
target: {
status: 'error',
error: 'one or more edge groups failed to sync',
},
},
artifacts: [
{
id: 105,
@@ -135,9 +172,14 @@ export const mockWorkflowEdgeMixed: WorkflowDetail = {
],
};
export const mockWorkflowMultiArtifact: WorkflowDetail = {
export const mockWorkflowMultiArtifact: Workflow = {
id: 6,
name: 'multi-artifact-workflow',
status: {
source: { status: 'healthy' },
artifact: { status: 'healthy' },
target: { status: 'syncing' },
},
artifacts: [
{
id: 106,
@@ -184,13 +226,18 @@ export const mockWorkflowMultiArtifact: WorkflowDetail = {
],
};
export const mockWorkflowEmpty: WorkflowDetail = {
export const mockWorkflowEmpty: Workflow = {
id: 7,
name: 'empty-workflow',
status: {
source: { status: 'unknown' },
artifact: { status: 'unknown' },
target: { status: 'unknown' },
},
artifacts: [],
};
const mockWorkflows: Record<number, WorkflowDetail> = {
const mockWorkflows: Record<number, Workflow> = {
1: mockWorkflowHealthy,
2: mockWorkflowSourceError,
3: mockWorkflowArtifactError,
@@ -200,6 +247,6 @@ const mockWorkflows: Record<number, WorkflowDetail> = {
7: mockWorkflowEmpty,
};
export function getWorkflowMock(id: number): WorkflowDetail | undefined {
export function getWorkflowMock(id: number): Workflow | undefined {
return mockWorkflows[id];
}
+3 -17
View File
@@ -1,11 +1,9 @@
import {
WorkflowsArtifactDetail,
WorkflowsArtifactFileDetail,
WorkflowsWorkflowDetail,
WorkflowsWorkflow,
} from '@api/types.gen';
import { RepoConfigResponse } from '@/react/portainer/gitops/types';
export type WorkflowStatus =
| 'healthy'
| 'error'
@@ -37,19 +35,6 @@ export interface WorkflowTarget {
resolvedEndpointIds?: number[];
}
export interface Workflow {
id: number;
name: string;
type: WorkflowType;
platform: DeploymentPlatform;
status: WorkflowStatusObject;
sourceId?: number;
gitConfig?: RepoConfigResponse;
target: WorkflowTarget;
creationDate: number;
lastSyncDate: number;
}
export interface WorkflowFileRef {
sourceId: number;
path: string;
@@ -65,6 +50,7 @@ export type WorkflowArtifactFile = WorkflowsArtifactFileDetail & {
sourceId: number;
};
export type WorkflowDetail = Omit<WorkflowsWorkflowDetail, 'artifacts'> & {
export type Workflow = Omit<WorkflowsWorkflow, 'artifacts' | 'status'> & {
status: WorkflowStatusObject;
artifacts: WorkflowArtifact[];
};
@@ -1,8 +1,8 @@
import { StackType } from '@/react/common/stacks/types';
import { DeploymentPlatform, Workflow, WorkflowType } from './types';
import { DeploymentPlatform, WorkflowType } from './types';
export function getWorkflowLink(item: Workflow): {
export function getWorkflowLink(item: { id: number }): {
to: string;
params: object;
} {