Compare commits

...

16 Commits

Author SHA1 Message Date
portainer-bot[bot]
0b8d0db0be fix(api/workflows): kubernetes UAC (#2507)
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2026-04-29 22:48:43 +00:00
Xing
c52767fb04 fix(test): isolate registry config in OCI client tests to fix env-dependent failures [C9S-119] (#2497)
Co-authored-by: nickl-portainer <nicholas.loomans@portainer.io>
2026-04-30 09:33:09 +12:00
RHCowan
8e39a16172 fix(agent): correct Podman container engine header in sync edge client [BE-12887] (#2498) (#2506) 2026-04-30 09:03:20 +12:00
LP B
e964be75db fix(api/workflows): move filterK8SStacks outside of transaction (#2504) 2026-04-29 17:57:02 +02:00
Cara Ryan
6776b01ac8 fix(home):CE group by health down discrepancies between headings and list [C9S-139] (#2484) 2026-04-28 15:42:56 +12:00
bernard-portainer
b96031965a fix(environmentlist) use nevironment card in home view [C9S-42] (#2483) 2026-04-28 15:38:15 +12:00
Cara Ryan
b2a2e5c222 feat(home): environment home page ui improvements to highlight groups [C9S-23] (#2453)
Signed-off-by: Bernard Setz <bernard.setz@portainer.io>
Co-authored-by: bernard-portainer <bernard.setz@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Josiah Clumont <josiah.clumont@portainer.io>
Co-authored-by: Dakota Walsh <101994734+dakota-portainer@users.noreply.github.com>
2026-04-28 13:39:48 +12:00
LP B
27285a94ac feat(api/gitops): list and filter kubernetes git workflows (#2465) 2026-04-27 15:19:17 -03:00
Chaim Lev-Ari
b3f01973ec fix(ui/sortable-list): remove 1 as page size option [BE-12900] (#2470) 2026-04-27 17:01:08 +03:00
Chaim Lev-Ari
17ffd62480 feat(gitops): show live git validity status in workflow overview [BE-12885] (#2467)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 13:11:52 +03:00
Chaim Lev-Ari
86f6aba362 fix(gitops): align list component with current design [BE-12888] (#2445) 2026-04-26 16:54:51 +03:00
Chaim Lev-Ari
718e11ccd0 fix(kube/stacks): allow empty stack name [BE-12889] (#2446) 2026-04-26 12:14:53 +03:00
Josiah Clumont
e68b0e80f1 feat(recommendations): completeness recommendations [C9S-18] (#2262) (#2454) 2026-04-24 14:55:15 +12:00
Ali
9a14f2acb7 feat(docker): add docker builder prune as option [C9S-128] (#2451) 2026-04-24 10:21:32 +12:00
Ali
01ff1486e0 fix(ui): use uuidv4 instead of cryptorandomuuid to support non-secure browsers [c9s-133] (#2433) 2026-04-24 08:41:48 +12:00
andres-portainer
b91f77a554 feat(gitops): introduce workflows view [BE-12807] (#2391) (#2428)
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
2026-04-22 14:37:04 -03:00
145 changed files with 8415 additions and 1007 deletions

View File

@@ -1,6 +1,8 @@
/* eslint-disable */
/* tslint:disable */
import { v4 as uuidv4 } from 'uuid';
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
@@ -109,7 +111,7 @@ addEventListener('fetch', function (event) {
return;
}
const requestId = crypto.randomUUID();
const requestId = uuidv4();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});

View File

@@ -0,0 +1,59 @@
package workflows
import (
"context"
"fmt"
"path"
"slices"
)
// ListRefsFunc lists all git refs for a repository.
type ListRefsFunc func(ctx context.Context) ([]string, error)
// ListFilesFunc lists files in a repository branch filtered by extension.
type ListFilesFunc func(ctx context.Context, exts []string) ([]string, error)
// ComputeGitPhases checks source (ref reachability) and artifact (config file presence).
// If source fails, artifact is returned as unknown without making a network call.
func ComputeGitPhases(ctx context.Context, referenceName, configFilePath string, listRefs ListRefsFunc, listFiles ListFilesFunc) (source, artifact WorkflowPhaseStatus) {
source = computeSourcePhase(ctx, referenceName, listRefs)
if source.Status == StatusError {
return source, WorkflowPhaseStatus{Status: StatusUnknown}
}
return source, computeArtifactPhase(ctx, configFilePath, listFiles)
}
func computeSourcePhase(ctx context.Context, referenceName string, listRefs ListRefsFunc) WorkflowPhaseStatus {
refs, err := listRefs(ctx)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
if referenceName == "" {
return WorkflowPhaseStatus{Status: StatusHealthy}
}
if !slices.Contains(refs, referenceName) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("ref %q not found", referenceName)}
}
return WorkflowPhaseStatus{Status: StatusHealthy}
}
func computeArtifactPhase(ctx context.Context, configFilePath string, listFiles ListFilesFunc) WorkflowPhaseStatus {
if configFilePath == "" {
return WorkflowPhaseStatus{Status: StatusError, Error: "no config file path specified"}
}
ext := path.Ext(configFilePath)
var exts []string
if len(ext) > 0 {
ext = ext[1:]
exts = []string{ext}
}
files, err := listFiles(ctx, exts)
if err != nil {
return WorkflowPhaseStatus{Status: StatusError, Error: err.Error()}
}
if !slices.Contains(files, configFilePath) {
return WorkflowPhaseStatus{Status: StatusError, Error: fmt.Sprintf("file %q not found", configFilePath)}
}
return WorkflowPhaseStatus{Status: StatusHealthy}
}

View File

@@ -0,0 +1,162 @@
package workflows
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestComputeGitPhases(t *testing.T) {
t.Parallel()
okRefs := func(_ context.Context) ([]string, error) {
return []string{"refs/heads/main"}, nil
}
okFiles := func(_ context.Context, _ []string) ([]string, error) {
return []string{"docker-compose.yml"}, nil
}
errRefs := func(_ context.Context) ([]string, error) {
return nil, errors.New("connection refused")
}
errFiles := func(_ context.Context, _ []string) ([]string, error) {
return nil, errors.New("connection refused")
}
cases := []struct {
name string
referenceName string
configFilePath string
listRefs ListRefsFunc
listFiles ListFilesFunc
expectedSource Status
expectedArtifact Status
}{
{
name: "listRefs errors → source error, artifact unknown",
referenceName: "refs/heads/main",
configFilePath: "docker-compose.yml",
listRefs: errRefs,
listFiles: okFiles,
expectedSource: StatusError,
expectedArtifact: StatusUnknown,
},
{
name: "ref not in list → source error, artifact unknown",
referenceName: "refs/heads/missing",
configFilePath: "docker-compose.yml",
listRefs: func(_ context.Context) ([]string, error) {
return []string{"refs/heads/main"}, nil
},
listFiles: okFiles,
expectedSource: StatusError,
expectedArtifact: StatusUnknown,
},
{
name: "empty configFilePath → artifact error",
referenceName: "refs/heads/main",
configFilePath: "",
listRefs: okRefs,
listFiles: okFiles,
expectedSource: StatusHealthy,
expectedArtifact: StatusError,
},
{
name: "listFiles errors → artifact error",
referenceName: "refs/heads/main",
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: errFiles,
expectedSource: StatusHealthy,
expectedArtifact: StatusError,
},
{
name: "file not in list → artifact error",
referenceName: "refs/heads/main",
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: func(_ context.Context, _ []string) ([]string, error) {
return []string{"other.yml"}, nil
},
expectedSource: StatusHealthy,
expectedArtifact: StatusError,
},
{
name: "both healthy",
referenceName: "refs/heads/main",
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: okFiles,
expectedSource: StatusHealthy,
expectedArtifact: StatusHealthy,
},
{
name: "empty referenceName → source healthy (default HEAD)",
referenceName: "",
configFilePath: "docker-compose.yml",
listRefs: okRefs,
listFiles: okFiles,
expectedSource: StatusHealthy,
expectedArtifact: StatusHealthy,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
source, artifact := ComputeGitPhases(t.Context(), tc.referenceName, tc.configFilePath, tc.listRefs, tc.listFiles)
assert.Equal(t, tc.expectedSource, source.Status)
assert.Equal(t, tc.expectedArtifact, artifact.Status)
})
}
}
func TestComputeArtifactPhase_ExtensionFilter(t *testing.T) {
t.Parallel()
cases := []struct {
configPath string
wantExts []string
}{
{"docker-compose.yml", []string{"yml"}},
{"stack.yaml", []string{"yaml"}},
{"subdir/compose.yml", []string{"yml"}},
{"Makefile", nil},
{"archive.tar.gz", []string{"gz"}},
}
for _, tc := range cases {
t.Run(tc.configPath, func(t *testing.T) {
t.Parallel()
var capturedExts []string
ComputeGitPhases(
t.Context(),
"",
tc.configPath,
func(_ context.Context) ([]string, error) { return nil, nil },
func(_ context.Context, exts []string) ([]string, error) {
capturedExts = exts
return []string{tc.configPath}, nil
},
)
assert.Equal(t, tc.wantExts, capturedExts)
})
}
}
func TestComputeGitPhases_ArtifactNotCalledOnSourceError(t *testing.T) {
t.Parallel()
listFilesCalled := false
listRefs := func(_ context.Context) ([]string, error) {
return nil, errors.New("repo unreachable")
}
listFiles := func(_ context.Context, _ []string) ([]string, error) {
listFilesCalled = true
return nil, nil
}
ComputeGitPhases(t.Context(), "refs/heads/main", "docker-compose.yml", listRefs, listFiles)
assert.False(t, listFilesCalled, "listFiles must not be called when source fails")
}

View File

@@ -0,0 +1,137 @@
package workflows
import (
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
// MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately
// because EE embeds a different GitConfig type that shadows the CE field.
// source and artifact are the pre-computed git phase statuses from the caller.
func MapStackToWorkflow(s portainer.Stack, gitConfig *gittypes.RepoConfig, source, artifact WorkflowPhaseStatus) Workflow {
return Workflow{
ID: int(s.ID),
Name: s.Name,
Type: TypeStack,
Platform: platformFromStackType(s.Type),
Status: WorkflowStatusObject{
Source: source,
Artifact: artifact,
Target: deriveStackTargetState(s),
},
GitConfig: gitConfig,
Target: Target{
EndpointID: s.EndpointID,
Namespace: s.Namespace,
},
CreationDate: s.CreationDate,
LastSyncDate: stackLastSyncDate(s),
}
}
// MapEdgeStackToWorkflow converts an edge stack to a Workflow. gitConfig is passed separately
// because EE embeds a different GitConfig type that shadows the CE field.
// source and artifact are the pre-computed git phase statuses from the caller.
func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConfig, statuses []portainer.EdgeStackStatusForEnv, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID, source, artifact WorkflowPhaseStatus) Workflow {
platform := DeploymentPlatformDockerStandalone
if es.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
platform = DeploymentPlatformKubernetes
}
return Workflow{
ID: int(es.ID),
Name: es.Name,
Type: TypeEdgeStack,
Platform: platform,
Status: WorkflowStatusObject{
Source: source,
Artifact: artifact,
Target: deriveEdgeStackTargetState(statuses),
},
GitConfig: gitConfig,
Target: Target{
EdgeGroupIDs: es.EdgeGroups,
GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints),
},
CreationDate: es.CreationDate,
LastSyncDate: edgeStackLastSyncDate(statuses),
}
}
func stackLastSyncDate(s portainer.Stack) int64 {
for i := len(s.DeploymentStatus) - 1; i >= 0; i-- {
if s.DeploymentStatus[i].Status == portainer.StackStatusActive {
return s.DeploymentStatus[i].Time
}
}
return 0
}
func edgeStackLastSyncDate(statuses []portainer.EdgeStackStatusForEnv) int64 {
var oldest int64
for _, epStatus := range statuses {
last := endpointLastSyncDate(epStatus)
if last == 0 {
return 0
}
if oldest == 0 || last < oldest {
oldest = last
}
}
return oldest
}
func endpointLastSyncDate(epStatus portainer.EdgeStackStatusForEnv) int64 {
for i := len(epStatus.Status) - 1; i >= 0; i-- {
if isEdgeStackHealthyStatus(epStatus.Status[i].Type) {
return epStatus.Status[i].Time
}
}
return 0
}
func platformFromStackType(t portainer.StackType) DeploymentPlatform {
switch t {
case portainer.KubernetesStack:
return DeploymentPlatformKubernetes
case portainer.DockerSwarmStack:
return DeploymentPlatformDockerSwarm
default:
return DeploymentPlatformDockerStandalone
}
}
func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool {
switch t {
case portainer.EdgeStackStatusRunning,
portainer.EdgeStackStatusRolledBack,
portainer.EdgeStackStatusCompleted,
portainer.EdgeStackStatusRemoved,
portainer.EdgeStackStatusRemoteUpdateSuccess:
return true
}
return false
}
func edgeStackTargetStatuses(
groups []portainer.EdgeGroupID,
statuses []portainer.EdgeStackStatusForEnv,
groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID,
) map[portainer.EdgeGroupID]Status {
epMap := make(map[portainer.EndpointID]Status, len(statuses))
for _, s := range statuses {
ws, _ := endpointWorkflowStatus(s)
epMap[s.EndpointID] = ws
}
result := make(map[portainer.EdgeGroupID]Status, len(groups))
for _, gid := range groups {
gStatus := StatusUnknown
for _, epID := range groupEndpoints[gid] {
if ws := epMap[epID]; statusPriority(ws) > statusPriority(gStatus) {
gStatus = ws
}
}
result[gid] = gStatus
}
return result
}

View File

@@ -0,0 +1,149 @@
package workflows
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func TestStackLastSyncDate(t *testing.T) {
t.Parallel()
t.Run("no deployment status", func(t *testing.T) {
t.Parallel()
assert.Equal(t, int64(0), stackLastSyncDate(portainer.Stack{}))
})
t.Run("no active entry", func(t *testing.T) {
t.Parallel()
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusDeploying, Time: 100},
}}
assert.Equal(t, int64(0), stackLastSyncDate(s))
})
t.Run("last entry is active", func(t *testing.T) {
t.Parallel()
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusDeploying, Time: 50},
{Status: portainer.StackStatusActive, Time: 100},
}}
assert.Equal(t, int64(100), stackLastSyncDate(s))
})
t.Run("active followed by non-active returns the active time", func(t *testing.T) {
t.Parallel()
s := portainer.Stack{DeploymentStatus: []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusActive, Time: 100},
{Status: portainer.StackStatusDeploying, Time: 200},
}}
assert.Equal(t, int64(100), stackLastSyncDate(s))
})
}
func TestEdgeStackLastSyncDate(t *testing.T) {
t.Parallel()
t.Run("empty statuses", func(t *testing.T) {
t.Parallel()
assert.Equal(t, int64(0), edgeStackLastSyncDate(nil))
})
t.Run("no healthy status for endpoint", func(t *testing.T) {
t.Parallel()
statuses := []portainer.EdgeStackStatusForEnv{
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
}},
}
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
})
t.Run("single endpoint with healthy status", func(t *testing.T) {
t.Parallel()
statuses := []portainer.EdgeStackStatusForEnv{
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
{Type: portainer.EdgeStackStatusRunning, Time: 200},
}},
}
assert.Equal(t, int64(200), edgeStackLastSyncDate(statuses))
})
t.Run("returns minimum healthy time across endpoints", func(t *testing.T) {
t.Parallel()
statuses := []portainer.EdgeStackStatusForEnv{
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
{Type: portainer.EdgeStackStatusRunning, Time: 300},
}},
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
{Type: portainer.EdgeStackStatusRunning, Time: 100},
}},
}
assert.Equal(t, int64(100), edgeStackLastSyncDate(statuses))
})
t.Run("one endpoint not yet synced returns 0", func(t *testing.T) {
t.Parallel()
statuses := []portainer.EdgeStackStatusForEnv{
{EndpointID: 1, Status: []portainer.EdgeStackDeploymentStatus{
{Type: portainer.EdgeStackStatusRunning, Time: 200},
}},
{EndpointID: 2, Status: []portainer.EdgeStackDeploymentStatus{
{Type: portainer.EdgeStackStatusDeploying, Time: 100},
}},
}
assert.Equal(t, int64(0), edgeStackLastSyncDate(statuses))
})
}
func TestEdgeStackTargetStatuses(t *testing.T) {
t.Parallel()
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
return portainer.EdgeStackStatusForEnv{
EndpointID: id,
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
}
}
t.Run("group with no endpoints is unknown", func(t *testing.T) {
t.Parallel()
result := edgeStackTargetStatuses(
[]portainer.EdgeGroupID{1},
nil,
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {}},
)
assert.Equal(t, StatusUnknown, result[portainer.EdgeGroupID(1)])
})
t.Run("group inherits highest-priority endpoint status", func(t *testing.T) {
t.Parallel()
result := edgeStackTargetStatuses(
[]portainer.EdgeGroupID{1},
[]portainer.EdgeStackStatusForEnv{
ep(1, portainer.EdgeStackStatusRunning),
ep(2, portainer.EdgeStackStatusDeploying),
},
map[portainer.EdgeGroupID][]portainer.EndpointID{1: {1, 2}},
)
assert.Equal(t, StatusSyncing, result[portainer.EdgeGroupID(1)])
})
t.Run("multiple groups tracked separately", func(t *testing.T) {
t.Parallel()
result := edgeStackTargetStatuses(
[]portainer.EdgeGroupID{10, 20},
[]portainer.EdgeStackStatusForEnv{
ep(1, portainer.EdgeStackStatusRunning),
ep(2, portainer.EdgeStackStatusError),
},
map[portainer.EdgeGroupID][]portainer.EndpointID{
10: {1},
20: {2},
},
)
assert.Equal(t, StatusHealthy, result[portainer.EdgeGroupID(10)])
assert.Equal(t, StatusError, result[portainer.EdgeGroupID(20)])
})
}

View File

@@ -0,0 +1,112 @@
package workflows
import portainer "github.com/portainer/portainer/api"
func deriveStackTargetState(s portainer.Stack) WorkflowPhaseStatus {
if len(s.DeploymentStatus) == 0 {
return WorkflowPhaseStatus{Status: StatusHealthy}
}
last := s.DeploymentStatus[len(s.DeploymentStatus)-1]
switch last.Status {
case portainer.StackStatusActive:
return WorkflowPhaseStatus{Status: StatusHealthy}
case portainer.StackStatusError:
return WorkflowPhaseStatus{Status: StatusError, Error: last.Message}
case portainer.StackStatusDeploying:
return WorkflowPhaseStatus{Status: StatusSyncing}
case portainer.StackStatusInactive:
return WorkflowPhaseStatus{Status: StatusPaused}
default:
return WorkflowPhaseStatus{Status: StatusUnknown}
}
}
func deriveEdgeStackTargetState(statuses []portainer.EdgeStackStatusForEnv) WorkflowPhaseStatus {
result := StatusUnknown
for _, epStatus := range statuses {
ws, msg := endpointWorkflowStatus(epStatus)
if ws == StatusError {
return WorkflowPhaseStatus{Status: ws, Error: msg}
}
if statusPriority(ws) > statusPriority(result) {
result = ws
}
}
return WorkflowPhaseStatus{Status: result}
}
func endpointWorkflowStatus(epStatus portainer.EdgeStackStatusForEnv) (Status, string) {
if len(epStatus.Status) == 0 {
return StatusUnknown, ""
}
last := epStatus.Status[len(epStatus.Status)-1]
switch last.Type {
case portainer.EdgeStackStatusError:
return StatusError, last.Error
case portainer.EdgeStackStatusDeploying,
portainer.EdgeStackStatusRollingBack,
portainer.EdgeStackStatusRemoving,
portainer.EdgeStackStatusPending,
portainer.EdgeStackStatusDeploymentReceived,
portainer.EdgeStackStatusAcknowledged,
portainer.EdgeStackStatusImagesPulled:
return StatusSyncing, ""
case portainer.EdgeStackStatusPausedDeploying:
return StatusPaused, ""
case portainer.EdgeStackStatusRunning,
portainer.EdgeStackStatusRolledBack,
portainer.EdgeStackStatusCompleted,
portainer.EdgeStackStatusRemoved,
portainer.EdgeStackStatusRemoteUpdateSuccess:
return StatusHealthy, ""
default:
return StatusUnknown, ""
}
}
// 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
}
if statusPriority(w.Status.Artifact.Status) > statusPriority(s) {
s = w.Status.Artifact.Status
}
return s
}
// CountByStatus counts workflows per effective status and returns a StatusSummary.
func CountByStatus(workflows []Workflow) StatusSummary {
var s StatusSummary
for _, w := range workflows {
switch EffectiveStatus(w) {
case StatusHealthy:
s.Healthy++
case StatusSyncing:
s.Syncing++
case StatusError:
s.Error++
case StatusPaused:
s.Paused++
default:
s.Unknown++
}
}
return s
}
func statusPriority(s Status) int {
switch s {
case StatusError:
return 4
case StatusSyncing:
return 3
case StatusPaused:
return 2
case StatusHealthy:
return 1
default:
return 0
}
}

View File

@@ -0,0 +1,151 @@
package workflows
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func TestEffectiveStatus(t *testing.T) {
t.Parallel()
makeWorkflow := func(source, artifact, target Status) Workflow {
return Workflow{
Status: WorkflowStatusObject{
Source: WorkflowPhaseStatus{Status: source},
Artifact: WorkflowPhaseStatus{Status: artifact},
Target: WorkflowPhaseStatus{Status: target},
},
}
}
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},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, EffectiveStatus(tc.w))
})
}
}
func TestCountByStatus(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},
},
}
}
t.Run("empty list", func(t *testing.T) {
t.Parallel()
assert.Equal(t, StatusSummary{}, CountByStatus(nil))
})
t.Run("single healthy", func(t *testing.T) {
t.Parallel()
assert.Equal(t, StatusSummary{Healthy: 1}, CountByStatus([]Workflow{makeW(StatusHealthy)}))
})
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))
})
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)
})
}
func TestDeriveEdgeStackTargetState(t *testing.T) {
t.Parallel()
ep := func(id portainer.EndpointID, typ portainer.EdgeStackStatusType) portainer.EdgeStackStatusForEnv {
return portainer.EdgeStackStatusForEnv{
EndpointID: id,
Status: []portainer.EdgeStackDeploymentStatus{{Type: typ}},
}
}
cases := []struct {
name string
statuses []portainer.EdgeStackStatusForEnv
want Status
}{
{"empty", nil, StatusUnknown},
{"all per-env status slices empty", []portainer.EdgeStackStatusForEnv{{EndpointID: 1}}, StatusUnknown},
{"running → healthy", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusRunning)}, StatusHealthy},
{"deploying → syncing", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusDeploying)}, StatusSyncing},
{"paused deploying → paused", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusPausedDeploying)}, StatusPaused},
{"error short-circuits", []portainer.EdgeStackStatusForEnv{ep(1, portainer.EdgeStackStatusError)}, StatusError},
{
"error + running → error (short-circuit, order matters)",
[]portainer.EdgeStackStatusForEnv{
ep(1, portainer.EdgeStackStatusError),
ep(2, portainer.EdgeStackStatusRunning),
},
StatusError,
},
{
"syncing beats paused",
[]portainer.EdgeStackStatusForEnv{
ep(1, portainer.EdgeStackStatusPausedDeploying),
ep(2, portainer.EdgeStackStatusDeploying),
},
StatusSyncing,
},
{
"healthy does not downgrade syncing",
[]portainer.EdgeStackStatusForEnv{
ep(1, portainer.EdgeStackStatusDeploying),
ep(2, portainer.EdgeStackStatusRunning),
},
StatusSyncing,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := deriveEdgeStackTargetState(tc.statuses)
assert.Equal(t, tc.want, result.Status)
})
}
}

View File

@@ -0,0 +1,98 @@
package workflows
import (
"fmt"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
)
type Status string
const (
StatusHealthy Status = "healthy"
StatusSyncing Status = "syncing"
StatusError Status = "error"
StatusPaused Status = "paused"
StatusUnknown Status = "unknown"
)
type Type string
const (
TypeStack Type = "stack"
TypeEdgeStack Type = "edgeStack"
)
type DeploymentPlatform string
const (
DeploymentPlatformDockerStandalone DeploymentPlatform = "dockerStandalone"
DeploymentPlatformDockerSwarm DeploymentPlatform = "dockerSwarm"
DeploymentPlatformKubernetes DeploymentPlatform = "kubernetes"
)
func ParseStatus(s string) (Status, error) {
switch Status(s) {
case StatusHealthy, StatusSyncing, StatusError, StatusPaused, StatusUnknown:
return Status(s), nil
}
return "", fmt.Errorf("unknown status %q", s)
}
func ParseType(s string) (Type, error) {
switch Type(s) {
case TypeStack, TypeEdgeStack:
return Type(s), nil
}
return "", fmt.Errorf("unknown type %q", s)
}
func ParsePlatform(s string) (DeploymentPlatform, error) {
switch DeploymentPlatform(s) {
case DeploymentPlatformDockerStandalone, DeploymentPlatformDockerSwarm, DeploymentPlatformKubernetes:
return DeploymentPlatform(s), nil
}
return "", fmt.Errorf("unknown platform %q", s)
}
type Target struct {
EndpointID portainer.EndpointID `json:"endpointId,omitempty"`
Namespace string `json:"namespace,omitempty"`
EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"`
GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"`
}
// WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow.
// All three phases share the Status type; source and artifact only ever emit healthy, error, or unknown.
type WorkflowPhaseStatus struct {
Status Status `json:"status"`
Error string `json:"error,omitempty"`
}
// WorkflowStatusObject is the structured status reported for a workflow.
type WorkflowStatusObject struct {
Source WorkflowPhaseStatus `json:"source"`
Artifact WorkflowPhaseStatus `json:"artifact"`
Target WorkflowPhaseStatus `json:"target"`
}
type Workflow struct {
ID int `json:"id"`
Name string `json:"name"`
Type Type `json:"type"`
Platform DeploymentPlatform `json:"platform"`
Status WorkflowStatusObject `json:"status"`
GitConfig *gittypes.RepoConfig `json:"gitConfig,omitempty"`
Target Target `json:"target"`
CreationDate int64 `json:"creationDate"`
LastSyncDate int64 `json:"lastSyncDate"`
}
type StatusSummary struct {
Healthy int `json:"healthy"`
Syncing int `json:"syncing"`
Error int `json:"error"`
Paused int `json:"paused"`
Unknown int `json:"unknown"`
}

View File

@@ -0,0 +1,65 @@
package workflows
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseStatus(t *testing.T) {
t.Parallel()
for _, valid := range []string{"healthy", "error", "syncing", "paused", "unknown"} {
t.Run(valid, func(t *testing.T) {
t.Parallel()
s, err := ParseStatus(valid)
require.NoError(t, err)
assert.Equal(t, Status(valid), s)
})
}
t.Run("invalid returns error", func(t *testing.T) {
t.Parallel()
_, err := ParseStatus("garbage")
assert.Error(t, err)
})
}
func TestParseType(t *testing.T) {
t.Parallel()
for _, valid := range []string{"stack", "edgeStack"} {
t.Run(valid, func(t *testing.T) {
t.Parallel()
tp, err := ParseType(valid)
require.NoError(t, err)
assert.Equal(t, Type(valid), tp)
})
}
t.Run("invalid returns error", func(t *testing.T) {
t.Parallel()
_, err := ParseType("garbage")
assert.Error(t, err)
})
}
func TestParsePlatform(t *testing.T) {
t.Parallel()
for _, valid := range []string{"dockerStandalone", "dockerSwarm", "kubernetes"} {
t.Run(valid, func(t *testing.T) {
t.Parallel()
p, err := ParsePlatform(valid)
require.NoError(t, err)
assert.Equal(t, DeploymentPlatform(valid), p)
})
}
t.Run("invalid returns error", func(t *testing.T) {
t.Parallel()
_, err := ParsePlatform("garbage")
assert.Error(t, err)
})
}

View File

@@ -32,13 +32,16 @@ func Test_EndpointList_AgentVersion(t *testing.T) {
Type: portainer.AgentOnDockerEnvironment,
Agent: struct {
Version string `example:"1.0.0"`
}{
Version: "1.0.0",
},
}{Version: "1.0.0"},
}
version2Endpoint := portainer.Endpoint{
ID: 2,
GroupID: 1,
Type: portainer.AgentOnDockerEnvironment,
Agent: struct {
Version string `example:"1.0.0"`
}{Version: "2.0.0"},
}
version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.AgentOnDockerEnvironment, Agent: struct {
Version string `example:"1.0.0"`
}{Version: "2.0.0"}}
noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1}
notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1}

View File

@@ -0,0 +1,229 @@
package endpoints
import (
"net/http"
"sort"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
"golang.org/x/mod/semver"
)
type groupCount struct {
GroupID int `json:"groupID"`
GroupName string `json:"groupName"`
Count int `json:"count"`
}
type platformCounts struct {
Docker int `json:"docker"`
Kubernetes int `json:"kubernetes"`
Azure int `json:"azure"`
Podman int `json:"podman"`
}
type healthCounts struct {
Down int `json:"down"`
Outdated int `json:"outdated"`
Up int `json:"up"`
Heartbeat int `json:"heartbeat"`
}
type EnvironmentSummaryCountsResponse struct {
Total int `json:"total"`
Up int `json:"up"`
Down int `json:"down"`
Outdated int `json:"outdated"`
Unassigned int `json:"unassigned"`
ByGroup []groupCount `json:"byGroup"`
ByPlatformType platformCounts `json:"byPlatformType"`
ByHealth healthCounts `json:"byHealth"`
}
const UnassignedGroupID = portainer.EndpointGroupID(1)
// @id EndpointSummaryCounts
// @summary Get environment summary counts
// @description Returns counts of environments by status (up, down) and ungrouped environments (unassigned), plus breakdowns by group, type, and health.
// @description **Access policy**: restricted
// @tags endpoints
// @security ApiKeyAuth
// @security jwt
// @produce json
// @success 200 {object} EnvironmentSummaryCountsResponse "Environment summary counts"
// @failure 500 "Server error"
// @router /endpoints/summary [get]
func (handler *Handler) endpointSummaryCounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var counts EnvironmentSummaryCountsResponse
err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
}
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
settings, err := tx.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
// Filter out untrusted edge endpoints to match the environment list behavior
trustedEndpoints := make([]portainer.Endpoint, 0, len(filteredEndpoints))
for i := range filteredEndpoints {
ep := &filteredEndpoints[i]
if endpointutils.IsEdgeEndpoint(ep) && !ep.UserTrusted {
continue
}
trustedEndpoints = append(trustedEndpoints, filteredEndpoints[i])
}
counts = EnvironmentSummaryCountsResponse{
Total: len(trustedEndpoints),
}
groupCounts := make(map[portainer.EndpointGroupID]int)
platformCounts := platformCounts{}
healthCounts := healthCounts{}
for i := range trustedEndpoints {
endpoint := &trustedEndpoints[i]
switch endpointutils.EndpointPlatformType(endpoint) {
case portainer.DockerPlatformType:
platformCounts.Docker++
case portainer.KubernetesPlatformType:
platformCounts.Kubernetes++
case portainer.AzurePlatformType:
platformCounts.Azure++
case portainer.PodmanPlatformType:
platformCounts.Podman++
case portainer.UnknownPlatformType:
log.Error().Int("endpoint_id", int(endpoint.ID)).Msg("Unknown platform type")
}
groupCounts[endpoint.GroupID]++
if endpoint.GroupID == UnassignedGroupID {
counts.Unassigned++
}
// Both counts.* and healthCounts.* are non-exclusive: an outdated env
// contributes to its connection bucket (Up / Down) and to Outdated.
outdated := isOutdated(endpoint)
status := resolveEndpointStatus(endpoint, settings)
if outdated {
counts.Outdated++
healthCounts.Outdated++
}
switch status {
case statusHeartbeat:
healthCounts.Heartbeat++
healthCounts.Up++
counts.Up++
case statusUp:
healthCounts.Up++
counts.Up++
case statusDown:
healthCounts.Down++
counts.Down++
}
}
counts.ByGroup = parseGroupCounts(groupCounts, endpointGroups)
counts.ByPlatformType = platformCounts
counts.ByHealth = healthCounts
return nil
})
return response.TxResponse(w, counts, err)
}
// iota order overlaps with portainer.EndpointStatus (Up=1, Down=2) so non-edge
// endpoints can pass their Status straight through. statusHeartbeat (0) is
// edge-only.
const (
statusHeartbeat = iota
statusUp
statusDown
)
func resolveEndpointStatus(endpoint *portainer.Endpoint, settings *portainer.Settings) int {
if endpointutils.IsEdgeEndpoint(endpoint) {
if endpointutils.GetHeartbeatStatus(endpoint, settings) {
return statusHeartbeat
}
return statusDown
}
return int(endpoint.Status)
}
func parseGroupCounts(counts map[portainer.EndpointGroupID]int, endpointGroups []portainer.EndpointGroup) []groupCount {
parsedGroupCounts := []groupCount{}
// Build group name lookup
groupNameByID := make(map[portainer.EndpointGroupID]string, len(endpointGroups))
for _, g := range endpointGroups {
groupNameByID[g.ID] = g.Name
}
for groupID, count := range counts {
parsedGroupCounts = append(parsedGroupCounts,
groupCount{
GroupID: int(groupID),
GroupName: groupNameByID[groupID],
Count: count,
})
}
sort.Slice(parsedGroupCounts, func(i, j int) bool {
return parsedGroupCounts[i].GroupID < parsedGroupCounts[j].GroupID
})
return parsedGroupCounts
}
// canonicalizeSemver ensures v has a "v" prefix as required by golang.org/x/mod/semver.
func canonicalizeSemver(v string) string {
v = strings.TrimSpace(v)
if v == "" || strings.HasPrefix(v, "v") {
return v
}
return "v" + v
}
func isOutdated(endpoint *portainer.Endpoint) bool {
if !endpointutils.IsAgentEndpoint(endpoint) {
return false
}
if endpoint.Agent.Version == "" {
return true
}
latestVersion := canonicalizeSemver(portainer.APIVersion)
agentVersion := canonicalizeSemver(endpoint.Agent.Version)
return semver.Compare(agentVersion, latestVersion) < 0
}

View File

@@ -0,0 +1,349 @@
package endpoints
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSummaryCounts(t *testing.T) {
type testEndpoint struct {
endpointType portainer.EndpointType
status portainer.EndpointStatus
groupID portainer.EndpointGroupID
agentVersion string
containerEngine string
userTrusted bool
lastCheckInDate int64
}
currentVersion := portainer.APIVersion
tests := []struct {
name string
endpoints []testEndpoint
expectedCounts EnvironmentSummaryCountsResponse
}{
{
name: "all docker endpoints up",
endpoints: []testEndpoint{
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 0,
// GroupID 2 has no matching EndpointGroup in the test store.
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}},
ByPlatformType: platformCounts{Docker: 2},
ByHealth: healthCounts{Up: 2},
},
},
{
name: "mix of up and down docker endpoints",
endpoints: []testEndpoint{
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion},
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 3, Up: 1, Down: 2, Outdated: 0, Unassigned: 0,
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 3}},
ByPlatformType: platformCounts{Docker: 3},
ByHealth: healthCounts{Down: 2, Up: 1},
},
},
{
name: "unassigned endpoints have groupID 1",
endpoints: []testEndpoint{
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 1, agentVersion: currentVersion},
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 1,
// GroupID 1 is the default "Unassigned" group; GroupID 2 has no match.
ByGroup: []groupCount{{GroupID: 1, GroupName: "Unassigned", Count: 1}, {GroupID: 2, GroupName: "", Count: 1}},
ByPlatformType: platformCounts{Docker: 2},
ByHealth: healthCounts{Up: 2},
},
},
{
name: "mixed scenario with docker and kubernetes types",
endpoints: []testEndpoint{
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 1, agentVersion: currentVersion},
{endpointType: portainer.KubernetesLocalEnvironment, status: portainer.EndpointStatusUp, groupID: 1, agentVersion: currentVersion},
{endpointType: portainer.AgentOnKubernetesEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 4, Up: 2, Down: 2, Outdated: 0, Unassigned: 2,
ByGroup: []groupCount{{GroupID: 1, GroupName: "Unassigned", Count: 2}, {GroupID: 2, GroupName: "", Count: 2}},
ByPlatformType: platformCounts{Docker: 2, Kubernetes: 2},
ByHealth: healthCounts{Down: 2, Up: 2},
},
},
{
name: "outdated endpoints count in both their connection bucket and Outdated",
endpoints: []testEndpoint{
{endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: "2.0.0"},
{endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "2.0.0"},
{endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 3, Up: 2, Down: 1, Outdated: 2, Unassigned: 0,
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 3}},
ByPlatformType: platformCounts{Docker: 3},
ByHealth: healthCounts{Outdated: 2, Up: 2, Down: 1},
},
},
{
name: "azure and podman endpoints counted in platform breakdown",
endpoints: []testEndpoint{
{endpointType: portainer.AzureEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, containerEngine: portainer.ContainerEnginePodman},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 0,
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}},
ByPlatformType: platformCounts{Azure: 1, Podman: 1},
ByHealth: healthCounts{Up: 2},
},
},
{
name: "untrusted edge endpoints are excluded from counts",
endpoints: []testEndpoint{
{endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion},
{endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: false},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 1, Up: 1, Down: 0, Outdated: 0, Unassigned: 0,
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}},
ByPlatformType: platformCounts{Docker: 1},
ByHealth: healthCounts{Up: 1},
},
},
{
name: "trusted edge endpoints classified by heartbeat, not stored status",
endpoints: []testEndpoint{
// Recent check-in: heartbeat alive → counted as Up + Heartbeat.
{endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: true, lastCheckInDate: time.Now().Unix()},
// Stored Up but never checked in: counted as Down.
{endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: true, lastCheckInDate: 0},
},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 2, Up: 1, Down: 1, Outdated: 0, Unassigned: 0,
ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}},
ByPlatformType: platformCounts{Docker: 2},
ByHealth: healthCounts{Up: 1, Down: 1, Heartbeat: 1},
},
},
{
name: "no endpoints returns all zeros",
endpoints: []testEndpoint{},
expectedCounts: EnvironmentSummaryCountsResponse{
Total: 0, Up: 0, Down: 0, Outdated: 0, Unassigned: 0,
ByGroup: []groupCount{},
ByPlatformType: platformCounts{},
ByHealth: healthCounts{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
for i, ep := range tt.endpoints {
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(i + 1),
Name: "env",
Type: ep.endpointType,
Status: ep.status,
GroupID: ep.groupID,
ContainerEngine: ep.containerEngine,
UserTrusted: ep.userTrusted,
LastCheckInDate: ep.lastCheckInDate,
}
endpoint.Agent.Version = ep.agentVersion
err := store.Endpoint().Create(endpoint)
require.NoError(t, err)
}
err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
require.NoError(t, err)
bouncer := testhelpers.NewTestRequestBouncer()
handler := NewHandler(bouncer)
handler.DataStore = store
req := httptest.NewRequest(http.MethodGet, "/endpoints/summary", nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true})
req = req.WithContext(restrictedCtx)
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code, "expected 200 OK")
body, err := io.ReadAll(rr.Body)
require.NoError(t, err)
var counts EnvironmentSummaryCountsResponse
err = json.Unmarshal(body, &counts)
require.NoError(t, err)
assert.Equal(t, tt.expectedCounts.Total, counts.Total, "Total")
assert.Equal(t, tt.expectedCounts.Up, counts.Up, "Up")
assert.Equal(t, tt.expectedCounts.Down, counts.Down, "Down")
assert.Equal(t, tt.expectedCounts.Outdated, counts.Outdated, "Outdated")
assert.Equal(t, tt.expectedCounts.Unassigned, counts.Unassigned, "Unassigned")
assert.Equal(t, tt.expectedCounts.ByPlatformType, counts.ByPlatformType, "ByPlatformType")
assert.Equal(t, tt.expectedCounts.ByHealth, counts.ByHealth, "ByHealth")
// ByGroup is derived from map iteration so order is non-deterministic.
assert.ElementsMatch(t, tt.expectedCounts.ByGroup, counts.ByGroup, "ByGroup")
})
}
}
func TestResolveEndpointStatus(t *testing.T) {
settings := &portainer.Settings{EdgeAgentCheckinInterval: 60}
tests := []struct {
name string
endpoint *portainer.Endpoint
expectedStatus int
}{
{
name: "non-edge endpoint returns stored up status",
endpoint: &portainer.Endpoint{
Type: portainer.DockerEnvironment,
Status: portainer.EndpointStatusUp,
},
expectedStatus: statusUp,
},
{
name: "non-edge endpoint returns stored down status",
endpoint: &portainer.Endpoint{
Type: portainer.DockerEnvironment,
Status: portainer.EndpointStatusDown,
},
expectedStatus: statusDown,
},
{
name: "edge endpoint with recent check-in returns heartbeat",
endpoint: &portainer.Endpoint{
Type: portainer.EdgeAgentOnDockerEnvironment,
Status: portainer.EndpointStatusUp,
LastCheckInDate: time.Now().Unix(),
},
expectedStatus: statusHeartbeat,
},
{
name: "edge endpoint with stale check-in returns down regardless of stored status",
endpoint: &portainer.Endpoint{
Type: portainer.EdgeAgentOnDockerEnvironment,
Status: portainer.EndpointStatusUp,
LastCheckInDate: 0,
},
expectedStatus: statusDown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectedStatus, resolveEndpointStatus(tt.endpoint, settings))
})
}
}
func TestIsOutdated(t *testing.T) {
currentVersion := portainer.APIVersion
tests := []struct {
name string
version string
expected bool
}{
{name: "empty version is outdated", version: "", expected: true},
{name: "old version is outdated", version: "2.0.0", expected: true},
{name: "v-prefixed old version is outdated", version: "v2.0.0", expected: true},
{name: "current version is not outdated", version: currentVersion, expected: false},
{name: "v-prefixed current version is not outdated", version: "v" + currentVersion, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ep := &portainer.Endpoint{Type: portainer.AgentOnDockerEnvironment}
ep.Agent.Version = tt.version
assert.Equal(t, tt.expected, isOutdated(ep))
})
}
}
func TestParseGroupCounts(t *testing.T) {
groups := []portainer.EndpointGroup{
{ID: 1, Name: "Unassigned"},
{ID: 3, Name: "Production"},
{ID: 2, Name: "Staging"},
}
tests := []struct {
name string
counts map[portainer.EndpointGroupID]int
expected []groupCount
}{
{
name: "empty counts returns empty slice",
counts: map[portainer.EndpointGroupID]int{},
expected: []groupCount{},
},
{
name: "results are sorted by GroupID ascending",
counts: map[portainer.EndpointGroupID]int{
3: 5,
1: 2,
2: 8,
},
expected: []groupCount{
{GroupID: 1, GroupName: "Unassigned", Count: 2},
{GroupID: 2, GroupName: "Staging", Count: 8},
{GroupID: 3, GroupName: "Production", Count: 5},
},
},
{
name: "group with no matching name gets empty string",
counts: map[portainer.EndpointGroupID]int{
99: 1,
},
expected: []groupCount{
{GroupID: 99, GroupName: "", Count: 1},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseGroupCounts(tt.counts, groups)
assert.Equal(t, tt.expected, got)
})
}
}
func TestCanonicalizeSemver(t *testing.T) {
assert.Equal(t, "v2.0.0", canonicalizeSemver("2.0.0"))
assert.Equal(t, "v2.0.0", canonicalizeSemver("v2.0.0"))
assert.Empty(t, canonicalizeSemver(""))
assert.Empty(t, canonicalizeSemver(" "))
}

View File

@@ -15,6 +15,8 @@ import (
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/roar"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors"
@@ -23,6 +25,7 @@ import (
type EnvironmentsQuery struct {
search string
types []portainer.EndpointType
platformTypes []portainer.PlatformType
tagIds []portainer.TagID
endpointIds []portainer.EndpointID
tagsPartialMatch bool
@@ -34,6 +37,7 @@ type EnvironmentsQuery struct {
excludeSnapshots bool
name string
agentVersions []string
outdated bool
edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
@@ -64,6 +68,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
platformTypes, err := getNumberArrayQueryParameter[portainer.PlatformType](r, "platformTypes")
if err != nil {
return EnvironmentsQuery{}, err
}
tagIDs, err := getNumberArrayQueryParameter[portainer.TagID](r, "tagIds")
if err != nil {
return EnvironmentsQuery{}, err
@@ -98,6 +107,8 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
agentVersions := getArrayQueryParameter(r, "agentVersions")
outdated, _ := request.RetrieveBooleanQueryParameter(r, "outdated", true)
name, _ := request.RetrieveQueryParameter(r, "name", true)
var edgeAsync *bool
@@ -122,6 +133,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{
search: search,
types: endpointTypes,
platformTypes: platformTypes,
tagIds: tagIDs,
endpointIds: endpointIDs,
excludeIds: excludeIDs,
@@ -134,6 +146,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
excludeSnapshots: excludeSnapshots,
name: name,
agentVersions: agentVersions,
outdated: outdated,
edgeCheckInPassedSeconds: edgeCheckInPassedSeconds,
edgeStackId: portainer.EdgeStackID(edgeStackId),
edgeStackStatus: edgeStackStatus,
@@ -249,6 +262,10 @@ func (handler *Handler) filterEndpointsByQuery(
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, query.types)
}
if len(query.platformTypes) > 0 {
filteredEndpoints = filterEndpointsByPlatform(filteredEndpoints, query.platformTypes)
}
if len(query.tagIds) > 0 {
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, query.tagIds, groups, query.tagsPartialMatch)
}
@@ -258,6 +275,13 @@ func (handler *Handler) filterEndpointsByQuery(
return !endpointutils.IsAgentEndpoint(&endpoint) || slices.Contains(query.agentVersions, endpoint.Agent.Version)
})
}
if query.outdated {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return isOutdated(&endpoint)
})
}
if query.edgeStackId != 0 {
f, err := filterEndpointsByEdgeStack(filteredEndpoints, query.edgeStackId, query.edgeStackStatus, handler.DataStore)
if err != nil {
@@ -553,20 +577,19 @@ func edgeGroupMatchSearchCriteria(
}
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []portainer.EndpointType) []portainer.Endpoint {
typeSet := map[portainer.EndpointType]bool{}
for _, endpointType := range endpointTypes {
typeSet[endpointType] = true
}
typeSet := set.ToSet(endpointTypes)
n := 0
for _, endpoint := range endpoints {
if typeSet[endpoint.Type] {
endpoints[n] = endpoint
n++
}
}
return slicesx.Filter(endpoints, func(e portainer.Endpoint) bool {
return typeSet[e.Type]
})
}
return endpoints[:n]
func filterEndpointsByPlatform(endpoints []portainer.Endpoint, platformTypes []portainer.PlatformType) []portainer.Endpoint {
typeSet := set.ToSet(platformTypes)
return slicesx.Filter(endpoints, func(e portainer.Endpoint) bool {
return typeSet[endpointutils.EndpointPlatformType(&e)]
})
}
func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {

View File

@@ -549,3 +549,180 @@ func TestGetShortestAsyncInterval(t *testing.T) {
require.Equal(t, 10, getShortestAsyncInterval(endpoint, settings))
}
func Test_filterEndpointsByPlatform(t *testing.T) {
ep := func(id portainer.EndpointID, epType portainer.EndpointType, containerEngine string) portainer.Endpoint {
return portainer.Endpoint{
ID: id,
Type: epType,
ContainerEngine: containerEngine,
}
}
docker := ep(1, portainer.DockerEnvironment, portainer.ContainerEngineDocker)
agentDocker := ep(2, portainer.AgentOnDockerEnvironment, portainer.ContainerEngineDocker)
edgeAgentDocker := ep(3, portainer.EdgeAgentOnDockerEnvironment, portainer.ContainerEngineDocker)
podman := ep(4, portainer.DockerEnvironment, portainer.ContainerEnginePodman)
agentPodman := ep(5, portainer.AgentOnDockerEnvironment, portainer.ContainerEnginePodman)
edgeAgentPodman := ep(6, portainer.EdgeAgentOnDockerEnvironment, portainer.ContainerEnginePodman)
k8sLocal := ep(7, portainer.KubernetesLocalEnvironment, "")
agentK8s := ep(8, portainer.AgentOnKubernetesEnvironment, "")
edgeAgentK8s := ep(9, portainer.EdgeAgentOnKubernetesEnvironment, "")
azure := ep(10, portainer.AzureEnvironment, "")
type args struct {
endpoints []portainer.Endpoint
platformTypes []portainer.PlatformType
}
tests := []struct {
name string
args args
want []portainer.Endpoint
}{
// Docker platform types
{
name: "DockerEnvironment is Docker platform",
args: args{endpoints: []portainer.Endpoint{docker}, platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}},
want: []portainer.Endpoint{docker},
},
{
name: "AgentOnDockerEnvironment is Docker platform",
args: args{endpoints: []portainer.Endpoint{agentDocker}, platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}},
want: []portainer.Endpoint{agentDocker},
},
{
name: "EdgeAgentOnDockerEnvironment is Docker platform",
args: args{endpoints: []portainer.Endpoint{edgeAgentDocker}, platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}},
want: []portainer.Endpoint{edgeAgentDocker},
},
// Podman platform types
{
name: "DockerEnvironment with Podman engine is Podman platform",
args: args{endpoints: []portainer.Endpoint{podman}, platformTypes: []portainer.PlatformType{portainer.PodmanPlatformType}},
want: []portainer.Endpoint{podman},
},
{
name: "AgentOnDockerEnvironment with Podman engine is Podman platform",
args: args{endpoints: []portainer.Endpoint{agentPodman}, platformTypes: []portainer.PlatformType{portainer.PodmanPlatformType}},
want: []portainer.Endpoint{agentPodman},
},
{
name: "EdgeAgentOnDockerEnvironment with Podman engine is Podman platform",
args: args{endpoints: []portainer.Endpoint{edgeAgentPodman}, platformTypes: []portainer.PlatformType{portainer.PodmanPlatformType}},
want: []portainer.Endpoint{edgeAgentPodman},
},
// Kubernetes platform types
{
name: "KubernetesLocalEnvironment is Kubernetes platform",
args: args{endpoints: []portainer.Endpoint{k8sLocal}, platformTypes: []portainer.PlatformType{portainer.KubernetesPlatformType}},
want: []portainer.Endpoint{k8sLocal},
},
{
name: "AgentOnKubernetesEnvironment is Kubernetes platform",
args: args{endpoints: []portainer.Endpoint{agentK8s}, platformTypes: []portainer.PlatformType{portainer.KubernetesPlatformType}},
want: []portainer.Endpoint{agentK8s},
},
{
name: "EdgeAgentOnKubernetesEnvironment is Kubernetes platform",
args: args{endpoints: []portainer.Endpoint{edgeAgentK8s}, platformTypes: []portainer.PlatformType{portainer.KubernetesPlatformType}},
want: []portainer.Endpoint{edgeAgentK8s},
},
// Azure platform type
{
name: "AzureEnvironment is Azure platform",
args: args{endpoints: []portainer.Endpoint{azure}, platformTypes: []portainer.PlatformType{portainer.AzurePlatformType}},
want: []portainer.Endpoint{azure},
},
// Filter behaviour
{
name: "filters out non-matching platform types",
args: args{
endpoints: []portainer.Endpoint{docker, k8sLocal, azure},
platformTypes: []portainer.PlatformType{portainer.DockerPlatformType},
},
want: []portainer.Endpoint{docker},
},
{
name: "multiple platform types returns all matches",
args: args{
endpoints: []portainer.Endpoint{docker, agentDocker, edgeAgentDocker, podman, k8sLocal, agentK8s, edgeAgentK8s, azure},
platformTypes: []portainer.PlatformType{portainer.DockerPlatformType, portainer.KubernetesPlatformType},
},
want: []portainer.Endpoint{docker, agentDocker, edgeAgentDocker, k8sLocal, agentK8s, edgeAgentK8s},
},
{
name: "Podman endpoints not returned when filtering for Docker",
args: args{
endpoints: []portainer.Endpoint{docker, podman, agentPodman},
platformTypes: []portainer.PlatformType{portainer.DockerPlatformType},
},
want: []portainer.Endpoint{docker},
},
{
name: "returns empty when no endpoints match filter",
args: args{
endpoints: []portainer.Endpoint{k8sLocal, azure},
platformTypes: []portainer.PlatformType{portainer.DockerPlatformType},
},
want: []portainer.Endpoint{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, filterEndpointsByPlatform(tt.args.endpoints, tt.args.platformTypes), "filterEndpointsByPlatform(%v, %v)", tt.args.endpoints, tt.args.platformTypes)
})
}
}
func Test_FilterQuery_PlatformTypes(t *testing.T) {
t.Parallel()
dockerEndpoint := portainer.Endpoint{ID: 1, GroupID: 1, Type: portainer.DockerEnvironment}
kubernetesEndpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.KubernetesLocalEnvironment}
azureEndpoint := portainer.Endpoint{ID: 3, GroupID: 1, Type: portainer.AzureEnvironment}
endpoints := []portainer.Endpoint{dockerEndpoint, kubernetesEndpoint, azureEndpoint}
handler := setupFilterTest(t, endpoints)
tests := []filterTest{
{
title: "platformTypes filter returns only matching platform",
expected: []portainer.EndpointID{dockerEndpoint.ID},
query: EnvironmentsQuery{platformTypes: []portainer.PlatformType{portainer.DockerPlatformType}},
},
{
title: "multiple platformTypes returns all matching platforms",
expected: []portainer.EndpointID{dockerEndpoint.ID, kubernetesEndpoint.ID},
query: EnvironmentsQuery{platformTypes: []portainer.PlatformType{portainer.DockerPlatformType, portainer.KubernetesPlatformType}},
},
}
runTests(tests, t, handler, endpoints)
}
func Test_FilterQuery_Outdated(t *testing.T) {
t.Parallel()
currentVersion := portainer.APIVersion
upToDateEndpoint := portainer.Endpoint{ID: 1, GroupID: 1, Type: portainer.AgentOnDockerEnvironment}
upToDateEndpoint.Agent.Version = currentVersion
outdatedEndpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.AgentOnDockerEnvironment}
outdatedEndpoint.Agent.Version = "2.0.0"
endpoints := []portainer.Endpoint{upToDateEndpoint, outdatedEndpoint}
handler := setupFilterTest(t, endpoints)
tests := []filterTest{
{
title: "outdated filter returns only outdated endpoints",
expected: []portainer.EndpointID{outdatedEndpoint.ID},
query: EnvironmentsQuery{outdated: true},
},
{
title: "outdated=false returns all endpoints",
expected: []portainer.EndpointID{upToDateEndpoint.ID, outdatedEndpoint.ID},
query: EnvironmentsQuery{outdated: false},
},
}
runTests(tests, t, handler, endpoints)
}

View File

@@ -61,6 +61,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
h.Handle("/endpoints/agent_versions",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
h.Handle("/endpoints/summary",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointSummaryCounts))).Methods(http.MethodGet)
h.Handle("/endpoints/relations", bouncer.AdminAccess(httperror.LoggerHandler(h.updateRelations))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",

View File

@@ -5,6 +5,7 @@ import (
"github.com/fvbommel/sortorder"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/endpointutils"
)
type comp[T any] func(a, b T) int
@@ -61,6 +62,11 @@ func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroup
return stringComp(a.EdgeID, b.EdgeID)
}
case sortKeyPlatformType:
less = func(a, b portainer.Endpoint) int {
return int(endpointutils.EndpointPlatformType(&a) - endpointutils.EndpointPlatformType(&b))
}
}
slices.SortStableFunc(environments, func(a, b portainer.Endpoint) int {
@@ -82,11 +88,12 @@ const (
sortKeyStatus sortKey = "Status"
sortKeyLastCheckInDate sortKey = "LastCheckIn"
sortKeyEdgeID sortKey = "EdgeID"
sortKeyPlatformType sortKey = "PlatformType"
)
func getSortKey(sortField string) sortKey {
fieldAsSortKey := sortKey(sortField)
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID}, fieldAsSortKey) {
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID, sortKeyPlatformType}, fieldAsSortKey) {
return fieldAsSortKey
}

View File

@@ -149,6 +149,16 @@ func TestSortEndpointsByField(t *testing.T) {
environments[3].ID,
},
},
{
name: "sort by platform type ascending groups same-platform types together",
sortField: "PlatformType",
expected: []portainer.EndpointID{
environments[0].ID,
environments[1].ID,
environments[2].ID,
environments[3].ID,
},
},
}
for _, tt := range tests {
@@ -168,3 +178,9 @@ func getEndpointIDs(environments []portainer.Endpoint) []portainer.EndpointID {
return environment.ID
})
}
func TestGetSortKey(t *testing.T) {
assert.Equal(t, sortKey("Name"), getSortKey("Name"))
assert.Equal(t, sortKey("PlatformType"), getSortKey("PlatformType"))
assert.Equal(t, sortKey(""), getSortKey("unknown"))
}

View File

@@ -6,9 +6,12 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
"github.com/portainer/portainer/api/http/handler/gitops/workflows"
)
// Handler is the HTTP handler used to handle git repo operation
@@ -19,7 +22,7 @@ type Handler struct {
fileService portainer.FileService
}
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, gitService portainer.GitService, fileService portainer.FileService) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, gitService portainer.GitService, fileService portainer.FileService, k8sFactory *cli.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
dataStore: dataStore,
@@ -32,5 +35,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
authenticatedRouter.Handle("/gitops/repo/file/preview", httperror.LoggerHandler(h.gitOperationRepoFilePreview)).Methods(http.MethodPost)
workflowsHandler := workflows.NewHandler(dataStore, gitService, k8sFactory)
authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler)
return h
}

View File

@@ -0,0 +1,122 @@
package workflows
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
"github.com/portainer/portainer/api/stacks/stackutils"
)
func endpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool {
switch stackType {
case portainer.DockerSwarmStack:
return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm
case portainer.DockerComposeStack:
return len(ep.Snapshots) == 0 || !ep.Snapshots[0].Swarm
case portainer.KubernetesStack:
return endpointutils.IsKubernetesEndpoint(&ep)
default:
return true
}
}
func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (map[portainer.EndpointID]portainer.Endpoint, error) {
ids := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) portainer.EndpointID { return s.EndpointID }))
endpoints, err := tx.Endpoint().ReadAll(func(ep portainer.Endpoint) bool { return ids[ep.ID] })
if err != nil {
return nil, err
}
m := make(map[portainer.EndpointID]portainer.Endpoint, len(endpoints))
for i := range endpoints {
if err := snapshot.FillSnapshotData(tx, &endpoints[i], false); err != nil {
return nil, fmt.Errorf("unable to fill snapshot data for endpoint %d: %w", endpoints[i].ID, err)
}
m[endpoints[i].ID] = endpoints[i]
}
return m, nil
}
type endpointAccess struct {
isKubeAdmin bool
nonAdminNamespaces []string
}
// filterDockerStacksByAccess filters stacks to only those the current user can access.
func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
if sc.IsAdmin {
return stacks, nil
}
// do not try to check UAC on kube stacks
filtered, dockerStacks := slicesx.Partition(stacks, func(s portainer.Stack) bool { return s.Type == portainer.KubernetesStack })
stackResourceIDSet := set.ToSet(slicesx.Map(dockerStacks, func(s portainer.Stack) string {
return stackutils.ResourceControlID(s.EndpointID, s.Name)
}))
resourceControls, err := tx.ResourceControl().ReadAll(func(rc portainer.ResourceControl) bool {
return rc.Type == portainer.StackResourceControl && stackResourceIDSet[rc.ResourceID]
})
if err != nil {
return nil, err
}
dockerStacks = authorization.DecorateStacks(dockerStacks, resourceControls)
userTeamIDs := authorization.TeamIDs(sc.UserMemberships)
filtered = append(filtered, authorization.FilterAuthorizedStacks(dockerStacks, sc.UserID, userTeamIDs)...)
return filtered, nil
}
func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, ep *portainer.Endpoint) (endpointAccess, error) {
if sc.IsAdmin {
return endpointAccess{isKubeAdmin: true}, nil
}
pcli, err := k8sFactory.GetPrivilegedKubeClient(ep)
if err != nil {
return endpointAccess{}, fmt.Errorf("unable to get privileged kube client for endpoint %d: %w", ep.ID, err)
}
teamIDs := make([]int, 0, len(sc.UserMemberships))
for _, m := range sc.UserMemberships {
teamIDs = append(teamIDs, int(m.TeamID))
}
nonAdminNamespaces, err := pcli.GetNonAdminNamespaces(int(sc.UserID), teamIDs, ep.Kubernetes.Configuration.RestrictDefaultNamespace)
if err != nil {
return endpointAccess{}, fmt.Errorf("unable to retrieve non-admin namespaces for endpoint %d: %w", ep.ID, err)
}
return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil
}
func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) {
result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap))
for epID, ep := range endpointMap {
if !endpointutils.IsKubernetesEndpoint(&ep) {
continue
}
access, err := resolveKubeAccess(k8sFactory, sc, &ep)
if err != nil {
return nil, err
}
result[epID] = access
}
return result, nil
}

View File

@@ -0,0 +1,80 @@
package workflows
import (
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowsList_RBAC_NonAdminNoAccess(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
user := &portainer.User{
ID: 1,
Username: "standard",
Role: portainer.StandardUserRole,
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
}
require.NoError(t, store.User().Create(user))
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"}))
// Stack on endpoint 1 WITHOUT resource control — non-admin cannot see it
require.NoError(t, store.StackService.Create(&portainer.Stack{
ID: 1, Name: "no-rc-stack", EndpointID: 1,
GitConfig: gitConfig("https://github.com/x/no-rc"),
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, ""))
items := decodeWorkflows(t, rr)
assert.Empty(t, items, "non-admin without resource control access should see no stacks")
}
func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
user := &portainer.User{
ID: 1,
Username: "standard",
Role: portainer.StandardUserRole,
PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(),
}
require.NoError(t, store.User().Create(user))
require.NoError(t, store.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "test-env"}))
const stackName = "rc-stack"
require.NoError(t, store.StackService.Create(&portainer.Stack{
ID: 1, Name: stackName, EndpointID: 1,
GitConfig: gitConfig("https://github.com/x/rc"),
}))
require.NoError(t, store.ResourceControl().Create(&portainer.ResourceControl{
ID: 1,
ResourceID: stackutils.ResourceControlID(1, stackName),
Type: portainer.StackResourceControl,
UserAccesses: []portainer.UserResourceAccess{
{UserID: 1, AccessLevel: portainer.ReadWriteAccessLevel},
},
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.StandardUserRole, ""))
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
assert.Equal(t, stackName, items[0].Name)
}

View File

@@ -0,0 +1,32 @@
package workflows
import (
"context"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
wf "github.com/portainer/portainer/api/gitops/workflows"
)
func computeGitPhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact wf.WorkflowPhaseStatus) {
if gitSvc == nil || cfg == nil {
return wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}, wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}
}
username, password := gitCredentials(cfg)
return wf.ComputeGitPhases(ctx, cfg.ReferenceName, cfg.ConfigFilePath,
func(ctx context.Context) ([]string, error) {
return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify)
},
func(ctx context.Context, exts []string) ([]string, error) {
return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, false, false, exts, cfg.TLSSkipVerify)
},
)
}
func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) {
if cfg.Authentication != nil {
return cfg.Authentication.Username, cfg.Authentication.Password
}
return "", ""
}

View File

@@ -0,0 +1,42 @@
package workflows
import (
"net/http"
"time"
gocache "github.com/patrickmn/go-cache"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
)
const (
cacheTTL = 30 * time.Second
cacheCleanupInterval = 10 * time.Minute
)
type Handler struct {
*mux.Router
dataStore dataservices.DataStore
gitService portainer.GitService
cache *gocache.Cache
k8sFactory *cli.ClientFactory
}
func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitService, k8sFactory *cli.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
dataStore: dataStore,
gitService: gitService,
cache: gocache.New(cacheTTL, cacheCleanupInterval),
k8sFactory: k8sFactory,
}
h.Handle("/gitops/workflows", httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
h.Handle("/gitops/workflows/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet)
return h
}

View File

@@ -0,0 +1,42 @@
package workflows
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
// buildWorkflowsReq creates an HTTP GET request with security context pre-populated.
func buildWorkflowsReq(t *testing.T, userID portainer.UserID, role portainer.UserRole, query string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/gitops/workflows?"+query, nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: userID})
req = req.WithContext(ctx)
ctx = security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
UserID: userID,
IsAdmin: security.IsAdminRole(role),
})
return req.WithContext(ctx)
}
// decodeWorkflows decodes a 200 JSON response into a slice of ce.Workflow.
func decodeWorkflows(t *testing.T, rr *httptest.ResponseRecorder) []ce.Workflow {
t.Helper()
require.Equal(t, http.StatusOK, rr.Code, "unexpected status: %s", rr.Body.String())
var items []ce.Workflow
require.NoError(t, json.NewDecoder(rr.Body).Decode(&items))
return items
}
// gitConfig is a convenience constructor for test RepoConfigs.
func gitConfig(url string) *gittypes.RepoConfig {
return &gittypes.RepoConfig{URL: url, ConfigFilePath: "docker-compose.yml"}
}

View File

@@ -0,0 +1,269 @@
package workflows
import (
"cmp"
"context"
"net/http"
"slices"
"strconv"
"strings"
gocache "github.com/patrickmn/go-cache"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
svc "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/http/utils/filters"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/set"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GitOpsWorkflowsList
// @summary List all GitOps workflows
// @description Returns a unified list of all stacks that have GitOps (GitConfig) configured.
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param search query string false "Search term (matches name or repository URL)"
// @param sort query string false "Sort field: name | type | status | creationDate | lastSyncDate"
// @param order query string false "Sort order: asc or desc"
// @param start query int false "Pagination start index"
// @param limit query int false "Pagination limit (0 = unlimited)"
// @param endpointIds query []int false "Filter by environment IDs (e.g. endpointIds[]=1&endpointIds[]=2)"
// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown"
// @param type query string false "Filter by type: stack"
// @param platform query string false "Filter by platform: dockerStandalone | dockerSwarm | kubernetes"
// @success 200 {array} svc.Workflow
// @failure 500 "Server error"
// @router /gitops/workflows [get]
func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
params := filters.ExtractListModifiersQueryParams(r)
endpointIDs, err := request.RetrieveNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds")
if err != nil {
return httperror.BadRequest("Invalid endpointIds parameter", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
key := cacheKey(securityContext, endpointIDs)
items, err := h.getWorkflows(r.Context(), key, securityContext, endpointIDs)
if err != nil {
return httperror.InternalServerError("Unable to retrieve workflows", err)
}
if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" {
s, err := svc.ParseStatus(status)
if err != nil {
return httperror.BadRequest("Invalid status parameter", err)
}
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return svc.EffectiveStatus(i) == s })
}
if workflowType, _ := request.RetrieveQueryParameter(r, "type", true); workflowType != "" {
t, err := svc.ParseType(workflowType)
if err != nil {
return httperror.BadRequest("Invalid type parameter", err)
}
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Type == t })
}
if platform, _ := request.RetrieveQueryParameter(r, "platform", true); platform != "" {
p, err := svc.ParsePlatform(platform)
if err != nil {
return httperror.BadRequest("Invalid platform parameter", err)
}
items = slicesx.FilterInPlace(items, func(i svc.Workflow) bool { return i.Platform == p })
}
results := filters.SearchOrderAndPaginate(items, params, filters.Config[svc.Workflow]{
SearchAccessors: []filters.SearchAccessor[svc.Workflow]{
func(i svc.Workflow) (string, error) { return i.Name, nil },
func(i svc.Workflow) (string, error) {
if i.GitConfig == nil {
return "", nil
}
return i.GitConfig.URL, nil
},
},
SortBindings: []filters.SortBinding[svc.Workflow]{
{Key: "name", Fn: func(a, b svc.Workflow) int { return strings.Compare(a.Name, b.Name) }},
{Key: "type", Fn: func(a, b svc.Workflow) int { return strings.Compare(string(a.Type), string(b.Type)) }},
{Key: "status", Fn: func(a, b svc.Workflow) int {
return strings.Compare(string(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))
}
func redactWorkflowCredentials(items []svc.Workflow) []svc.Workflow {
for i := range items {
if items[i].GitConfig != nil && items[i].GitConfig.Authentication != nil {
gc := *items[i].GitConfig
auth := *gc.Authentication
auth.Password = ""
gc.Authentication = &auth
items[i].GitConfig = &gc
}
}
return items
}
func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) ([]svc.Workflow, error) {
if cached, ok := h.cache.Get(key); ok {
return slices.Clone(cached.([]svc.Workflow)), nil
}
result, err := h.fetchWorkflows(ctx, sc, set.ToSet(endpointIDs))
if err != nil {
return nil, err
}
h.cache.Set(key, result, gocache.DefaultExpiration)
return slices.Clone(result), nil
}
func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) {
var entries []portainer.Stack
var endpointMap map[portainer.EndpointID]portainer.Endpoint
err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID))
})
if err != nil {
return err
}
endpointMap, err = buildEndpointMap(tx, stacks)
if err != nil {
return err
}
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
if err != nil {
return err
}
for i := range stacks {
s := stacks[i]
if ep, ok := endpointMap[s.EndpointID]; ok && !endpointMatchesStackType(ep, s.Type) {
continue
}
entries = append(entries, s)
}
return nil
})
if err != nil {
return nil, err
}
accessMap, err := buildEndpointAccessMap(h.k8sFactory, sc, endpointMap)
if err != nil {
return nil, err
}
entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, accessMap)
if err != nil {
return nil, err
}
items := make([]svc.Workflow, 0, len(entries))
for _, s := range entries {
source, artifact := computeGitPhases(ctx, h.gitService, s.GitConfig)
items = append(items, svc.MapStackToWorkflow(s, s.GitConfig, source, artifact))
}
return items, nil
}
// lookup only if env is kube and either not edge or (edge + not async)
func shouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
return endpointutils.IsKubernetesEndpoint(endpoint) &&
(!endpointutils.IsEdgeEndpoint(endpoint) ||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
}
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
return s.Type == portainer.KubernetesStack
})
groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID {
return s.EndpointID
})
for envID, stacks := range groupedByEnvId {
ep, ok := endpointMap[envID]
if !ok || !shouldPerformEnvLookup(&ep) {
continue
}
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
if err != nil {
return nil, err
}
access := accessMap[envID]
kcl.SetIsKubeAdmin(access.isKubeAdmin)
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
apps, err := kcl.GetApplications("", "")
if err != nil {
return nil, err
}
for _, s := range stacks {
idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool {
return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID))
})
if idx == -1 {
// if we don't find a matching application (deployment/statefulset/daemonset) in the environment workloads
// this workflow (stack) wouldn't show in the Applications list, so we don't keep it
continue
}
app := apps[idx]
s.Name = app.Name
s.Namespace = app.ResourcePool
result = append(result, s)
}
}
return result, nil
}
func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string {
ids := make([]string, len(endpointIDs))
for i, id := range endpointIDs {
ids[i] = strconv.Itoa(int(id))
}
slices.Sort(ids)
teamIDs := make([]string, len(sc.UserMemberships))
for i, membership := range sc.UserMemberships {
teamIDs[i] = strconv.Itoa(int(membership.TeamID))
}
slices.Sort(teamIDs)
return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(ids, ",") + ":" + strings.Join(teamIDs, ",")
}

View File

@@ -0,0 +1,386 @@
package workflows
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowsList_GitConfigFilter(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "gitops-stack",
GitConfig: gitConfig("https://github.com/example/repo"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{ID: 2, Name: "plain-stack"}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
assert.Equal(t, "gitops-stack", items[0].Name)
assert.Equal(t, ce.TypeStack, items[0].Type)
assert.Equal(t, "https://github.com/example/repo", items[0].GitConfig.URL)
assert.Equal(t, "docker-compose.yml", items[0].GitConfig.ConfigFilePath)
}
func TestWorkflowsList_EndpointIDsFilter(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for i := 1; i <= 3; i++ {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: portainer.StackID(i),
Name: fmt.Sprintf("env%d-stack", i),
EndpointID: portainer.EndpointID(i),
GitConfig: gitConfig(fmt.Sprintf("https://github.com/x/%d", i)),
}))
}
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1&endpointIds[]=2"))
items := decodeWorkflows(t, rr)
require.Len(t, items, 2)
names := []string{items[0].Name, items[1].Name}
assert.Contains(t, names, "env1-stack")
assert.Contains(t, names, "env2-stack")
}
func TestWorkflowsList_Pagination(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for i := 1; i <= 5; i++ {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: portainer.StackID(i),
Name: fmt.Sprintf("stack-%d", i),
GitConfig: gitConfig("https://github.com/x/y"),
}))
}
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "start=0&limit=2"))
items := decodeWorkflows(t, rr)
assert.Len(t, items, 2)
assert.Equal(t, "5", rr.Header().Get("X-Total-Count"))
}
func TestWorkflowsList_Search(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for _, s := range []*portainer.Stack{
{ID: 1, Name: "alpha", GitConfig: gitConfig("https://github.com/org/alpha")},
{ID: 2, Name: "beta", GitConfig: gitConfig("https://github.com/org/beta")},
} {
require.NoError(t, tx.Stack().Create(s))
}
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=alpha"))
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
assert.Equal(t, "alpha", items[0].Name)
}
func TestWorkflowsList_SearchByURL(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "stack-org1",
GitConfig: gitConfig("https://github.com/org1/repo"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 2, Name: "stack-org2",
GitConfig: gitConfig("https://github.com/org2/repo"),
}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "search=org1"))
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
assert.Equal(t, "stack-org1", items[0].Name)
}
func TestWorkflowsList_Sort(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
for i, name := range []string{"gamma", "alpha", "beta"} {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: portainer.StackID(i + 1),
Name: name,
GitConfig: gitConfig("https://github.com/x/" + name),
}))
}
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc"))
items := decodeWorkflows(t, rr)
require.Len(t, items, 3)
assert.Equal(t, "gamma", items[0].Name)
assert.Equal(t, "beta", items[1].Name)
assert.Equal(t, "alpha", items[2].Name)
}
// Uses testing/synctest to control time.Now() without real sleeps.
// The Handler is created outside the bubble so its go-cache cleanup goroutine
// does not join the bubble. Inside the bubble all time.Now() calls return
// fake time, so cache.Set stores a fake expiry and cache.Get compares
// against the same fake clock — consistent without touching real time.
func TestWorkflowsList_Cache(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "initial-stack",
GitConfig: gitConfig("https://github.com/x/initial"),
}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
// Create the handler outside the bubble so the go-cache cleanup goroutine
// is not part of the bubble and does not block synctest.Test from returning.
h := NewHandler(store, nil, nil)
synctest.Test(t, func(t *testing.T) {
// First request at fake T=0: populates cache.
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
require.Len(t, decodeWorkflows(t, rr), 1)
// Mutate the store while cache is still warm.
require.NoError(t, store.StackService.Create(&portainer.Stack{
ID: 2, Name: "new-stack",
GitConfig: gitConfig("https://github.com/x/new"),
}))
// Second request — same cache key, should return stale cached result.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
assert.Len(t, decodeWorkflows(t, rr), 1, "cache hit: new stack should not appear yet")
// Advance fake clock past the cache TTL. synctest unblocks immediately
// since no other goroutines are in the bubble.
time.Sleep(cacheTTL + time.Second)
// Third request — cache expired, should now fetch fresh data.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
assert.Len(t, decodeWorkflows(t, rr), 2, "after TTL expiry: both stacks should appear")
})
}
func TestWorkflowsList_CacheImmutableAfterSort(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
for i, name := range []string{"alpha", "beta", "gamma"} {
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(
&portainer.Stack{
ID: portainer.StackID(i + 1),
Name: name,
GitConfig: gitConfig("https://github.com/x/" + name),
},
))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
}
h := NewHandler(store, nil, nil)
// First request: no sort — cache miss, populates cache as [alpha, beta, gamma].
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
items := decodeWorkflows(t, rr)
require.Len(t, items, 3)
require.Equal(t, "alpha", items[0].Name)
// Second request: sort desc — cache hit, sorts the shared slice in-place to [gamma, beta, alpha].
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "sort=name&order=desc"))
items = decodeWorkflows(t, rr)
require.Len(t, items, 3)
require.Equal(t, "gamma", items[0].Name)
// Third request: no sort — should still return insertion order [alpha, beta, gamma],
// but without a defensive clone the mutated cache returns [gamma, beta, alpha].
rr = httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
items = decodeWorkflows(t, rr)
require.Len(t, items, 3)
assert.Equal(t, "alpha", items[0].Name, "sort must not mutate the cached slice")
}
func TestWorkflowsList_CacheSeparateKeys(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "env1-stack", EndpointID: 1,
GitConfig: gitConfig("https://github.com/x/1"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 2, Name: "env2-stack", EndpointID: 2,
GitConfig: gitConfig("https://github.com/x/2"),
}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr1 := httptest.NewRecorder()
h.ServeHTTP(rr1, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=1"))
items1 := decodeWorkflows(t, rr1)
require.Len(t, items1, 1)
assert.Equal(t, "env1-stack", items1[0].Name)
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "endpointIds[]=2"))
items2 := decodeWorkflows(t, rr2)
require.Len(t, items2, 1)
assert.Equal(t, "env2-stack", items2[0].Name)
}
func TestWorkflowsList_StatusFilter(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "healthy-stack",
GitConfig: gitConfig("https://github.com/x/1"),
}))
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 2, Name: "error-stack",
GitConfig: gitConfig("https://github.com/x/2"),
DeploymentStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}},
}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
t.Run("status=healthy returns only healthy workflows", func(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "status=healthy"))
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
assert.Equal(t, "healthy-stack", items[0].Name)
})
t.Run("status=error returns only error workflows", func(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, "status=error"))
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
assert.Equal(t, "error-stack", items[0].Name)
})
}
func TestWorkflowsList_InvalidFilterParams(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
h := NewHandler(store, nil, nil)
for _, query := range []string{"status=garbage", "type=garbage", "platform=garbage"} {
t.Run(query, func(t *testing.T) {
t.Parallel()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, query))
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
}
}
func TestWorkflowsList_RedactsCredentials(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
cfg := gitConfig("https://github.com/x/secure")
cfg.Authentication = &gittypes.GitAuthentication{Username: "user", Password: "s3cr3t"}
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1, Name: "secure-stack", GitConfig: cfg,
}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
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)
}

View File

@@ -0,0 +1,84 @@
package workflows
import (
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
ce "github.com/portainer/portainer/api/gitops/workflows"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowsList_StackStatusDerivation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
deployStatus []portainer.StackDeploymentStatus
expectedStatus ce.Status
}{
{
name: "no deployment status → healthy",
expectedStatus: ce.StatusHealthy,
},
{
name: "active → healthy",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusActive}},
expectedStatus: ce.StatusHealthy,
},
{
name: "error → error",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusError}},
expectedStatus: ce.StatusError,
},
{
name: "deploying → syncing",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusDeploying}},
expectedStatus: ce.StatusSyncing,
},
{
name: "inactive → paused",
deployStatus: []portainer.StackDeploymentStatus{{Status: portainer.StackStatusInactive}},
expectedStatus: ce.StatusPaused,
},
{
name: "last entry wins",
deployStatus: []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusDeploying},
{Status: portainer.StackStatusActive},
},
expectedStatus: ce.StatusHealthy,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Stack().Create(&portainer.Stack{
ID: 1,
Name: "status-stack",
DeploymentStatus: tc.deployStatus,
GitConfig: gitConfig("https://github.com/x/y"),
}))
require.NoError(t, tx.User().Create(&portainer.User{ID: 1, Role: portainer.AdministratorRole}))
return nil
}))
h := NewHandler(store, nil, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, buildWorkflowsReq(t, 1, portainer.AdministratorRole, ""))
items := decodeWorkflows(t, rr)
require.Len(t, items, 1)
assert.Equal(t, tc.expectedStatus, items[0].Status.Target.Status, tc.name)
})
}
}

View File

@@ -0,0 +1,35 @@
package workflows
import (
"net/http"
svc "github.com/portainer/portainer/api/gitops/workflows"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
)
// @id GitOpsWorkflowsSummary
// @summary Summarize GitOps workflow status counts
// @description Returns a count of workflows per status across all environments.
// @description **Access policy**: authenticated
// @tags gitops
// @security ApiKeyAuth
// @security jwt
// @produce json
// @success 200 {object} svc.StatusSummary
// @failure 500 "Server error"
// @router /gitops/workflows/summary [get]
func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
items, err := h.getWorkflows(r.Context(), cacheKey(securityContext, nil), securityContext, nil)
if err != nil {
return httperror.InternalServerError("Unable to retrieve workflows", err)
}
return response.JSON(w, svc.CountByStatus(items))
}

View File

@@ -2,7 +2,6 @@ package kubernetes
import (
"net/http"
"strconv"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
@@ -33,7 +32,7 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err)
}
pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID)))
pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, tokenData.ID)
if err != nil {
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.")
return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err)

View File

@@ -76,7 +76,7 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
userTeamIDs := authorization.TeamIDs(securityContext.UserMemberships)
stacks = authorization.FilterAuthorizedStacks(stacks, user, userTeamIDs)
stacks = authorization.FilterAuthorizedStacks(stacks, user.ID, userTeamIDs)
}
for _, stack := range stacks {

View File

@@ -465,7 +465,11 @@ func (transport *Transport) proxyTaskRequest(request *http.Request, unversionedP
}
}
func (transport *Transport) proxyBuildRequest(request *http.Request, _ string) (*http.Response, error) {
func (transport *Transport) proxyBuildRequest(request *http.Request, unversionedPath string) (*http.Response, error) {
if unversionedPath == "/build/prune" {
return transport.administratorOperation(request)
}
if err := transport.updateDefaultGitBranch(request); err != nil {
return nil, err
}

View File

@@ -608,3 +608,67 @@ func TestTransport_proxyImageRequest_Prune(t *testing.T) {
require.NoError(t, r.Body.Close())
}
}
func TestTransport_proxyBuildRequest_Prune(t *testing.T) {
t.Parallel()
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole}
_, ds := datastore.MustNewTestStore(t, true, false)
require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.User().Create(&admin))
require.NoError(t, tx.User().Create(&std1))
require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env",
UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}},
}))
return nil
}))
srv, version := mockDockerAPIServer(t, RoutesDefinition{
{http.MethodPost, "/build/prune"}: struct {
CachesDeleted []string `json:"CachesDeleted"`
SpaceReclaimed int `json:"SpaceReclaimed"`
}{
CachesDeleted: []string{},
SpaceReclaimed: 0,
},
})
defer srv.Close()
transport := &Transport{
endpoint: &portainer.Endpoint{URL: srv.URL},
dataStore: ds,
HTTPTransport: &http.Transport{},
}
test := func(method string, url string, token portainer.TokenData) (*http.Response, error) {
req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil)
req = req.WithContext(security.StoreTokenData(req, &token))
require.NotNil(t, req)
return transport.proxyBuildRequest(req, url)
}
adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role}
std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role}
// Admin should be able to prune build cache
{
r, err := test(http.MethodPost, "/build/prune", adminToken)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusOK, r.StatusCode)
require.NoError(t, r.Body.Close())
}
// Standard user should NOT be able to prune build cache (administrator operation)
{
r, err := test(http.MethodPost, "/build/prune", std1Token)
require.NoError(t, err)
require.NotNil(t, r)
require.Equal(t, http.StatusForbidden, r.StatusCode)
require.NoError(t, r.Body.Close())
}
}

View File

@@ -208,7 +208,7 @@ func (server *Server) Start(ctx context.Context) error {
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)
var gitOperationHandler = gitops.NewHandler(requestBouncer, server.DataStore, server.GitService, server.FileService)
var gitOperationHandler = gitops.NewHandler(requestBouncer, server.DataStore, server.GitService, server.FileService, server.KubernetesClientFactory)
var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager)

View File

@@ -16,8 +16,9 @@ type SortQueryParams struct {
type SortOption[T any] func(a, b T) int
type SortBinding[T any] struct {
Key string
Fn SortOption[T]
Key string
Fn SortOption[T]
NullsLast func(T) bool // if set, items where this returns true always sort after all others
}
func sortFn[T any](items []T, params SortQueryParams, sorts []SortBinding[T]) []T {
@@ -27,6 +28,9 @@ func sortFn[T any](items []T, params SortQueryParams, sorts []SortBinding[T]) []
if params.order == SortDesc {
fn = reverSortFn(fn)
}
if sort.NullsLast != nil {
fn = nullsLastWrap(fn, sort.NullsLast)
}
slices.SortStableFunc(items, fn)
}
}
@@ -38,3 +42,21 @@ func reverSortFn[T any](fn SortOption[T]) SortOption[T] {
return -1 * fn(a, b)
}
}
// nullsLastWrap wraps a comparator so that items where isNull returns true
// always sort after all others, regardless of sort direction.
func nullsLastWrap[T any](fn SortOption[T], isNull func(T) bool) SortOption[T] {
return func(a, b T) int {
aN, bN := isNull(a), isNull(b)
if aN && bN {
return 0
}
if aN {
return 1
}
if bN {
return -1
}
return fn(a, b)
}
}

View File

@@ -141,11 +141,11 @@ func DecorateCustomTemplates(templates []portainer.CustomTemplate, resourceContr
}
// FilterAuthorizedStacks returns a list of decorated stacks filtered through resource control access checks.
func FilterAuthorizedStacks(stacks []portainer.Stack, user *portainer.User, userTeamIDs []portainer.TeamID) []portainer.Stack {
func FilterAuthorizedStacks(stacks []portainer.Stack, userID portainer.UserID, userTeamIDs []portainer.TeamID) []portainer.Stack {
authorizedStacks := make([]portainer.Stack, 0)
for _, stack := range stacks {
if stack.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, stack.ResourceControl) {
if stack.ResourceControl != nil && UserCanAccessResource(userID, userTeamIDs, stack.ResourceControl) {
authorizedStacks = append(authorizedStacks, stack)
}
}

View File

@@ -47,6 +47,22 @@ func IsAgentEndpoint(endpoint *portainer.Endpoint) bool {
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
}
// EndpointPlatformType returns the type of the endpoint based on the environment and container engine
func EndpointPlatformType(endpoint *portainer.Endpoint) portainer.PlatformType {
switch endpoint.Type {
case portainer.DockerEnvironment, portainer.AgentOnDockerEnvironment, portainer.EdgeAgentOnDockerEnvironment:
if endpoint.ContainerEngine == portainer.ContainerEnginePodman {
return portainer.PodmanPlatformType
}
return portainer.DockerPlatformType
case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment:
return portainer.KubernetesPlatformType
case portainer.AzureEnvironment:
return portainer.AzurePlatformType
}
return portainer.UnknownPlatformType
}
// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param
func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint {
if len(excludeIds) == 0 {
@@ -212,8 +228,12 @@ func UpdateEdgeEndpointHeartbeat(endpoint *portainer.Endpoint, settings *portain
return
}
endpoint.Heartbeat = GetHeartbeatStatus(endpoint, settings)
}
func GetHeartbeatStatus(endpoint *portainer.Endpoint, settings *portainer.Settings) bool {
checkInInterval := getEndpointCheckinInterval(endpoint, settings)
endpoint.Heartbeat = time.Now().Unix()-endpoint.LastCheckInDate <= int64(checkInInterval*2+20)
return time.Now().Unix()-endpoint.LastCheckInDate <= int64(checkInInterval*2+20)
}
func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portainer.Settings) int {

View File

@@ -125,8 +125,8 @@ func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoi
// GetPrivilegedUserKubeClient checks if an existing admin client is already registered for the environment(endpoint) and user and returns it if one is found.
// If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetPrivilegedUserKubeClient(endpoint *portainer.Endpoint, userID string) (*KubeClient, error) {
key := strconv.Itoa(int(endpoint.ID)) + ".admin." + userID
func (factory *ClientFactory) GetPrivilegedUserKubeClient(endpoint *portainer.Endpoint, userID portainer.UserID) (*KubeClient, error) {
key := strconv.Itoa(int(endpoint.ID)) + ".admin." + strconv.Itoa(int(userID))
pcl, ok := factory.endpointProxyClients.Get(key)
if ok {
return pcl.(*KubeClient), nil

View File

@@ -693,6 +693,9 @@ type (
// EndpointType represents the type of an environment(endpoint)
EndpointType int
// PlatformType represents the platform that an agent is running on
PlatformType int
// EndpointRelation represents a environment(endpoint) relation object
EndpointRelation struct {
EndpointID EndpointID
@@ -2142,6 +2145,14 @@ const (
EdgeAgentOnKubernetesEnvironment
)
const (
DockerPlatformType PlatformType = iota
KubernetesPlatformType
AzurePlatformType
PodmanPlatformType
UnknownPlatformType
)
const (
_ JobType = iota
// SnapshotJobType is a system job used to create environment(endpoint) snapshots

View File

@@ -15,7 +15,7 @@ func Filter[T any](input []T, predicate func(T) bool) []T {
return result
}
// Filter in place all elements from input that predicate returns truthy for and returns an array of the removed elements.
// Filter in place all elements from input that predicate returns truthy for.
//
// Note: Unlike `Filter`, this method mutates input.
func FilterInPlace[T any](input []T, predicate func(T) bool) []T {

10
api/slicesx/group_by.go Normal file
View File

@@ -0,0 +1,10 @@
package slicesx
func GroupBy[A any, K comparable](input []A, f func(A) K) map[K][]A {
result := make(map[K][]A)
for _, v := range input {
key := f(v)
result[key] = append(result[key], v)
}
return result
}

View File

@@ -0,0 +1,21 @@
package slicesx_test
import (
"reflect"
"testing"
"github.com/portainer/portainer/api/slicesx"
)
func TestGroupBy(t *testing.T) {
t.Parallel()
input := []string{"apple", "banana", "cherry", "date", "elderberry"}
f := func(a string) int {
return len(a)
}
expected := map[int][]string{5: {"apple"}, 6: {"banana", "cherry"}, 4: {"date"}, 10: {"elderberry"}}
result := slicesx.GroupBy(input, f)
if !reflect.DeepEqual(expected, result) {
t.Errorf("Expected %v, got %v", expected, result)
}
}

19
api/slicesx/partition.go Normal file
View File

@@ -0,0 +1,19 @@
package slicesx
// Split elements in two slices.
// The first contains elements predicate returns truthy for
// The second contains elements predicate returns falsey for
// The predicate is invoked with one argument: (value).
func Partition[T any](input []T, predicate func(T) bool) ([]T, []T) {
truthy := make([]T, 0)
falsey := make([]T, 0)
for i := range input {
if predicate(input[i]) {
truthy = append(truthy, input[i])
} else {
falsey = append(falsey, input[i])
}
}
return truthy, falsey
}

View File

@@ -0,0 +1,32 @@
package slicesx_test
import (
"testing"
"github.com/portainer/portainer/api/slicesx"
)
func partition[T any](input []T, predicate func(T) bool) [2][]T {
left, right := slicesx.Partition(input, predicate)
return [2][]T{left, right}
}
func Test_Partition(t *testing.T) {
t.Parallel()
test(t, partition, "Partition even and odd",
[]int{1, 2, 3, 4, 5, 6, 7, 8, 9},
[2][]int{{2, 4, 6, 8}, {1, 3, 5, 7, 9}},
func(x int) bool { return x%2 == 0 },
)
test(t, partition, "Partition strings starting with 'A'",
[]string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
[2][]string{{"Apple", "Avocado", "Apricot"}, {"Banana", "Grapes"}},
func(s string) bool { return s[0] == 'A' },
)
test(t, partition, "Partition strings longer than 5 chars",
[]string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
[2][]string{{"Banana", "Avocado", "Grapes", "Apricot"}, {"Apple"}},
func(s string) bool { return len(s) > 5 },
)
}

View File

@@ -11,6 +11,7 @@ import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
import { helpersModule } from './helpers';
import { AccessHeaders, requiresAuthHook } from './authorization-guard';
import { filterParam, paginationParams } from './helpers/stateParamHelper';
async function initAuthentication(Authentication) {
return await Authentication.init();
@@ -288,7 +289,7 @@ angular
var home = {
name: 'portainer.home',
url: '/home?redirect&environmentId&environmentName&route',
url: '/home?redirect&environmentId&environmentName&route&groupBy&filter',
views: {
'content@': {
component: 'homeView',
@@ -299,6 +300,22 @@ angular
},
};
var workflows = {
name: 'portainer.workflows',
url: '/workflows?search&sort&order&page&pageSize&status&type&platform',
params: {
...paginationParams('name'),
status: filterParam(),
type: filterParam(),
platform: filterParam(),
},
views: {
'content@': {
component: 'workflowsView',
},
},
};
var init = {
name: 'portainer.init',
abstract: true,
@@ -420,6 +437,7 @@ angular
$stateRegistryProvider.register(groupAccess);
$stateRegistryProvider.register(groupCreation);
$stateRegistryProvider.register(home);
$stateRegistryProvider.register(workflows);
$stateRegistryProvider.register(init);
$stateRegistryProvider.register(initAdmin);
$stateRegistryProvider.register(settings);

View File

@@ -0,0 +1,13 @@
export function filterParam(defaultValue = null) {
return { value: defaultValue, squash: true, dynamic: true };
}
export function paginationParams(defaultSort = 'name') {
return {
search: filterParam(),
sort: filterParam(defaultSort),
order: filterParam('asc'),
page: filterParam('0'),
pageSize: filterParam(),
};
}

View File

@@ -10,6 +10,7 @@ import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeV
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView';
import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView';
import { wizardModule } from './wizard';
import { teamsModule } from './teams';
@@ -66,4 +67,8 @@ export const viewsModule = angular
withUIRouter(withReactQuery(withCurrentUser(CreateHelmRepositoriesView))),
[]
)
)
.component(
'workflowsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(WorkflowsView))), [])
).name;

View File

@@ -85,7 +85,7 @@ export function withPaginationQueryParams({
}
export type PaginatedResults<T> = {
data: T;
data: T | null;
totalCount: number;
totalAvailable: number;
};

View File

@@ -72,7 +72,9 @@ export function InnerForm({
stackName={values.kube?.name ?? ''}
setStackName={(value) => {
setFieldValue('kube.name', value);
setFieldValue('redeployNow', true);
if (value && value !== stackName) {
setFieldValue('redeployNow', true);
}
}}
error={errors.kube?.name}
/>

View File

@@ -60,7 +60,7 @@ export function useUpdateGitStack(stack: Stack) {
await updateGitStack(stack.Id, stack.EndpointId, {
Env: values.env,
Prune: values.prune,
StackName: values.kube.name,
StackName: values.kube.name.trim() || undefined,
RepositoryAuthentication: resolvedAuth.RepositoryAuthentication,
RepositoryGitCredentialID: resolvedAuth.RepositoryGitCredentialID,
RepositoryUsername: resolvedAuth.RepositoryUsername,

View File

@@ -22,7 +22,7 @@ export function useValidationSchema(
object({
kube: isKubernetes
? object({
name: string().required('Stack name is required'),
name: string().default(''),
}).required()
: object({ name: string().default('') }).optional(),
git: buildGitValidationSchema(

View File

@@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Extension } from '@codemirror/state';
import { mockClipboard } from '@/react/test-utils/clipboard';
import { CodeEditor } from './CodeEditor';
const mockExtension: Extension = { extension: [] };
@@ -45,22 +47,17 @@ test('should display placeholder when provided', async () => {
test('should show copy button and copy content', async () => {
const testValue = 'test content';
const { writeText } = mockClipboard();
const { findByText } = render(
<CodeEditor {...defaultProps} value={testValue} />
);
const mockClipboard = {
writeText: vi.fn(),
};
Object.assign(navigator, {
clipboard: mockClipboard,
});
const copyButton = await findByText('Copy');
expect(copyButton).toBeVisible();
await userEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(testValue);
expect(writeText).toHaveBeenCalledWith(testValue);
});
test('should handle read-only mode', async () => {

View File

@@ -17,7 +17,6 @@ test('when edge id is not set, should show unassociated label', async () => {
test('given edge id and last checkin is set, should show heartbeat', async () => {
const { queryByLabelText } = await renderComponent('id', 1);
expect(queryByLabelText('edge-heartbeat')).toBeVisible();
expect(queryByLabelText('edge-last-checkin')).toBeVisible();
});

View File

@@ -2,8 +2,6 @@ import { Activity } from 'lucide-react';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { Environment } from '@/react/portainer/environments/types';
import heartbeatup from '@/assets/ico/heartbeat-up.svg?c';
import heartbeatdown from '@/assets/ico/heartbeat-down.svg?c';
import { EnvironmentStatusBadgeItem } from './EnvironmentStatusBadgeItem';
@@ -16,8 +14,6 @@ export function EdgeIndicator({
environment,
showLastCheckInDate = false,
}: Props) {
const heartbeat = environment.Heartbeat;
const associated = !!environment.EdgeID;
if (!associated) {
return (
@@ -35,14 +31,6 @@ export function EdgeIndicator({
aria-label="edge-status"
className="flex items-center gap-1"
>
<EnvironmentStatusBadgeItem
color={heartbeat ? 'success' : 'danger'}
icon={heartbeat ? heartbeatup : heartbeatdown}
aria-label="edge-heartbeat"
>
heartbeat
</EnvironmentStatusBadgeItem>
{showLastCheckInDate && !!environment.LastCheckInDate && (
<span
className="small text-muted vertical-center"

View File

@@ -1,21 +1,67 @@
import { CheckCircle, XCircle } from 'lucide-react';
import clsx from 'clsx';
import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { EnvironmentStatusBadgeItem } from './EnvironmentStatusBadgeItem';
import {
Environment,
EnvironmentStatus,
} from '@/react/portainer/environments/types';
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
interface Props {
status: EnvironmentStatus;
environment: Environment;
}
export function EnvironmentStatusBadge({ status }: Props) {
return status === EnvironmentStatus.Up ? (
<EnvironmentStatusBadgeItem color="success" icon={CheckCircle}>
Up
</EnvironmentStatusBadgeItem>
export function EnvironmentStatusBadge({ environment }: Props) {
if (isEdgeEnvironment(environment.Type)) {
return (
<EnvironmentStatusBadgeComponent
color={environment.Heartbeat ? 'success' : 'danger'}
text={environment.Heartbeat ? 'Heartbeat' : 'Down'}
heartbeat={environment.Heartbeat}
/>
);
}
return environment.Status === EnvironmentStatus.Up ? (
<EnvironmentStatusBadgeComponent color="success" text="Up" />
) : (
<EnvironmentStatusBadgeItem color="danger" icon={XCircle}>
Down
</EnvironmentStatusBadgeItem>
<EnvironmentStatusBadgeComponent color="danger" text="Down" />
);
}
function EnvironmentStatusBadgeComponent({
color,
text,
heartbeat,
}: {
color: 'danger' | 'success';
text: string;
heartbeat?: boolean;
}) {
return (
<span
className={clsx(
'flex items-center gap-2 rounded-xl',
'w-fit px-2 py-px',
'text-xs font-bold',
{
'bg-success-7/20 text-success-7': color === 'success',
'bg-error-7/20 text-error-7': color === 'danger',
}
)}
aria-label="status-badge"
>
<span
aria-hidden="true"
className={clsx(
'block h-2 w-2 rounded-full',
{ 'animate-pulse': heartbeat },
{
'bg-success-7': color === 'success',
'bg-error-7': color === 'danger',
}
)}
/>
<span>{text}</span>
</span>
);
}

View File

@@ -0,0 +1,29 @@
interface Props {
label: string;
onClear: () => void;
}
export function FilterBarActiveIndicator({ label, onClear }: Props) {
return (
<div
className="flex items-center gap-4 whitespace-nowrap bg-[var(--bg-blocklist-hover-color)] px-5"
data-cy="active-filter-indicator"
>
<span className="text-sm text-[var(--text-muted-color)]">
Showing:{' '}
<span className="font-semibold text-[var(--text-summary-color)]">
{label}
</span>
</span>
<button
type="button"
className="close-button cursor-pointer border-0 bg-transparent p-0 text-[21px] font-bold leading-none text-[var(--button-close-color)] opacity-[var(--button-opacity)] hover:opacity-[var(--button-opacity-hover)]"
onClick={onClear}
aria-label="Clear filter"
data-cy="clear-filter-button"
>
&times;
</button>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterBarButton } from './FilterBarButton';
function renderComponent(
props: Partial<React.ComponentProps<typeof FilterBarButton>> = {}
) {
const defaultProps: React.ComponentProps<typeof FilterBarButton> = {
count: 5,
label: 'Running',
isSelected: false,
onClick: vi.fn(),
name: 'status-filter',
'data-cy': 'filter-bar-button',
...props,
};
return {
...render(<FilterBarButton {...defaultProps} />),
props: defaultProps,
};
}
describe('FilterBarButton', () => {
it('should render count and label, and call onClick when clicked', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
renderComponent({ onClick, colorScheme: 'success' });
expect(screen.getByText('5')).toBeVisible();
expect(screen.getByText('Running')).toBeVisible();
expect(
screen.getByRole('radio', { name: /filter by running/i })
).toBeVisible();
await user.click(screen.getByText('Running'));
expect(onClick).toHaveBeenCalledOnce();
});
it('should return null when count is 0', () => {
const { container } = renderComponent({ count: 0 });
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -0,0 +1,107 @@
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
export type FilterBarColorScheme =
| 'success'
| 'error'
| 'warning'
| 'blue'
| 'gray';
const colorSchemeStyles: Record<
FilterBarColorScheme,
{ dot: string; text: string }
> = {
success: { dot: 'bg-success-7', text: 'text-success-7' },
error: { dot: 'bg-error-7', text: 'text-error-7' },
warning: { dot: 'bg-warning-7', text: 'text-warning-7' },
blue: { dot: 'bg-blue-7', text: 'text-blue-7' },
gray: { dot: 'bg-gray-7', text: 'text-gray-7' },
};
interface Props extends AutomationTestingProps {
count: number;
label: string;
isSelected: boolean;
onClick: () => void;
name: string;
colorScheme?: FilterBarColorScheme;
}
export function FilterBarButton({
count,
label,
isSelected,
onClick,
name,
colorScheme,
'data-cy': dataCy,
}: Props) {
if (count === 0) {
return null;
}
const colors = colorScheme ? colorSchemeStyles[colorScheme] : undefined;
return (
<label
className={clsx(
'relative mb-0 flex items-center gap-2',
'px-8 py-3',
'cursor-pointer border-0',
'text-sm font-medium',
'text-[var(--text-muted-color)]',
'hover:bg-[var(--bg-blocklist-item-selected-color)]',
'transition-colors duration-150',
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-blue-5 has-[:focus-visible]:ring-inset',
isSelected && 'bg-[var(--bg-blocklist-item-selected-color)]'
)}
data-cy={dataCy}
>
<input
type="radio"
className="sr-only"
name={name}
value={label}
checked={isSelected}
onClick={onClick}
readOnly
aria-label={`Filter by ${label}`}
tabIndex={0}
/>
{colors && (
<span
className={clsx('h-2.5 w-2.5 shrink-0 rounded-full', colors.dot)}
aria-hidden="true"
/>
)}
{colors && (
<span className="flex flex-col leading-tight">
<span className={clsx('text-2xl font-bold', colors.text)}>
{count}
</span>
<span className="text-xs uppercase tracking-wide text-[var(--text-muted-color)]">
{label}
</span>
</span>
)}
{!colors && (
<span className="flex items-baseline gap-2">
<span className="text-2xl font-bold">{count}</span>
<span className="text-base uppercase tracking-wide text-[var(--text-muted-color)]">
{label}
</span>
</span>
)}
{isSelected && (
<span
className={clsx(
'absolute bottom-0 left-0 right-0 h-1',
colors?.dot || 'bg-blue-7'
)}
aria-hidden="true"
/>
)}
</label>
);
}

View File

@@ -0,0 +1,669 @@
import {
useState,
useEffect,
useRef,
useContext,
createContext,
ReactNode,
} from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ColumnDef, Row } from '@tanstack/react-table';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { GroupEntry, GroupSortTable } from './GroupSortTable';
import {
useTestingGroupSortTableStateWithoutStorage,
GroupSortTableState,
} from './useGroupSortTableState';
type MenuCtxType = {
isOpen: boolean;
setOpen: (v: boolean) => void;
menuRef: React.RefObject<HTMLDivElement>;
};
vi.mock('@reach/menu-button', () => {
const MenuCtx = createContext<MenuCtxType | null>(null);
function Menu({ children }: { children?: ReactNode }) {
const [isOpen, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleDocDown(e: MouseEvent) {
const target = e.target as Node | null;
if (
isOpen &&
menuRef.current &&
target &&
!menuRef.current.contains(target)
) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleDocDown);
return () => document.removeEventListener('mousedown', handleDocDown);
}, [isOpen]);
return (
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef }}>
<div ref={menuRef}>{children}</div>
</MenuCtx.Provider>
);
}
function MenuButton({
children,
onClick: externalOnClick,
...props
}: {
children?: ReactNode;
onClick?: () => void;
[key: string]: unknown;
}) {
const ctx = useContext(MenuCtx);
function handleClick() {
externalOnClick?.();
ctx?.setOpen(!ctx.isOpen);
}
return (
<button type="button" onClick={handleClick} {...props}>
{children}
</button>
);
}
function MenuList({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return (
<div role="menu" className={className}>
{children}
</div>
);
}
function MenuItem({
children,
onSelect,
className,
}: {
children?: ReactNode;
onSelect?: () => void;
className?: string;
}) {
const ctx = useContext(MenuCtx);
function handleClick() {
onSelect?.();
ctx?.setOpen(false);
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div role="menuitem" onClick={handleClick} className={className}>
{children}
</div>
);
}
return { Menu, MenuButton, MenuList, MenuItem };
});
type Item = { id: string; name: string; group: string };
const columns: ColumnDef<Item>[] = [
{ id: 'Name', accessorKey: 'name' },
{ id: 'Group', accessorKey: 'group' },
];
const sortOptions = [
{ key: 'Group', label: 'Group', grouped: true },
{ key: 'Name', label: 'Name' },
];
function getGroupKey(item: Item): string {
return item.group;
}
function renderRow(row: Row<Item>) {
const item = row.original;
return (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.group}</td>
</tr>
);
}
/**
* A controlled wrapper that owns all GroupSortTable state via tableState and
* performs local filtering/pagination to simulate what the server would do.
*/
function ControlledTestWrapper({ allData }: { allData: Item[] }) {
const tableState = useTestingGroupSortTableStateWithoutStorage('Group');
const filteredData = allData.filter((item) => {
const matchesSearch =
!tableState.search ||
item.name.toLowerCase().includes(tableState.search.toLowerCase()) ||
item.group.toLowerCase().includes(tableState.search.toLowerCase());
const matchesGroup =
!tableState.groupBy || item.group === tableState.groupBy;
return matchesSearch && matchesGroup;
});
const start = (tableState.page - 1) * tableState.pageSize;
const pageData = filteredData.slice(start, start + tableState.pageSize);
const groupCounts = allData.reduce<Record<string, number>>((acc, item) => {
acc[item.group] = (acc[item.group] ?? 0) + 1;
return acc;
}, {});
const availableGroupsBySort: Record<string, GroupEntry[]> = {
Group: Object.entries(groupCounts).map(([key, count]) => ({ key, count })),
Name: [],
};
return (
<GroupSortTable
data={pageData}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item) => item.id}
tableState={tableState}
sortOptions={sortOptions}
getGroupKey={getGroupKey}
totalCount={filteredData.length}
availableGroupsBySort={availableGroupsBySort}
emptyContentLabel="No items found"
data-cy="test-table"
/>
);
}
function makeData(items: Partial<Item>[] = []): Item[] {
return items.map((item, i) => ({
id: String(i),
name: `Item ${i}`,
group: 'GroupA',
...item,
}));
}
function renderComponent(data: Item[] = []) {
const Wrapped = withTestQueryProvider(
withTestRouter(() => <ControlledTestWrapper allData={data} />)
);
return render(<Wrapped />);
}
function makeTableState(
overrides: Partial<GroupSortTableState> = {}
): GroupSortTableState {
return {
search: '',
setSearch: () => {},
pageSize: 10,
setPageSize: () => {},
page: 1,
setPage: () => {},
groupBy: null,
setGroupBy: () => {},
sortBy: { id: 'Group', desc: false },
setSortBy: () => {},
...overrides,
};
}
describe('GroupSortTable', () => {
test('shows loading state when isLoading is true', () => {
function LoadingWrapper() {
return (
<GroupSortTable
data={[]}
isLoading
columns={columns}
renderRow={renderRow}
getRowId={(item: object) => (item as Item).id}
tableState={makeTableState()}
sortOptions={sortOptions}
totalCount={0}
availableGroupsBySort={{}}
loadingLabel="Loading items..."
data-cy="test-table"
/>
);
}
const Wrapped = withTestQueryProvider(
withTestRouter(() => <LoadingWrapper />)
);
render(<Wrapped />);
expect(screen.getByText('Loading items...')).toBeVisible();
});
test('shows empty label when data is empty', async () => {
renderComponent([]);
expect(await screen.findByText('No items found')).toBeVisible();
});
test('renders all items from data', async () => {
const data = makeData([
{ name: 'Alpha', group: 'GroupA' },
{ name: 'Beta', group: 'GroupB' },
]);
renderComponent(data);
expect(await screen.findByText('Alpha')).toBeVisible();
expect(screen.getByText('Beta')).toBeVisible();
});
test('filtering by group in the dropdown only shows items from that group', async () => {
const user = userEvent.setup();
const data = makeData([
{ name: 'Alpha', group: 'GroupA' },
{ name: 'Beta', group: 'GroupB' },
{ name: 'Gamma', group: 'GroupA' },
]);
renderComponent(data);
expect(await screen.findByText('Alpha')).toBeVisible();
const groupBtn = screen.getByRole('button', { name: /Group/i });
await user.click(groupBtn);
const groupAOption = screen.getByRole('menuitem', { name: /GroupA/ });
await user.click(groupAOption);
expect(screen.getByText('Alpha')).toBeVisible();
expect(screen.getByText('Gamma')).toBeVisible();
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
});
test('selecting All in the dropdown clears the group filter', async () => {
const user = userEvent.setup();
const data = makeData([
{ name: 'Alpha', group: 'GroupA' },
{ name: 'Beta', group: 'GroupB' },
]);
renderComponent(data);
expect(await screen.findByText('Alpha')).toBeVisible();
await user.click(screen.getByRole('button', { name: /Group/i }));
await user.click(screen.getByRole('menuitem', { name: /GroupA/ }));
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Group/i }));
await user.click(screen.getByRole('menuitem', { name: /^All$/ }));
expect(await screen.findByText('Beta')).toBeVisible();
});
test('renders group headers above the first item in each group', async () => {
function GroupHeaderWrapper() {
const data: Item[] = [
{ id: '0', name: 'Alpha', group: 'GroupA' },
{ id: '1', name: 'Beta', group: 'GroupB' },
{ id: '2', name: 'Gamma', group: 'GroupA' },
];
const groupCounts = { GroupA: 2, GroupB: 1 };
const availableGroupsBySort: Record<string, GroupEntry[]> = {
Group: Object.entries(groupCounts).map(([key, count]) => ({
key,
count,
})),
Name: [],
};
return (
<GroupSortTable
data={data}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item) => item.id}
tableState={makeTableState()}
sortOptions={sortOptions}
getGroupKey={(item) => item.group}
renderGroupHeader={(groupKey, count) => (
<span data-cy={`header-${groupKey}`}>
{groupKey} ({count})
</span>
)}
totalCount={data.length}
availableGroupsBySort={availableGroupsBySort}
/>
);
}
const Wrapped = withTestQueryProvider(
withTestRouter(() => <GroupHeaderWrapper />)
);
render(<Wrapped />);
expect(await screen.findByTestId('header-GroupA')).toBeVisible();
expect(screen.getByTestId('header-GroupB')).toBeVisible();
expect(screen.getAllByTestId(/^header-/)).toHaveLength(2);
expect(screen.getByTestId('header-GroupA')).toHaveTextContent('GroupA (2)');
expect(screen.getByTestId('header-GroupB')).toHaveTextContent('GroupB (1)');
});
test('pinned items appear after non-pinned items regardless of data order', async () => {
type PinnableItem = Item & { pinned: boolean };
function PinWrapper() {
const data: PinnableItem[] = [
{ id: '0', name: 'PinnedFirst', group: 'G', pinned: true },
{ id: '1', name: 'NormalSecond', group: 'G', pinned: false },
];
return (
<GroupSortTable
data={data}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item: object) => (item as Item).id}
tableState={makeTableState()}
sortOptions={sortOptions}
pinToBottom={(item: object) => (item as PinnableItem).pinned}
totalCount={data.length}
availableGroupsBySort={{}}
/>
);
}
const Wrapped = withTestQueryProvider(withTestRouter(() => <PinWrapper />));
const { container } = render(<Wrapped />);
await screen.findByText('NormalSecond');
const bodyText = container.textContent ?? '';
expect(bodyText.indexOf('NormalSecond')).toBeLessThan(
bodyText.indexOf('PinnedFirst')
);
});
test('changing sort key clears the active group filter', async () => {
const user = userEvent.setup();
const data = makeData([
{ name: 'Alpha', group: 'GroupA' },
{ name: 'Beta', group: 'GroupB' },
]);
renderComponent(data);
expect(await screen.findByText('Alpha')).toBeVisible();
// Filter to GroupA
await user.click(screen.getByRole('button', { name: /Group/i }));
await user.click(screen.getByRole('menuitem', { name: /GroupA/ }));
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
// Switch sort key — should clear the group filter and show all items
await user.click(screen.getByRole('button', { name: /^Name$/i }));
expect(await screen.findByText('Beta')).toBeVisible();
});
test('typing in search clears the active group filter', async () => {
const user = userEvent.setup();
const data = makeData([
{ name: 'Alpha', group: 'GroupA' },
{ name: 'Beta', group: 'GroupB' },
]);
renderComponent(data);
expect(await screen.findByText('Alpha')).toBeVisible();
// Filter to GroupA
await user.click(screen.getByRole('button', { name: /Group/i }));
await user.click(screen.getByRole('menuitem', { name: /GroupA/ }));
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
// Type a search term — should clear the group filter
await user.type(screen.getByPlaceholderText('Filter...'), 'Beta');
await waitFor(() => {
expect(screen.getByText('Beta')).toBeVisible();
});
});
test('search filters rows to only matching items', async () => {
const user = userEvent.setup();
const data = makeData([
{ name: 'Alpha', group: 'GroupA' },
{ name: 'Beta', group: 'GroupB' },
]);
renderComponent(data);
await screen.findByText('Alpha');
await user.type(screen.getByPlaceholderText('Filter...'), 'Alpha');
await waitFor(() => {
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
});
expect(screen.getByText('Alpha')).toBeVisible();
});
test('emptyContentLabel object: shows withoutSearch when no search term', async () => {
function LabelWrapper() {
return (
<GroupSortTable
data={[]}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item: object) => (item as Item).id}
tableState={makeTableState({ search: '' })}
sortOptions={sortOptions}
totalCount={0}
availableGroupsBySort={{}}
emptyContentLabel={{
withSearch: 'No results match',
withoutSearch: 'Nothing here yet',
}}
/>
);
}
const Wrapped = withTestQueryProvider(
withTestRouter(() => <LabelWrapper />)
);
render(<Wrapped />);
expect(await screen.findByText('Nothing here yet')).toBeVisible();
});
test('emptyContentLabel object: shows withSearch when search term is set', async () => {
function LabelWrapper() {
return (
<GroupSortTable
data={[]}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item: object) => (item as Item).id}
tableState={makeTableState({ search: 'xyz' })}
sortOptions={sortOptions}
totalCount={0}
availableGroupsBySort={{}}
emptyContentLabel={{
withSearch: 'No results match',
withoutSearch: 'Nothing here yet',
}}
/>
);
}
const Wrapped = withTestQueryProvider(
withTestRouter(() => <LabelWrapper />)
);
render(<Wrapped />);
expect(await screen.findByText('No results match')).toBeVisible();
});
test('changing items per page resets to first page and shows more rows', async () => {
const user = userEvent.setup();
const data = makeData(
Array.from({ length: 11 }, (_, i) => ({ name: `Item ${i}`, group: 'G' }))
);
renderComponent(data);
expect(await screen.findByText('Item 0')).toBeVisible();
expect(screen.queryByText('Item 10')).not.toBeInTheDocument();
const pagination = screen.getByTestId('table-pagination');
await user.click(within(pagination).getByRole('button', { name: '›' }));
expect(await screen.findByText('Item 10')).toBeVisible();
await user.selectOptions(
within(pagination).getByTestId('paginationSelect'),
'25'
);
expect(await screen.findByText('Item 0')).toBeVisible();
expect(screen.getByText('Item 10')).toBeVisible();
});
test('container has overflow-clip class', async () => {
const data = makeData([{ name: 'Alpha', group: 'GroupA' }]);
const { container } = renderComponent(data);
await screen.findByText('Alpha');
const wrapper = container.querySelector(
'[data-cy="test-table"]'
) as HTMLElement;
expect(wrapper).toHaveClass('overflow-clip');
});
test('pagination next/prev navigation works', async () => {
const user = userEvent.setup();
const data = makeData(
Array.from({ length: 11 }, (_, i) => ({ name: `Item ${i}`, group: 'G' }))
);
renderComponent(data);
expect(await screen.findByText('Item 0')).toBeVisible();
const pagination = screen.getByTestId('table-pagination');
await user.click(within(pagination).getByRole('button', { name: '›' }));
expect(await screen.findByText('Item 10')).toBeVisible();
expect(screen.queryByText('Item 0')).not.toBeInTheDocument();
});
test('renders all headerButtons when provided', async () => {
const buttons = [
<button type="button" key="a">
Action A
</button>,
<button type="button" key="b">
Action B
</button>,
];
function Wrapper() {
return (
<GroupSortTable
data={[]}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item: object) => (item as Item).id}
tableState={makeTableState()}
sortOptions={sortOptions}
totalCount={0}
availableGroupsBySort={{}}
headerButtons={buttons}
/>
);
}
const Wrapped = withTestQueryProvider(withTestRouter(() => <Wrapper />));
render(<Wrapped />);
expect(
await screen.findByRole('button', { name: 'Action A' })
).toBeVisible();
expect(screen.getByRole('button', { name: 'Action B' })).toBeVisible();
});
test('renders no extra buttons when headerButtons is omitted', async () => {
function Wrapper() {
return (
<GroupSortTable
data={[]}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item: object) => (item as Item).id}
tableState={makeTableState()}
sortOptions={sortOptions}
totalCount={0}
availableGroupsBySort={{}}
/>
);
}
const Wrapped = withTestQueryProvider(withTestRouter(() => <Wrapper />));
render(<Wrapped />);
await screen.findByText('SORT BY:');
expect(
screen.queryByRole('button', { name: 'Action A' })
).not.toBeInTheDocument();
});
test('renders only the truthy buttons passed via headerButtons', async () => {
const flag = false;
const buttons = [
flag && (
<button type="button" key="hidden">
Hidden
</button>
),
<button type="button" key="visible">
Visible
</button>,
].filter((btn): btn is React.JSX.Element => Boolean(btn));
function Wrapper() {
return (
<GroupSortTable
data={[]}
isLoading={false}
columns={columns}
renderRow={renderRow}
getRowId={(item: object) => (item as Item).id}
tableState={makeTableState()}
sortOptions={sortOptions}
totalCount={0}
availableGroupsBySort={{}}
headerButtons={buttons}
/>
);
}
const Wrapped = withTestQueryProvider(withTestRouter(() => <Wrapper />));
render(<Wrapped />);
expect(
await screen.findByRole('button', { name: 'Visible' })
).toBeVisible();
expect(
screen.queryByRole('button', { name: 'Hidden' })
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,241 @@
import React, { ReactNode, useMemo } from 'react';
import { ColumnDef, Row, TableOptions } from '@tanstack/react-table';
import { Datatable } from '@@/datatables';
import { PaginationControls } from '@@/PaginationControls';
import { Widget } from '@@/Widget';
import { GroupSortTableHeader } from './GroupSortTableHeader';
import { GroupSortTableState } from './useGroupSortTableState';
export type GroupEntry = {
key: string;
label?: string;
count: number;
icon?: React.ReactNode;
};
interface Props<TItem extends object> {
data: TItem[];
isLoading: boolean;
columns: ColumnDef<TItem, unknown>[];
renderRow: (row: Row<TItem>) => React.ReactNode;
getRowId: (item: TItem) => string;
tableState: GroupSortTableState;
sortOptions: Array<{ key: string; label: string }>;
totalCount: number;
availableGroupsBySort: Record<string, GroupEntry[]>;
getGroupKey?: (item: TItem, sortBy: string) => string;
renderGroupHeader?: (groupKey: string, count: number) => React.ReactNode;
pinToBottom?: (item: TItem) => boolean;
emptyContentLabel?: string | { withSearch: string; withoutSearch: string };
loadingLabel?: string;
actionButton?: React.ReactNode;
searchPlaceholder?: string;
'data-cy'?: string;
headerButtons?: Array<ReactNode>;
}
export function GroupSortTable<TItem extends object>({
data,
isLoading,
columns,
renderRow,
getRowId,
tableState,
sortOptions,
totalCount,
availableGroupsBySort,
getGroupKey,
renderGroupHeader,
pinToBottom,
emptyContentLabel,
loadingLabel = 'Loading...',
actionButton,
searchPlaceholder,
headerButtons,
'data-cy': dataCy,
}: Props<TItem>) {
const sortBy = useMemo(() => {
if (!tableState.sortBy) return sortOptions[0]?.key ?? '';
return tableState.sortBy.id;
}, [tableState.sortBy, sortOptions]);
// Build a fast lookup from groupKey → total count for the active sort.
const groupCountByKey = useMemo<Record<string, number>>(() => {
const entries = availableGroupsBySort[sortBy] ?? [];
return Object.fromEntries(
entries.map(({ key, label, count }) => [label ?? key, count])
);
}, [availableGroupsBySort, sortBy]);
// Detect group boundaries within the current page.
// Since the server returns pre-sorted data, the first time a group key appears
// on a page is definitionally the start of that group on this page.
const firstInGroupSet = useMemo<Set<string>>(() => {
if (!getGroupKey) return new Set();
const seen = new Set<string>();
const ids = new Set<string>();
data.forEach((item) => {
const key = getGroupKey(item, sortBy);
if (!seen.has(key)) {
seen.add(key);
ids.add(getRowId(item));
}
});
return ids;
}, [data, getGroupKey, getRowId, sortBy]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(totalCount / tableState.pageSize)),
[totalCount, tableState.pageSize]
);
const emptyLabel = useMemo(() => {
if (!emptyContentLabel) {
return tableState.search
? 'No results match your search'
: 'No items found';
}
if (typeof emptyContentLabel === 'string') return emptyContentLabel;
return tableState.search
? emptyContentLabel.withSearch
: emptyContentLabel.withoutSearch;
}, [emptyContentLabel, tableState.search]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12" data-cy={dataCy}>
<span className="text-sm text-gray-7 th-dark:text-gray-5">
{loadingLabel}
</span>
</div>
);
}
return (
<Widget className="overflow-clip [&_table]:bg-transparent" data-cy={dataCy}>
<GroupSortTableHeader
sortBy={sortBy}
onSortChange={handleSortChange}
searchTerm={tableState.search}
onSearchChange={(value) => {
tableState.setSearch(value);
tableState.setPage(1);
tableState.setGroupBy(null);
}}
sortOptions={sortOptions}
searchPlaceholder={searchPlaceholder}
actionButton={actionButton}
groupFilter={tableState.groupBy}
groupOptions={availableGroupsBySort}
onGroupFilterChange={handleGroupFilterChange}
headerButtons={headerButtons}
data-cy={dataCy || ''}
/>
<div className="[&_.footer]:hidden [&_thead]:hidden">
<Datatable
key={`${tableState.sortBy?.id}-${tableState.sortBy?.desc}-${
tableState.search
}-${tableState.page}-${tableState.pageSize}-${
tableState.groupBy ?? 'all'
}`}
settingsManager={tableState}
columns={columns}
dataset={data}
initialTableState={{
pagination: {
pageIndex: 0,
pageSize: tableState.pageSize,
},
}}
renderRow={rowWithGroupHeader}
getRowId={getRowId}
disableSelect
noWidget
emptyContentLabel={emptyLabel}
extendTableOptions={pinToBottomExtension}
data-cy={dataCy ?? ''}
/>
</div>
<div
data-cy="table-pagination"
className="border-0 border-t border-solid border-gray-4 px-5 py-3 th-highcontrast:border-white th-dark:border-gray-7"
>
<PaginationControls
page={tableState.page}
pageCount={totalPages}
onPageChange={tableState.setPage}
pageLimit={tableState.pageSize}
onPageLimitChange={(n) => {
tableState.setPageSize(n);
}}
/>
</div>
</Widget>
);
function rowWithGroupHeader(row: Row<TItem>): React.ReactNode {
if (!getGroupKey || !renderGroupHeader) {
return renderRow(row);
}
const groupKey = getGroupKey(row.original, sortBy);
if (!firstInGroupSet.has(row.id)) {
return renderRow(row);
}
return (
<>
<tr>
<td colSpan={Number.MAX_SAFE_INTEGER} className="!p-0">
{renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0)}
</td>
</tr>
{renderRow(row)}
</>
);
}
function handleSortChange(key: string) {
tableState.setPage(1);
tableState.setSortBy(key, false);
}
function handleGroupFilterChange(value: string | null) {
tableState.setGroupBy(value);
tableState.setPage(1);
}
function pinToBottomExtension(
options: TableOptions<TItem>
): TableOptions<TItem> {
if (!pinToBottom) return options;
const originalGetSortedRowModel = options.getSortedRowModel;
if (!originalGetSortedRowModel) return options;
return {
...options,
getSortedRowModel: (table) => {
const getSortedRowModel = originalGetSortedRowModel(table);
return () => {
const rowModel = getSortedRowModel();
if (!rowModel?.rows) return rowModel;
const main = rowModel.rows.filter(
(row) => !pinToBottom(row.original)
);
const pinned = rowModel.rows.filter((row) =>
pinToBottom(row.original)
);
return {
...rowModel,
rows: [...main, ...pinned],
};
};
},
};
}
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
interface Props {
groupName: string;
groupDescription?: string;
groupIcon: React.ReactNode;
count?: number;
}
export function GroupSortTableGroupRow({
groupName,
groupDescription,
groupIcon,
count,
}: Props) {
return (
<div className="flex items-center gap-3 bg-gray-2 px-4 py-3 th-highcontrast:bg-black th-dark:bg-gray-iron-10">
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
{groupIcon}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-gray-11 th-highcontrast:text-white th-dark:text-white">
{groupName}
</span>
{count !== undefined && (
<span className="inline-flex items-center justify-center rounded-full bg-gray-4 px-2 py-0.5 text-xs font-medium text-gray-9 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-7 th-dark:text-gray-3">
{count}
</span>
)}
</div>
{groupDescription && (
<span className="truncate text-xs text-gray-7 th-highcontrast:text-white th-dark:text-gray-5">
{groupDescription}
</span>
)}
</div>
</div>
);
}

View File

@@ -52,14 +52,14 @@ describe('GroupSortTableHeader', () => {
expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible();
});
test('clicking an inactive sort button calls onSortChange and opens the dropdown', async () => {
test('clicking an inactive grouped sort button opens the dropdown without calling onSortChange', async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderHeader({ sortBy: 'Group', onSortChange });
await user.click(screen.getByRole('button', { name: /Platform/i }));
expect(onSortChange).toHaveBeenCalledWith('Platform');
expect(onSortChange).not.toHaveBeenCalled();
expect(screen.getByRole('menu', { name: /Platform/i })).toBeVisible();
});

View File

@@ -1,4 +1,4 @@
import React from 'react';
import { ReactNode } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
@@ -18,10 +18,11 @@ interface Props<TSortKey extends string> {
onSearchChange: (term: string) => void;
sortOptions: SortOption<TSortKey>[];
searchPlaceholder?: string;
actionButton?: React.ReactNode;
actionButton?: ReactNode;
groupFilter: string | null;
groupOptions?: Record<string, DropdownOption[]>;
onGroupFilterChange: (value: string | null) => void;
headerButtons?: ReactNode;
}
export function GroupSortTableHeader<TSortKey extends string>({
@@ -35,14 +36,15 @@ export function GroupSortTableHeader<TSortKey extends string>({
groupFilter,
groupOptions,
onGroupFilterChange,
headerButtons,
'data-cy': dataCy,
}: Props<TSortKey> & AutomationTestingProps) {
return (
<div
className={clsx(
'flex items-center justify-between gap-3 px-5 py-3',
'flex flex-wrap items-center justify-between gap-3 px-5 py-3',
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10',
'border-0 border-b border-solid border-gray-4'
'border-0 border-b border-solid border-gray-5 th-dark:border-gray-9'
)}
>
<SortByGroup
@@ -52,9 +54,10 @@ export function GroupSortTableHeader<TSortKey extends string>({
groupFilter={groupFilter}
groupOptions={groupOptions}
onGroupFilterChange={onGroupFilterChange}
dataCy={dataCy}
data-cy={`${dataCy}-sort`}
/>
<div className="ml-auto flex items-center gap-2">
{headerButtons}
<SearchBar
value={searchTerm}
placeholder={searchPlaceholder}

View File

@@ -0,0 +1,227 @@
import {
useState,
useEffect,
useRef,
useContext,
createContext,
ReactNode,
} from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { SortByGroup, SortOption } from './SortByGroup';
type MenuCtxType = {
isOpen: boolean;
setOpen: (v: boolean) => void;
menuRef: React.RefObject<HTMLDivElement>;
};
vi.mock('@reach/menu-button', () => {
const MenuCtx = createContext<MenuCtxType | null>(null);
function Menu({ children }: { children?: ReactNode }) {
const [isOpen, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleDocDown(e: MouseEvent) {
const target = e.target as Node | null;
if (
isOpen &&
menuRef.current &&
target &&
!menuRef.current.contains(target)
) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleDocDown);
return () => document.removeEventListener('mousedown', handleDocDown);
}, [isOpen]);
return (
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef }}>
<div ref={menuRef}>{children}</div>
</MenuCtx.Provider>
);
}
function MenuButton({
children,
onClick: externalOnClick,
...props
}: {
children?: ReactNode;
onClick?: () => void;
[key: string]: unknown;
}) {
const ctx = useContext(MenuCtx);
function handleClick() {
externalOnClick?.();
ctx?.setOpen(!ctx.isOpen);
}
return (
<button type="button" onClick={handleClick} {...props}>
{children}
</button>
);
}
function MenuList({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return (
<div role="menu" className={className}>
{children}
</div>
);
}
function MenuItem({
children,
onSelect,
className,
}: {
children?: ReactNode;
onSelect?: () => void;
className?: string;
}) {
const ctx = useContext(MenuCtx);
function handleClick() {
onSelect?.();
ctx?.setOpen(false);
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div role="menuitem" onClick={handleClick} className={className}>
{children}
</div>
);
}
return { Menu, MenuButton, MenuList, MenuItem };
});
const sortOptions: SortOption[] = [
{ key: 'Group', label: 'Group', grouped: true },
{ key: 'Platform', label: 'Platform', grouped: true },
{ key: 'Name', label: 'Name' },
];
const groupOptions = {
Group: [
{ key: 'GroupA', label: 'GroupA' },
{ key: 'GroupB', label: 'GroupB' },
],
Platform: [
{ key: 'Docker', label: 'Docker' },
{ key: 'Kubernetes', label: 'Kubernetes' },
],
};
function renderComponent({
sortBy = 'Group' as string,
groupFilter = null as string | null,
onSortChange = vi.fn(),
onGroupFilterChange = vi.fn(),
} = {}) {
const Wrapped = withTestQueryProvider(
withTestRouter(() => (
<SortByGroup
sortBy={sortBy}
onSortChange={onSortChange}
sortOptions={sortOptions}
groupFilter={groupFilter}
groupOptions={groupOptions}
onGroupFilterChange={onGroupFilterChange}
dataCy="test"
/>
))
);
return { ...render(<Wrapped />), onSortChange, onGroupFilterChange };
}
describe('SortByGroup', () => {
describe('grouped: false option', () => {
test('clicking an inactive button calls onSortChange', async () => {
const user = userEvent.setup();
const { onSortChange } = renderComponent({ sortBy: 'Group' });
await user.click(screen.getByRole('button', { name: /^Name$/i }));
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
});
test('clicking the already-active button does not call onSortChange', async () => {
const user = userEvent.setup();
const { onSortChange } = renderComponent({ sortBy: 'Name' });
await user.click(screen.getByRole('button', { name: /^Name$/i }));
expect(onSortChange).not.toHaveBeenCalled();
});
});
describe('grouped: true option', () => {
test('clicking the dropdown button to open it does not call onSortChange', async () => {
const user = userEvent.setup();
const { onSortChange } = renderComponent({ sortBy: 'Group' });
// Click Platform button (inactive grouped option) — should just open menu
await user.click(screen.getByRole('button', { name: /^Platform$/i }));
expect(onSortChange).not.toHaveBeenCalled();
});
test('selecting a filter from an inactive grouped option calls onSortChange and onGroupFilterChange', async () => {
const user = userEvent.setup();
const { onSortChange, onGroupFilterChange } = renderComponent({
sortBy: 'Group',
});
await user.click(screen.getByRole('button', { name: /^Platform$/i }));
await user.click(screen.getByRole('menuitem', { name: /Docker/ }));
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Platform');
expect(onGroupFilterChange).toHaveBeenCalledExactlyOnceWith('Docker');
});
test('selecting a filter from the already-active grouped option does not call onSortChange', async () => {
const user = userEvent.setup();
const { onSortChange, onGroupFilterChange } = renderComponent({
sortBy: 'Group',
groupFilter: null,
});
await user.click(screen.getByRole('button', { name: /^Group$/i }));
await user.click(screen.getByRole('menuitem', { name: /GroupA/ }));
expect(onSortChange).not.toHaveBeenCalled();
expect(onGroupFilterChange).toHaveBeenCalledExactlyOnceWith('GroupA');
});
test('selecting All from a grouped dropdown calls onGroupFilterChange with null', async () => {
const user = userEvent.setup();
const { onSortChange, onGroupFilterChange } = renderComponent({
sortBy: 'Group',
groupFilter: 'GroupA',
});
await user.click(screen.getByRole('button', { name: /^Group/i }));
await user.click(screen.getByRole('menuitem', { name: /^All$/ }));
expect(onSortChange).not.toHaveBeenCalled();
expect(onGroupFilterChange).toHaveBeenCalledWith(null);
});
});
});

View File

@@ -28,7 +28,7 @@ export function SortByGroup<TSortKey extends string>({
dataCy,
}: SortByGroupProps<TSortKey>) {
return (
<>
<div className="flex items-center gap-3">
<span
className="text-xs font-semibold tracking-wider text-gray-11 th-highcontrast:text-white th-dark:text-white"
data-cy="sort-by-label"
@@ -59,22 +59,25 @@ export function SortByGroup<TSortKey extends string>({
/>
))}
</div>
</>
</div>
);
}
const baseBtn =
'px-4 py-1.5 text-xs align-middle font-medium transition-colors';
const activeBtn =
'z-10 border-none rounded-md font-medium ' +
'bg-white text-gray-8 ' +
'th-dark:bg-gray-iron-10 th-dark:text-white ' +
'th-highcontrast:bg-white th-highcontrast:text-black';
const inactiveBtn =
'text-muted border-none rounded-md ' +
'bg-gray-4 hover:bg-gray-2 ' +
'th-dark:bg-gray-iron-11 th-dark:text-white th-dark:hover:bg-gray-iron-9 ' +
'th-highcontrast:bg-black th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black';
const baseBtn = clsx(
'px-4 py-1.5 align-middle text-xs font-medium transition-colors'
);
const activeBtn = clsx(
'z-10 rounded-md border-none font-medium',
'bg-white text-gray-8',
'th-dark:bg-gray-iron-10 th-dark:text-white',
'th-highcontrast:border th-highcontrast:border-solid th-highcontrast:bg-transparent th-highcontrast:text-white'
);
const inactiveBtn = clsx(
'text-muted rounded-md border-none',
'bg-gray-4 hover:bg-gray-2',
'th-dark:bg-gray-iron-11 th-dark:text-white th-dark:hover:bg-gray-iron-9',
'th-highcontrast:bg-black th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black'
);
interface SortOptionItemProps<TSortKey extends string> {
option: SortOption<TSortKey>;
@@ -112,15 +115,19 @@ function SortOptionItem<TSortKey extends string>({
label={option.label}
options={groupOptions?.[option.key]}
selected={groupFilter}
onSelect={onGroupFilterChange}
badge={isActive ? groupFilter : undefined}
className={className}
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
onClick={() => {
onSelect={(value) => {
if (!isActive) {
onSortChange(option.key);
}
onGroupFilterChange(value);
}}
badge={
isActive
? getFilterBadge(groupOptions, option.key, groupFilter)
: undefined
}
className={className}
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
/>
);
}
@@ -132,6 +139,7 @@ function SortOptionItem<TSortKey extends string>({
onClick={() => {
if (!isActive) {
onSortChange(option.key);
onGroupFilterChange(null);
}
}}
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
@@ -140,3 +148,17 @@ function SortOptionItem<TSortKey extends string>({
</button>
);
}
function getFilterBadge(
groupOptions: Record<string, DropdownOption[]> | undefined,
groupKey: string,
filter: string | null
): string | null {
if (!filter) {
return null;
}
const option = groupOptions?.[groupKey]?.find((o) => o.key === filter);
if (!option) return null;
return option.label ?? option.key;
}

View File

@@ -0,0 +1,18 @@
import {
type BasicTableSettings,
hiddenColumnsSettings,
type SettableColumnsTableSettings,
} from '@@/datatables/types';
import { useTableStateWithStorage } from '@@/datatables/useTableState';
export interface TableSettings
extends BasicTableSettings,
SettableColumnsTableSettings {}
const tableKey = 'environment_groups';
export function useStore() {
return useTableStateWithStorage<TableSettings>(tableKey, 'Name', (set) => ({
...hiddenColumnsSettings(set),
}));
}

View File

@@ -0,0 +1,82 @@
import { useMemo, useState } from 'react';
import {
BasicTableSettings,
createPersistedStore,
ZustandSetFunc,
} from '@@/datatables/types';
import { useTableState, TableState } from '@@/datatables/useTableState';
interface GroupSortTableSettings extends BasicTableSettings {
page: number;
setPage: (page: number) => void;
groupBy: string | null;
setGroupBy: (filter: string | null) => void;
}
export type GroupSortTableState = TableState<GroupSortTableSettings>;
function groupSortTableExtras(
set: ZustandSetFunc<GroupSortTableSettings>
): Partial<GroupSortTableSettings> {
return {
page: 1,
setPage: (page: number) => set((s) => ({ ...s, page })),
groupBy: null,
setGroupBy: (filter: string | null) =>
set((s) => ({ ...s, groupBy: filter })),
};
}
function createGroupSortTableStore(
storageKey: string,
defaultSort?: string,
defaultPageSize: number = 10
) {
return createPersistedStore<GroupSortTableSettings>(
storageKey,
defaultSort,
(set) => ({ ...groupSortTableExtras(set), pageSize: defaultPageSize })
);
}
export function useGroupSortTableState(
storageKey: string,
defaultSort?: string,
defaultPageSize: number = 10
): GroupSortTableState {
const [store] = useState(() =>
createGroupSortTableStore(storageKey, defaultSort, defaultPageSize)
);
return useTableState(store, storageKey);
}
export function useTestingGroupSortTableStateWithoutStorage(
defaultSort?: string
): GroupSortTableState {
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const [groupBy, setGroupBy] = useState<string | null>(null);
const [sortBy, setSortByState] = useState(
defaultSort ? { id: defaultSort, desc: false } : undefined
);
return useMemo(
() => ({
search,
setSearch,
pageSize,
setPageSize,
page,
setPage,
groupBy,
setGroupBy,
sortBy,
setSortBy: (id: string | undefined, desc: boolean) =>
setSortByState(id ? { id, desc } : undefined),
}),
[search, pageSize, page, groupBy, sortBy]
);
}

View File

@@ -81,7 +81,7 @@ function RateLimitsInner({
<>
You are currently using a free account to pull images from
DockerHub and will be limited to 200 pulls every 6 hours.
Remaining pulls:
Remaining pulls:{' '}
<span className="font-bold">
{pullRateLimits.remaining}/{pullRateLimits.limit}
</span>

View File

@@ -19,7 +19,7 @@ export function InformationPanel({
children,
}: PropsWithChildren<Props>) {
return (
<Widget className="!border-none">
<Widget className="border-none">
<WidgetBody className={bodyClassName}>
<div style={wrapperStyle}>
{title && (

View File

@@ -0,0 +1,22 @@
import clsx from 'clsx';
export function SnapshotBadge() {
return (
<span
className={clsx(
'flex items-center gap-2 rounded-xl',
'w-fit px-2 py-px',
'text-xs font-bold',
'bg-warning-7/20 text-warning-7'
)}
aria-label="status-badge"
>
<span
aria-hidden="true"
aria-label="edge-heartbeat"
className={clsx('block h-2 w-2 rounded-full', 'bg-warning-7')}
/>
<span>Snapshot available</span>
</span>
);
}

View File

@@ -60,18 +60,14 @@ export function SortableList<T>({
sortBy={activeSortKey}
onSortChange={(key) => {
tableState.setSortBy(key, false);
tableState.setGroupFilter(null);
tableState.setPage(0);
}}
searchTerm={tableState.search}
onSearchChange={(value) => {
tableState.setSearch(value);
tableState.setPage(0);
}}
groupFilter={tableState.groupFilter}
onGroupFilterChange={(value) => {
tableState.setGroupFilter(value);
tableState.setPage(0);
}}
groupOptions={groupOptions}
sortOptions={sortOptions}

View File

@@ -1,9 +1,12 @@
import { PropsWithChildren } from 'react';
import { WidgetBody } from '@@/Widget';
import { Widget } from '@@/Widget/Widget';
export function SortableListCard({ children }: PropsWithChildren<unknown>) {
return (
<div className="flex flex-col rounded-lg shadow-[0_1px_2px_rgba(16,24,40,.06),0_6px_16px_rgba(16,24,40,.08)]">
{children}
</div>
<Widget className="th-dark:!border-gray-8">
<WidgetBody className="overflow-hidden !p-0">{children}</WidgetBody>
</Widget>
);
}

View File

@@ -31,7 +31,7 @@ export function SortableListGroup<T>({
className={clsx(
'flex items-center gap-2 px-5 py-2.5',
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-11',
'border-0 border-b border-solid border-gray-4 th-dark:border-gray-8'
'border-0 border-b border-solid border-gray-5 th-dark:border-gray-9'
)}
>
{group.icon && (

View File

@@ -6,7 +6,7 @@ interface Props {
export function SortableListItem({ children }: Props) {
return (
<div className="border-0 border-b border-solid border-gray-3 px-5 py-3 th-dark:border-gray-8">
<div className="border-0 border-b border-solid border-gray-5 px-5 py-3 th-dark:border-gray-9">
{children}
</div>
);

View File

@@ -20,7 +20,7 @@ export function SortableListPager({
const safePage = Math.min(page, totalPages - 1);
return (
<div className="flex items-center justify-between px-5 py-3 text-xs text-gray-7 th-dark:text-gray-4">
<div className="flex items-center justify-between px-5 py-3 text-xs text-gray-7 th-highcontrast:text-gray-4 th-dark:text-gray-4">
<SortableListPagerInfo
page={safePage}
totalCount={totalCount}

View File

@@ -1,6 +1,6 @@
import clsx from 'clsx';
const PAGE_SIZE_OPTIONS = [1, 10, 25, 50, 100] as const;
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
interface Props {
page: number;
@@ -35,7 +35,7 @@ export function SortableListPagerInfo({
<select
className={clsx(
'rounded border border-solid border-gray-4 bg-transparent px-2 py-1',
'text-xs text-gray-7 th-dark:border-gray-7 th-dark:text-gray-4',
'text-xs text-gray-7 th-highcontrast:text-gray-4 th-dark:border-gray-7 th-dark:text-gray-4',
'cursor-pointer focus:outline-none'
)}
value={pageSize}

View File

@@ -1,27 +1,96 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { Cpu, Hexagon, LaptopMinimal, MemoryStick } from 'lucide-react';
import { Icon, IconProps } from '@/react/components/Icon';
interface Props extends IconProps {
value: string | number;
title?: string;
icon: IconProps['icon'];
iconClass?: string;
}
export function StatsItem({
value,
title,
icon,
children,
iconClass,
}: PropsWithChildren<Props>) {
return (
<span className="flex items-center gap-1">
<Icon className={clsx('icon icon-sm', iconClass)} icon={icon} />
<span>{value}</span>
{children && (
<span className="ml-1 flex items-center gap-2">{children}</span>
<div
className={clsx(
'flex flex-col items-center',
'h-full gap-1 rounded-lg p-2',
'bg-gray-2 th-highcontrast:bg-transparent th-dark:bg-gray-iron-10',
'border border-solid border-gray-4 th-dark:border-gray-8'
)}
</span>
>
<div className="flex items-center gap-1 text-[10px]">
<Icon className={clsx('icon icon-sm', iconClass)} icon={icon} />
<span>{title}</span>
</div>
<div className="flex w-full items-baseline gap-1">{children}</div>
</div>
);
}
interface StatsProps {
value: string | number | undefined;
}
export function NodeStats({ value }: StatsProps) {
return (
<StatsItem icon={LaptopMinimal} title="NODES">
<span className="text-left font-bold leading-none">{value}</span>
</StatsItem>
);
}
export function CPUStats({ value }: StatsProps) {
return (
<StatsItem icon={Cpu} title="CPUS">
<span className="text-left font-bold leading-none">{value}</span>
<span className="align-baseline text-xs leading-none">cores</span>
</StatsItem>
);
}
export function MemoryStats({ value }: StatsProps) {
return (
<StatsItem icon={MemoryStick} title="MEMORY">
<span className="text-left font-bold leading-none">{value}</span>
</StatsItem>
);
}
interface ContainerStatsProps {
total: number;
running: number;
stopped: number;
}
export function ContainerStats({
total,
running,
stopped,
}: ContainerStatsProps) {
const actualTotal = total || running + stopped;
return (
<StatsItem title="CONTAINERS" icon={Hexagon}>
<div className="flex w-full flex-col">
<div>
<span className="text-base font-bold leading-none">{running}</span>
<span> / {actualTotal}</span>
</div>
{actualTotal > 0 && (
<progress
className="h-[4px] w-auto rounded bg-gray-4 th-dark:bg-white/10"
value={running}
max={actualTotal}
aria-label={`${running} of ${actualTotal} containers running`}
/>
)}
</div>
</StatsItem>
);
}

View File

@@ -1,24 +1,24 @@
import { FilterBarButton, Color } from './FilterBarButton';
import { FilterBarActiveIndicator } from './FilterBarActiveIndicator';
export interface StatusSegment {
key: string;
export interface StatusSegment<TValue = string> {
key: TValue;
label: string;
count: number;
color: Color;
}
interface Props {
interface Props<TValue> {
total: number;
segments: StatusSegment[];
value: string | null;
onChange: (filter: string | null) => void;
segments: Array<StatusSegment<TValue>>;
value: TValue | null;
onChange: (filter: TValue | null) => void;
radioGroupName?: string;
ariaLabel?: string;
'data-cy'?: string;
}
export function StatusSummaryBar({
export function StatusSummaryBar<TValue extends string = string>({
total,
segments,
value,
@@ -26,11 +26,11 @@ export function StatusSummaryBar({
radioGroupName = 'status-summary-filter',
ariaLabel = 'Filter by status',
'data-cy': dataCy = 'status-summary-bar',
}: Props) {
const isAllSelected = !value;
}: Props<TValue>) {
const isAllSelected = !value || value === 'all' || value === 'custom';
const activeLabel = segments.find((s) => s.key === value)?.label;
function handleSegmentClick(key: string) {
function handleSegmentClick(key: TValue) {
onChange(value === key ? null : key);
}

View File

@@ -37,11 +37,13 @@ export function Widget({
mRef,
id,
'aria-label': ariaLabel,
'data-cy': dataCy,
}: PropsWithChildren<{
className?: string;
mRef?: Ref<HTMLDivElement>;
id?: string;
'aria-label'?: string;
'data-cy'?: string;
}>) {
// Only generate titleId once on mount if aria-label is not provided
const [titleId] = useState(() => (ariaLabel ? undefined : generateId()));
@@ -55,6 +57,7 @@ export function Widget({
ref={mRef}
aria-label={ariaLabel}
aria-labelledby={titleId}
data-cy={dataCy}
>
{children}
</section>

View File

@@ -1,5 +1,7 @@
import { fireEvent, render } from '@testing-library/react';
import { mockClipboard } from '@/react/test-utils/clipboard';
import { CopyButton } from './CopyButton';
test('should display a CopyButton with children', async () => {
@@ -15,14 +17,7 @@ test('should display a CopyButton with children', async () => {
});
test('CopyButton should copy text to clipboard', async () => {
// override navigator.clipboard.writeText (to test copy to clipboard functionality)
let clipboardText = '';
const writeText = vi.fn((text) => {
clipboardText = text;
});
Object.assign(navigator, {
clipboard: { writeText },
});
const { writeText } = mockClipboard();
const children = 'button';
const copyText = 'text successfully copied to clipboard';
@@ -36,6 +31,5 @@ test('CopyButton should copy text to clipboard', async () => {
expect(button).toBeTruthy();
fireEvent.click(button);
expect(clipboardText).toBe(copyText);
expect(writeText).toHaveBeenCalled();
expect(writeText).toHaveBeenCalledWith(copyText);
});

View File

@@ -29,7 +29,9 @@ export function useCopy(
// https://developer.mozilla.org/en-US/docs/Web/API/Clipboard
// https://caniuse.com/?search=clipboard
if (navigator.clipboard) {
// eslint-disable-next-line no-restricted-properties -- this file IS the approved secure-context-safe wrapper; consumers must use useCopy or CopyButton
if (window.isSecureContext && navigator.clipboard) {
// eslint-disable-next-line no-restricted-properties -- see above
navigator.clipboard.writeText(text);
} else {
// https://stackoverflow.com/a/57192718

View File

@@ -19,6 +19,7 @@ type MockCommonProps = Record<string, unknown>;
type MockWithChildren = { children?: ReactNode };
type MockMenuButtonProps = MockWithChildren & {
as?: ComponentType<MockCommonProps>;
onClick?: () => void;
} & MockCommonProps;
type MockMenuItemProps = MockWithChildren & {
onSelect?: () => void;
@@ -88,21 +89,28 @@ vi.mock('@reach/menu-button', () => {
function MenuButton({
children,
as: Component,
onClick: externalOnClick,
...props
}: MockMenuButtonProps) {
const ctx = useContext(MenuCtx);
function onClick() {
function handleClick() {
externalOnClick?.();
ctx?.setOpen(!ctx.isOpen);
}
if (Component) {
return (
<Component data-cy="menu-button" onClick={onClick} {...props}>
<Component data-cy="menu-button" onClick={handleClick} {...props}>
{children}
</Component>
);
}
return (
<button data-cy="menu-button" type="button" onClick={onClick} {...props}>
<button
data-cy="menu-button"
type="button"
onClick={handleClick}
{...props}
>
{children}
</button>
);

View File

@@ -22,6 +22,7 @@ export interface MenuButtonProps
menuClassName?: string;
dropdownPosition?: 'left' | 'right';
children: ReactNode;
onClick?: () => void;
}
export function MenuButton({
@@ -36,6 +37,7 @@ export function MenuButton({
menuClassName,
dropdownPosition = 'right',
'data-cy': dataCy,
onClick,
}: PropsWithChildren<MenuButtonProps>) {
return (
<Menu>
@@ -48,6 +50,7 @@ export function MenuButton({
title={title}
icon={icon}
data-cy={dataCy}
onClick={onClick}
>
{children}
<Icon icon={ChevronDown} size="xs" className="ml-1" />

View File

@@ -0,0 +1,130 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useTableStateFromUrl } from './useTableStateFromUrl';
const mockSetUrlState = vi.fn();
const mockSetStoredPageSize = vi.fn();
let mockUrlParams: Record<string, string | undefined> = {};
let mockStoredPageSize = 10;
vi.mock('@/react/hooks/useParamState', () => ({
useParamsState: (
parseParams: (params: Record<string, string | undefined>) => unknown
) => [parseParams(mockUrlParams), mockSetUrlState] as const,
}));
vi.mock('@/react/hooks/useLocalStorage', () => ({
useLocalStorage: () => [mockStoredPageSize, mockSetStoredPageSize],
}));
describe('useTableStateFromUrl', () => {
beforeEach(() => {
mockUrlParams = {};
mockStoredPageSize = 10;
mockSetUrlState.mockReset();
mockSetStoredPageSize.mockReset();
});
it('returns defaults when URL params are absent', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
expect(result.current.search).toBe('');
expect(result.current.sortBy).toEqual({ id: 'name', desc: false });
expect(result.current.page).toBe(0);
expect(result.current.pageSize).toBe(10);
});
it('setSearch updates search and resets page to 0', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setSearch('hello');
});
expect(mockSetUrlState).toHaveBeenCalledWith({ search: 'hello', page: 0 });
});
it('setSortBy updates sort and order and resets page to 0', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setSortBy('age', true);
});
expect(mockSetUrlState).toHaveBeenCalledWith({
sort: 'age',
order: 'desc',
page: 0,
});
});
it('setPage updates page only', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setPage(3);
});
expect(mockSetUrlState).toHaveBeenCalledWith({ page: 3 });
});
it('setPageSize updates localStorage and URL pageSize, resets page to 0', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setPageSize(25);
});
expect(mockSetStoredPageSize).toHaveBeenCalledWith(25);
expect(mockSetUrlState).toHaveBeenCalledWith({ pageSize: 25, page: 0 });
});
it('falls back to 0 for invalid page URL param', () => {
mockUrlParams = { page: 'abc' };
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
expect(result.current.page).toBe(0);
});
it('falls back to localStorage pageSize for invalid pageSize URL param', () => {
mockUrlParams = { pageSize: 'abc' };
mockStoredPageSize = 20;
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
expect(result.current.pageSize).toBe(20);
});
it('sanitizes out-of-range URL params to safe defaults', () => {
mockUrlParams = { page: '-3', pageSize: '0', order: 'sideways' };
mockStoredPageSize = 15;
const defaultSort = 'sorting';
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort })
);
expect(result.current.page).toBe(0);
expect(result.current.pageSize).toBe(15);
expect(result.current.sortBy?.desc).toBe(false);
expect(result.current.search).toBe('');
expect(result.current.sortBy?.id).toBe(defaultSort);
});
});

View File

@@ -0,0 +1,106 @@
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { useParamsState } from '@/react/hooks/useParamState';
import { BasicTableSettings } from './types';
type CoreUrlState = {
search: string;
sort: string;
order: 'asc' | 'desc';
page: number;
pageSize: number | null;
};
type Extra = {
search: string;
setSearch(value: string): void;
page: number;
setPage(page: number): void;
};
export function useTableStateFromUrl<
TParsed extends Record<string, unknown> = Record<never, never>,
TExtra extends Record<string, unknown> = Record<never, never>,
>({
localStorageKey,
defaultSort = 'name',
parseExtra,
buildExtra,
}: {
localStorageKey: string;
defaultSort?: string;
parseExtra?: (params: Record<string, string | undefined>) => TParsed;
buildExtra?: (
urlState: CoreUrlState & TParsed,
setUrlState: (s: Partial<CoreUrlState & TParsed>) => void
) => TExtra;
}): BasicTableSettings & TExtra & Extra {
const [storedPageSize, setStoredPageSize] = useLocalStorage(
`datatable_settings_${localStorageKey}_pageSize`,
10
);
const [urlState, setUrlState] = useParamsState((params) => ({
search: params.search ?? '',
sort: params.sort ?? defaultSort,
order: (params.order === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc',
page: Math.max(0, parseIntOrDefault(params.page, 0)),
pageSize: parsePositiveIntOrNull(params.pageSize),
...(parseExtra ? parseExtra(params) : ({} as TParsed)),
}));
const pageSize = urlState.pageSize ?? storedPageSize;
const extra = buildExtra ? buildExtra(urlState, setUrlState) : ({} as TExtra);
const tableState = {
search: urlState.search,
setSearch: (search: string) => setCoreState({ search, page: 0 }),
sortBy: { id: urlState.sort, desc: urlState.order === 'desc' },
setSortBy: (id, desc) =>
setCoreState({
sort: id ?? defaultSort,
order: desc ? 'desc' : 'asc',
page: 0,
}),
page: urlState.page,
setPage: (page) => setCoreState({ page }),
pageSize,
setPageSize: (size) => {
setStoredPageSize(size);
setCoreState({ pageSize: size, page: 0 });
},
...extra,
} satisfies BasicTableSettings & TExtra & Extra;
return tableState;
function setCoreState(partial: Partial<CoreUrlState>) {
return setUrlState(partial as Partial<CoreUrlState & TParsed>);
}
}
export function parseIntOrDefault<T>(
raw: string | undefined,
fallback: T
): number | T {
if (!raw) return fallback;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
export function parsePositiveIntOrNull(raw: string | undefined): number | null {
const n = parseIntOrDefault(raw, null);
return n !== null && n > 0 ? n : null;
}
export function asEnum<T>(
value: string | undefined,
allowed: Set<T>
): T | null {
return allowed.has(value as T) ? (value as T) : null;
}

View File

@@ -44,7 +44,7 @@ describe('ConfirmPruneModal', () => {
expect(result).toBeUndefined();
});
it('should return pruneAll: false when Continue is clicked without toggling switch', async () => {
it('should return pruneAll: false and clearBuildCache: false when Continue is clicked without toggling switches', async () => {
const user = userEvent.setup();
const resultPromise = confirmPruneImages();
@@ -54,28 +54,50 @@ describe('ConfirmPruneModal', () => {
await user.click(confirmButton);
const result = await resultPromise;
expect(result).toEqual({ pruneAll: false });
expect(result).toEqual({ pruneAll: false, clearBuildCache: false });
});
it('should return pruneAll: true when switch is toggled and Continue is clicked', async () => {
it('should return pruneAll: true when first switch is toggled and Continue is clicked', async () => {
const user = userEvent.setup();
const resultPromise = confirmPruneImages();
const switchInput = await screen.findByRole('checkbox');
await user.click(switchInput);
const [pruneAllSwitch] = await screen.findAllByRole('checkbox');
await user.click(pruneAllSwitch);
const confirmButton = screen.getByRole('button', { name: /continue/i });
await user.click(confirmButton);
const result = await resultPromise;
expect(result).toEqual({ pruneAll: true });
expect(result).toEqual({ pruneAll: true, clearBuildCache: false });
});
it('should have switch unchecked by default', async () => {
it('should have both switches unchecked by default', async () => {
confirmPruneImages();
const switchInput = await screen.findByRole('checkbox');
expect(switchInput).not.toBeChecked();
const switches = await screen.findAllByRole('checkbox');
switches.forEach((s) => expect(s).not.toBeChecked());
});
it('should render switch for clearing Docker build cache', async () => {
confirmPruneImages();
await waitFor(() => {
expect(screen.getByText('Clear Docker build cache')).toBeVisible();
});
});
it('should return clearBuildCache: true when build cache switch is toggled', async () => {
const user = userEvent.setup();
const resultPromise = confirmPruneImages();
const [, buildCacheSwitch] = await screen.findAllByRole('checkbox');
await user.click(buildCacheSwitch);
const confirmButton = screen.getByRole('button', { name: /continue/i });
await user.click(confirmButton);
const result = await resultPromise;
expect(result).toEqual({ pruneAll: false, clearBuildCache: true });
});
it('should show validation message when no untagged images but unused images exist', async () => {
@@ -94,7 +116,7 @@ describe('ConfirmPruneModal', () => {
screen.getByText(
/No untagged \(dangling\) images available to delete\./
)
).not.toHaveClass('invisible');
).toBeVisible();
});
});
@@ -111,10 +133,10 @@ describe('ConfirmPruneModal', () => {
await waitFor(() => {
expect(
screen.getByText(
screen.queryByText(
/No untagged \(dangling\) images available to delete\./
)
).toHaveClass('invisible');
).not.toBeInTheDocument();
});
});
@@ -135,18 +157,18 @@ describe('ConfirmPruneModal', () => {
screen.getByText(
/No untagged \(dangling\) images available to delete\./
)
).not.toHaveClass('invisible');
).toBeVisible();
});
const switchInput = await screen.findByRole('checkbox');
await user.click(switchInput);
const [pruneAllSwitch] = await screen.findAllByRole('checkbox');
await user.click(pruneAllSwitch);
await waitFor(() => {
expect(
screen.getByText(
screen.queryByText(
/No untagged \(dangling\) images available to delete\./
)
).toHaveClass('invisible');
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import clsx from 'clsx';
import { Modal, OnSubmit, ModalType, openModal } from '@@/modals';
import { Button } from '@@/buttons';
@@ -8,12 +7,13 @@ import { SwitchField } from '@@/form-components/SwitchField';
import { ImagesListResponse } from '../../queries/useImages';
interface Props {
onSubmit: OnSubmit<{ pruneAll: boolean }>;
onSubmit: OnSubmit<{ pruneAll: boolean; clearBuildCache: boolean }>;
images?: ImagesListResponse[];
}
function ConfirmPruneModal({ onSubmit, images = [] }: Props) {
const [pruneAll, setPruneAll] = useState(false);
const [clearBuildCache, setClearBuildCache] = useState(false);
const hasUntaggedImages = images.some(
(img) => !img.tags || img.tags.length === 0
@@ -29,23 +29,29 @@ function ConfirmPruneModal({ onSubmit, images = [] }: Props) {
<p>
This will delete all untagged (dangling) images in this environment.
</p>
<SwitchField
name="pruneAll"
data-cy="prune-all-unused-switch"
label="Delete all unused images"
tooltip="Delete all unused images, even if they are tagged."
checked={pruneAll}
onChange={setPruneAll}
/>
<p
className={clsx(
'text-muted mt-1 text-xs',
// use invisible class to avoid layout shift
showValidationMessage ? 'visible' : 'invisible'
<div className="mb-4">
<SwitchField
name="pruneAll"
data-cy="prune-all-unused-switch"
label="Delete all unused images"
tooltip="Delete all unused images, even if they are tagged."
checked={pruneAll}
onChange={setPruneAll}
/>
{showValidationMessage && (
<p className="text-muted mt-1 text-xs">
No untagged (dangling) images available to delete.
</p>
)}
>
No untagged (dangling) images available to delete.
</p>
</div>
<SwitchField
name="clearBuildCache"
data-cy="prune-clear-build-cache-switch"
label="Clear Docker build cache"
tooltip="This removes cached build layers that are no longer in use. Future builds may take longer until the cache is rebuilt."
checked={clearBuildCache}
onChange={setClearBuildCache}
/>
</Modal.Body>
<Modal.Footer>
<Button
@@ -56,7 +62,7 @@ function ConfirmPruneModal({ onSubmit, images = [] }: Props) {
Cancel
</Button>
<Button
onClick={() => onSubmit({ pruneAll })}
onClick={() => onSubmit({ pruneAll, clearBuildCache })}
color="danger"
data-cy="prune-confirm"
>

View File

@@ -148,7 +148,10 @@ describe('PruneButton', () => {
});
it('should call API with dangling filter when pruneAll is false', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: false });
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
let requestFilters = '';
server.use(
@@ -179,7 +182,10 @@ describe('PruneButton', () => {
});
it('should call API with dangling=false filter when pruneAll is true', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: true });
mockConfirmPruneImages.mockResolvedValue({
pruneAll: true,
clearBuildCache: false,
});
let requestFilters = '';
server.use(
@@ -215,7 +221,10 @@ describe('PruneButton', () => {
describe('Success Notification', () => {
it('should show success notification with space reclaimed', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: false });
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
@@ -245,7 +254,10 @@ describe('PruneButton', () => {
});
it('should show success notification with correct space reclaimed', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: false });
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
@@ -275,7 +287,10 @@ describe('PruneButton', () => {
});
it('should handle small space reclaimed', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: false });
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
@@ -300,8 +315,11 @@ describe('PruneButton', () => {
});
});
it('should handle zero space reclaimed', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: false });
it('should show contextual message when zero bytes reclaimed', async () => {
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
@@ -321,15 +339,95 @@ describe('PruneButton', () => {
await waitFor(() => {
expect(mockNotifySuccess).toHaveBeenCalledWith(
'Images pruned',
'Reclaimed 0 B'
'Reclaimed 0 B - the image layers may still be in use by other images, or are still in the Docker build cache.'
);
});
});
it('should call build cache prune API when clearBuildCache is toggled', async () => {
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: true,
});
let buildPruneCalled = false;
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
HttpResponse.json({ ImagesDeleted: [], SpaceReclaimed: 0 })
),
http.post('/api/endpoints/:envId/docker/build/prune', () => {
buildPruneCalled = true;
return HttpResponse.json({ CachesDeleted: [], SpaceReclaimed: 0 });
})
);
const user = userEvent.setup();
renderComponent([{ id: 'img1', used: false, tags: ['tag1'] }]);
await user.click(screen.getByRole('button', { name: /prune/i }));
await waitFor(() => expect(mockNotifySuccess).toHaveBeenCalled());
expect(buildPruneCalled).toBe(true);
});
it('should not call build cache prune API when clearBuildCache is false', async () => {
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
let buildPruneCalled = false;
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
HttpResponse.json({ ImagesDeleted: [], SpaceReclaimed: 0 })
),
http.post('/api/endpoints/:envId/docker/build/prune', () => {
buildPruneCalled = true;
return HttpResponse.json({ CachesDeleted: [], SpaceReclaimed: 0 });
})
);
const user = userEvent.setup();
renderComponent([{ id: 'img1', used: false, tags: ['tag1'] }]);
await user.click(screen.getByRole('button', { name: /prune/i }));
await waitFor(() => expect(mockNotifySuccess).toHaveBeenCalled());
expect(buildPruneCalled).toBe(false);
});
it('should show combined space reclaimed from image and build cache prune', async () => {
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: true,
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
HttpResponse.json({ ImagesDeleted: [], SpaceReclaimed: 52428800 })
),
http.post('/api/endpoints/:envId/docker/build/prune', () =>
HttpResponse.json({ CachesDeleted: [], SpaceReclaimed: 52428800 })
)
);
const user = userEvent.setup();
renderComponent([{ id: 'img1', used: false, tags: ['tag1'] }]);
await user.click(screen.getByRole('button', { name: /prune/i }));
await waitFor(() =>
expect(mockNotifySuccess).toHaveBeenCalledWith(
'Images pruned',
'Reclaimed 104.9 MB'
)
);
});
});
describe('Error Handling', () => {
it('should show error notification on API failure', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: false });
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
@@ -350,17 +448,55 @@ describe('PruneButton', () => {
);
});
});
it('should show success for images pruned and separate error when build cache prune fails', async () => {
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: true,
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', () =>
HttpResponse.json({ ImagesDeleted: [], SpaceReclaimed: 0 })
),
http.post('/api/endpoints/:envId/docker/build/prune', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 })
)
);
const user = userEvent.setup();
renderComponent([{ id: 'img1', used: false, tags: ['tag1'] }]);
await user.click(screen.getByRole('button', { name: /prune/i }));
await waitFor(() => {
expect(mockNotifySuccess).toHaveBeenCalledWith(
'Images pruned',
expect.stringContaining('Reclaimed 0 B')
);
expect(mockNotifyError).toHaveBeenCalledWith(
'Failed to clear Docker build cache',
expect.anything()
);
});
});
});
describe('Loading State', () => {
it('should show loading state while pruning', async () => {
mockConfirmPruneImages.mockResolvedValue({ pruneAll: false });
mockConfirmPruneImages.mockResolvedValue({
pruneAll: false,
clearBuildCache: false,
});
// Use a deferred promise so we control exactly when the request resolves,
// avoiding non-deterministic setTimeout timing.
let resolveRequest!: () => void;
const requestDeferred = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
server.use(
http.post('/api/endpoints/:envId/docker/images/prune', async () => {
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
await requestDeferred;
return HttpResponse.json({
ImagesDeleted: [],
SpaceReclaimed: 0,
@@ -377,6 +513,20 @@ describe('PruneButton', () => {
await waitFor(() => {
expect(screen.getByText('Pruning...')).toBeVisible();
});
resolveRequest();
// Wait for the in-flight request to complete before the test exits.
await waitFor(() => expect(mockNotifySuccess).toHaveBeenCalled(), {
timeout: 3000,
});
// Flush any remaining queued XHR events (e.g. loadend ProgressEvent) so
// they fire before jsdom tears down the environment, preventing an
// "ProgressEvent is not defined" unhandled rejection.
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
});
});
});

View File

@@ -19,7 +19,7 @@ interface Props {
export function PruneButton({ images }: Props) {
const environmentId = useEnvironmentId();
const pruneImagesMutation = usePruneImagesMutation(environmentId);
const pruneMutation = usePruneImagesMutation(environmentId);
const hasPrunableImages = images.some((image) => !image.used);
@@ -28,7 +28,7 @@ export function PruneButton({ images }: Props) {
color="default"
icon={hasPrunableImages ? BrushCleaning : Check}
onClick={handlePrune}
isLoading={pruneImagesMutation.isLoading}
isLoading={pruneMutation.isLoading}
loadingText="Pruning..."
data-cy="image-pruneButton"
disabled={!hasPrunableImages}
@@ -60,12 +60,18 @@ export function PruneButton({ images }: Props) {
return;
}
pruneImagesMutation.mutate(
{ all: result.pruneAll },
pruneMutation.mutate(
{ all: result.pruneAll, clearBuildCache: result.clearBuildCache },
{
onSuccess: (data) => {
const space = humanize(data.SpaceReclaimed);
notifySuccess('Images pruned', `Reclaimed ${space}`);
onSuccess: ({ SpaceReclaimed, buildCacheError }) => {
const message =
SpaceReclaimed === 0
? 'Reclaimed 0 B - the image layers may still be in use by other images, or are still in the Docker build cache.'
: `Reclaimed ${humanize(SpaceReclaimed)}`;
notifySuccess('Images pruned', message);
if (buildCacheError) {
notifyError('Failed to clear Docker build cache', buildCacheError);
}
},
onError: (error) => {
notifyError('Failed to prune images', error);

View File

@@ -8,21 +8,50 @@ import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
import { queryKeys } from './queryKeys';
interface PruneImagesResponse {
ImagesDeleted: Array<{ Deleted?: string; Untagged?: string }> | null;
SpaceReclaimed: number;
interface PruneOptions {
all?: boolean;
clearBuildCache?: boolean;
}
export function usePruneImagesMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (options: { all?: boolean }) =>
pruneImages(environmentId, options),
mutationFn: (options: PruneOptions) => pruneAll(environmentId, options),
...withInvalidate(queryClient, [queryKeys.base(environmentId)]),
});
}
export interface PruneResult {
SpaceReclaimed: number;
buildCacheError?: unknown;
}
async function pruneAll(
environmentId: EnvironmentId,
{ all = false, clearBuildCache = false }: PruneOptions
): Promise<PruneResult> {
const imageData = await pruneImages(environmentId, { all });
let spaceReclaimed = imageData.SpaceReclaimed;
if (!clearBuildCache) {
return { SpaceReclaimed: spaceReclaimed };
}
try {
const cacheData = await pruneBuildCache(environmentId);
spaceReclaimed += cacheData.SpaceReclaimed;
return { SpaceReclaimed: spaceReclaimed };
} catch (buildCacheError) {
return { SpaceReclaimed: spaceReclaimed, buildCacheError };
}
}
interface PruneImagesResponse {
ImagesDeleted: Array<{ Deleted?: string; Untagged?: string }> | null;
SpaceReclaimed: number;
}
async function pruneImages(
environmentId: EnvironmentId,
{ all = false }: { all?: boolean }
@@ -42,3 +71,22 @@ async function pruneImages(
throw parseAxiosError(err, 'Unable to prune images');
}
}
interface PruneBuildCacheResponse {
CachesDeleted: string[] | null;
SpaceReclaimed: number;
}
async function pruneBuildCache(
environmentId: EnvironmentId
): Promise<PruneBuildCacheResponse> {
try {
const { data } = await axios.post<PruneBuildCacheResponse>(
buildDockerProxyUrl(environmentId, 'build', 'prune'),
null
);
return data;
} catch (err) {
throw parseAxiosError(err, 'Unable to prune build cache');
}
}

View File

@@ -41,6 +41,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
"
@@ -61,6 +62,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
-e EDGE_GROUPS=1:2 \\
-e PORTAINER_GROUP=5 \\
-e PORTAINER_TAGS=10:20 \\
@@ -85,6 +87,7 @@ sudo podman run -d \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e EDGE_ASYNC=1 \\
-e PODMAN=1 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
"
@@ -105,6 +108,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
-e MY_VAR=value1 \\
-e ANOTHER_VAR=value2 \\
--name portainer_edge_agent \\
@@ -129,6 +133,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
"
@@ -149,6 +154,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
-e EDGE_GROUPS=1:2:3 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
@@ -169,6 +175,7 @@ sudo podman run -d \\
-e EDGE_ID=test-edge-id \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e PODMAN=1 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
"
@@ -189,6 +196,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
-e PORTAINER_GROUP=5 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
@@ -210,6 +218,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=1 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
"
@@ -230,6 +239,7 @@ sudo podman run -d \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e AGENT_SECRET=test-secret \\
-e PODMAN=1 \\
-e PORTAINER_TAGS=10:20:30 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
@@ -250,6 +260,7 @@ sudo podman run -d \\
-e EDGE_ID=test-edge-id \\
-e EDGE_KEY=test-edge-key \\
-e EDGE_INSECURE_POLL=0 \\
-e PODMAN=1 \\
--name portainer_edge_agent \\
docker.io/portainer/agent:2.19.0
"

View File

@@ -8,6 +8,30 @@ import {
} from './scripts';
import { ScriptFormValues } from './types';
it('buildLinuxPodmanCommand should include PODMAN=1', () => {
const command = buildLinuxPodmanCommand(
'2.19.0',
'test-edge-key',
{
allowSelfSignedCertificates: false,
authEnabled: false,
edgeGroupsIds: [],
edgeIdGenerator: '',
envVars: '',
group: 0,
os: 'linux',
platform: 'podman',
tagsIds: [],
tlsEnabled: false,
},
false,
'test-edge-id',
'test-secret'
);
expect(command).toContain('PODMAN=1');
});
describe.each([
{
name: 'buildLinuxStandaloneCommand',

View File

@@ -106,6 +106,7 @@ export function buildLinuxPodmanCommand(
agentSecret,
useAsyncMode
),
'PODMAN=1',
...metaEnvVars(properties),
]);

View File

@@ -22,7 +22,7 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
const jobResultsQuery = useJobResults(jobId, {
...queryOptionsFromTableState({ ...tableState }, sortOptions),
refetchInterval(dataset) {
const anyCollecting = dataset?.data.some(
const anyCollecting = dataset?.data?.some(
(r) => r.LogsStatus === LogsStatus.Pending
);

View File

@@ -0,0 +1,20 @@
import { useEffect, useRef } from 'react';
/**
* Like `useEffect`, but skips the callback on the initial render.
* Only runs when a dependency changes after the component has mounted.
*/
export function useUpdateEffect(
fn: () => void,
deps: React.DependencyList
): void {
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
fn();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}

View File

@@ -0,0 +1,80 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { EnvironmentHeader } from './EnvironmentHeader';
const mockCounts = {
total: 10,
up: 7,
down: 2,
unassigned: 1,
};
function renderComponent(
props: Partial<React.ComponentProps<typeof EnvironmentHeader>> = {}
) {
const defaultProps: React.ComponentProps<typeof EnvironmentHeader> = {
activeFilter: 'all',
onFilterChange: vi.fn(),
...props,
};
const Wrapped = withTestQueryProvider(EnvironmentHeader);
return { ...render(<Wrapped {...defaultProps} />), props: defaultProps };
}
function mockSummaryCounts(counts = mockCounts) {
server.use(
http.get('/api/endpoints/summary', () => HttpResponse.json(counts))
);
}
describe('EnvironmentHeader', () => {
it('should render counts and toggle filter on click', async () => {
const user = userEvent.setup();
const onFilterChange = vi.fn();
mockSummaryCounts();
renderComponent({ onFilterChange });
await waitFor(() => {
expect(
screen.getByRole('radio', { name: /filter by up/i })
).toBeVisible();
});
expect(
screen.getByRole('radio', { name: /filter by total/i })
).toBeVisible();
expect(
screen.getByRole('radio', { name: /filter by down/i })
).toBeVisible();
expect(
screen.getByRole('radio', { name: /filter by unassigned/i })
).toBeVisible();
await user.click(screen.getByRole('radio', { name: /filter by up/i }));
expect(onFilterChange).toHaveBeenCalledWith('up');
});
it('should reset to all when Total is clicked while a filter is active', async () => {
const user = userEvent.setup();
const onFilterChange = vi.fn();
mockSummaryCounts();
renderComponent({ activeFilter: 'up', onFilterChange });
await waitFor(() => {
expect(
screen.getByRole('radio', { name: /filter by total/i })
).toBeVisible();
});
await user.click(screen.getByRole('radio', { name: /filter by total/i }));
expect(onFilterChange).toHaveBeenCalledWith('all');
});
});

View File

@@ -0,0 +1,57 @@
import { useEnvironmentSummaryCounts } from '@/react/portainer/environments/queries/useEnvironmentSummaryCounts';
import {
StatusSummaryBar,
StatusSegment,
} from '@@/StatusSummaryBar/StatusSummaryBar';
export type HeaderFilter =
| 'all'
| 'custom'
| 'up'
| 'down'
| 'outdated'
| 'unassigned';
interface Props {
activeFilter: HeaderFilter;
onFilterChange: (filter: HeaderFilter) => void;
}
export function EnvironmentHeader({ activeFilter, onFilterChange }: Props) {
const countsQuery = useEnvironmentSummaryCounts();
const counts = countsQuery.data;
if (countsQuery.isLoading || !counts || counts.total === 0) {
return null;
}
const segments: StatusSegment[] = [
{ key: 'up', label: 'Up', count: counts.up, color: 'success' },
{ key: 'down', label: 'Down', count: counts.down, color: 'error' },
{
key: 'outdated',
label: 'Outdated',
count: counts.outdated,
color: 'warning',
},
{
key: 'unassigned',
label: 'Unassigned',
count: counts.unassigned,
color: 'gray',
},
];
return (
<StatusSummaryBar
total={counts.total}
segments={segments}
value={activeFilter === 'all' ? null : activeFilter}
onChange={(f) => onFilterChange((f ?? 'all') as HeaderFilter)}
radioGroupName="environment-status-filter"
data-cy="environment-status-bar"
ariaLabel="Filter by environment status"
/>
);
}

View File

@@ -1,12 +1,7 @@
import { AlertTriangle } from 'lucide-react';
import { Environment } from '@/react/portainer/environments/types';
import {
isAgentEnvironment,
isEdgeEnvironment,
} from '@/react/portainer/environments/utils';
import { isVersionSmaller } from '@/react/common/semver-utils';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
import { Tooltip } from '@@/Tip/Tooltip';
import { Icon } from '@@/Icon';
@@ -16,37 +11,21 @@ export function AgentDetails({ environment }: { environment: Environment }) {
return null;
}
if (isEdgeEnvironment(environment.Type)) {
return <EdgeAgentDetails environment={environment} />;
}
const { Version: agentVersion, IsOutdated } = environment.Agent;
return <span>{environment.Agent.Version}</span>;
}
function EdgeAgentDetails({ environment }: { environment: Environment }) {
const { data: systemStatus } = useSystemStatus();
const associated = !!environment.EdgeID;
if (!systemStatus || !associated) {
return null;
}
const agentVersion = environment.Agent.Version;
const { Version } = systemStatus;
const isSmaller =
!agentVersion || // agents before 2.15 don't send the version so it will be empty
isVersionSmaller(agentVersion, Version);
if (!isSmaller) {
return <span>{agentVersion}</span>;
if (!IsOutdated) {
return (
<span className="small text-muted vertical-center font-medium">
{agentVersion}
</span>
);
}
return (
<span className="flex items-center gap-1">
<span className="small text-muted vertical-center flex items-center gap-1 font-medium">
<Icon icon={AlertTriangle} className="icon-warning" />
<span className="icon-warning">{agentVersion || '< 2.15'}</span>
<Tooltip message="Features and bug fixes in your current Portainer Server release may not be available to this Edge Agent until it is upgraded." />
<Tooltip message="A newer agent version is available. Upgrade to access the latest features and bug fixes." />
</span>
);
}

View File

@@ -86,6 +86,7 @@ function ButtonsGrid({
children: ReactNode[];
className?: string;
}) {
const elementChildren = children.filter((child) => child);
return (
<div
className={clsx(
@@ -95,17 +96,17 @@ function ButtonsGrid({
// * hovering the buttons won't make the button's icon flicker
'rounded-r-lg border border-solid',
'border-y-transparent border-r-transparent',
'border-l-gray-5 th-highcontrast:border-l-white th-dark:border-l-gray-9',
'border-l-gray-4 th-highcontrast:border-l-white th-dark:border-l-gray-8',
'overflow-hidden',
className
)}
>
{children.map((child, index) => (
{elementChildren.map((child, index) => (
<div
key={index}
className={clsx({
'border-0 border-b border-solid border-b-gray-5 th-highcontrast:border-b-white th-dark:border-b-gray-9':
index < children.length - 1,
'border-0 border-b border-solid border-b-gray-4 th-highcontrast:border-b-white th-dark:border-b-gray-8':
index < elementChildren.length - 1,
})}
>
{child}

View File

@@ -0,0 +1,141 @@
import { http, HttpResponse } from 'msw';
import { render, screen } from '@testing-library/react';
import { createMockEnvironment } from '@/react-tools/test-mocks';
import {
Environment,
EnvironmentStatus,
EnvironmentType,
} from '@/react/portainer/environments/types';
import { UserViewModel } from '@/portainer/models/user';
import { server } from '@/setup-tests/server';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { EnvironmentCard } from './EnvironmentCard';
function renderCard(env: Environment, groupName?: string, isAdmin = false) {
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
server.use(http.get('/api/tags', () => HttpResponse.json([])));
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(EnvironmentCard, user))
);
return render(
<Wrapped environment={env} groupName={groupName} onClickBrowse={() => {}} />
);
}
describe('EnvironmentCard', () => {
it('renders the environment name', () => {
const env = createMockEnvironment({ Name: 'my-env' });
renderCard(env);
expect(screen.getByText('my-env')).toBeVisible();
});
it('renders the group name when provided', () => {
const env = createMockEnvironment();
renderCard(env, 'Production');
expect(screen.getByText(/Production/)).toBeVisible();
});
it('shows Unassigned when no group name is provided', () => {
const env = createMockEnvironment();
renderCard(env);
expect(screen.getByText(/Unassigned/)).toBeVisible();
});
describe('Live connect / disconnect', () => {
it('does not render a Live connect button', () => {
const env = createMockEnvironment();
renderCard(env);
expect(
screen.queryByRole('link', { name: /live connect/i })
).not.toBeInTheDocument();
});
it('does not render a Disconnect button', () => {
const env = createMockEnvironment();
renderCard(env);
expect(
screen.queryByRole('button', { name: /disconnect/i })
).not.toBeInTheDocument();
});
});
describe('Browse snapshot button', () => {
it('is hidden when environment is Up', () => {
const env = createMockEnvironment({
Type: EnvironmentType.EdgeAgentOnDocker,
Status: EnvironmentStatus.Up,
Edge: {
AsyncMode: true,
PingInterval: 0,
CommandInterval: 0,
SnapshotInterval: 0,
},
Snapshots: [{ Time: Date.now() / 1000 } as never],
});
renderCard(env);
expect(
screen.queryByRole('link', { name: /browse snapshot/i })
).not.toBeInTheDocument();
});
it('is hidden when environment is Down but has no snapshot', () => {
const env = createMockEnvironment({
Type: EnvironmentType.EdgeAgentOnDocker,
Status: EnvironmentStatus.Down,
Edge: {
AsyncMode: true,
PingInterval: 0,
CommandInterval: 0,
SnapshotInterval: 0,
},
Snapshots: [],
});
renderCard(env);
expect(
screen.queryByRole('link', { name: /browse snapshot/i })
).not.toBeInTheDocument();
});
// TODO: enable when BrowseSnapshotButton is uncommented [C9S-46]
it.todo('is visible when environment is Down and has a snapshot', () => {
const env = createMockEnvironment({
Type: EnvironmentType.EdgeAgentOnDocker,
Status: EnvironmentStatus.Down,
Edge: {
AsyncMode: true,
PingInterval: 0,
CommandInterval: 0,
SnapshotInterval: 0,
},
Snapshots: [{ Time: Date.now() / 1000 } as never],
});
renderCard(env);
expect(
screen.getByRole('link', { name: /browse snapshot/i })
).toBeVisible();
});
});
describe('edge environment', () => {
it('renders EdgeIndicator for edge environments', () => {
const env = createMockEnvironment({
Type: EnvironmentType.EdgeAgentOnDocker,
Edge: {
AsyncMode: false,
PingInterval: 0,
CommandInterval: 0,
SnapshotInterval: 0,
},
});
renderCard(env);
expect(screen.getByLabelText('edge-status')).toBeVisible();
});
});
});

View File

@@ -0,0 +1,135 @@
import { Clock } from 'lucide-react';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import {
type Environment,
PlatformType,
} from '@/react/portainer/environments/types';
import {
getDashboardRoute,
getPlatformType,
isEdgeEnvironment,
isSnapshotBrowsingSupported,
} from '@/react/portainer/environments/utils';
import { EnvironmentURL } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentURL';
import { EnvironmentGroupName } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentGroupName';
import { EnvironmentStats } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentStats';
import { AgentDetails } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/AgentDetails';
import { EnvironmentTypeTag } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentTypeTag';
import { SnapshotBadge } from '@@/SnapshotBadge';
import { EdgeIndicator } from '@@/EdgeIndicator';
import { EnvironmentStatusBadge } from '@@/EnvironmentStatusBadge';
import { Link } from '@@/Link';
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
import { EnvironmentIcon } from './EnvironmentIcon';
import { EngineVersion } from './EngineVersion';
import { EditButtons } from './EditButtons';
interface Props {
environment: Environment;
groupName?: string;
onClickBrowse(): void;
}
export function EnvironmentCard({
environment,
groupName,
onClickBrowse,
}: Props) {
const isEdge = isEdgeEnvironment(environment.Type);
const snapshotTime = getSnapshotTime(environment);
const dashboardRoute = getDashboardRoute(environment);
const hasDashboardRoute = !!dashboardRoute.to;
const showSnapshotButton =
isSnapshotBrowsingSupported(environment) &&
!environment.Heartbeat &&
environment.Snapshots.length > 0;
return (
<div className="relative">
<BlocklistItem
as={hasDashboardRoute ? Link : 'button'}
className="!m-0 flex flex-wrap gap-4 !border-none !pr-14"
onClick={hasDashboardRoute ? onClickBrowse : undefined}
aria-disabled={!hasDashboardRoute}
to={dashboardRoute.to}
params={dashboardRoute.params}
data-cy={`environment-card-${environment.Name}`}
>
<div className="flex grow gap-3">
<div className="flex items-center justify-center self-center rounded-lg bg-blue-9/10 p-2 pt-2">
<EnvironmentIcon
type={environment.Type}
containerEngine={environment.ContainerEngine}
/>
</div>
<div className="flex flex-1 grow flex-col gap-1">
{/* First row - title */}
<div className="flex items-center gap-2">
<span className="text-sm font-bold">{environment.Name}</span>
<EnvironmentStatusBadge environment={environment} />
{showSnapshotButton && <SnapshotBadge />}
</div>
{/* Middle row - status info */}
<div className="flex items-center gap-2">
{isEdge ? (
<EdgeIndicator environment={environment} showLastCheckInDate />
) : (
<>
{snapshotTime && (
<span
className="small text-muted vertical-center gap-1"
title="Last snapshot time"
>
<Clock className="icon icon-sm" aria-hidden="true" />
{snapshotTime}
</span>
)}
</>
)}
<EngineVersion environment={environment} />
<span className="small text-muted vertical-center"></span>
<EnvironmentURL environment={environment} />
</div>
<div className="flex items-center gap-2">
<EnvironmentGroupName groupName={groupName} />
<EnvironmentTypeTag environment={environment} />
<AgentDetails environment={environment} />
</div>
</div>
</div>
<EnvironmentStats environment={environment} />
</BlocklistItem>
{/*
Buttons are extracted out of the main button because it causes errors with react and accessibility issues
see https://stackoverflow.com/questions/66409964/warning-validatedomnesting-a-cannot-appear-as-a-descendant-of-a
*/}
<div className="absolute inset-y-0 right-0 flex w-56 justify-end">
<EditButtons environment={environment} />
</div>
</div>
);
}
function getSnapshotTime(environment: Environment) {
const platform = getPlatformType(environment.Type);
switch (platform) {
case PlatformType.Docker:
return environment.Snapshots.length > 0
? isoDateFromTimestamp(environment.Snapshots[0].Time)
: null;
case PlatformType.Kubernetes:
return environment.Kubernetes.Snapshots &&
environment.Kubernetes.Snapshots.length > 0
? isoDateFromTimestamp(environment.Kubernetes.Snapshots[0].Time)
: null;
default:
return null;
}
}

View File

@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import { EnvironmentGroupName } from './EnvironmentGroupName';
describe('EnvironmentGroupName', () => {
it('renders the provided group name', () => {
render(<EnvironmentGroupName groupName="Production" />);
expect(screen.getByText(/Production/)).toBeVisible();
});
it('renders Unassigned when no group name is provided', () => {
render(<EnvironmentGroupName />);
expect(screen.getByText(/Unassigned/)).toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More