mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 10:54:48 +00:00
refactor(gitops): support multiple artifacts per workflow [BE-13197] (#3156)
This commit is contained in:
+68
-39
@@ -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
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
} {
|
||||
|
||||
Reference in New Issue
Block a user