Compare commits
18 Commits
develop
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5d5cab68 | ||
|
|
51ffea9c93 | ||
|
|
eb0ee117a5 | ||
|
|
c52767fb04 | ||
|
|
8e39a16172 | ||
|
|
e964be75db | ||
|
|
6776b01ac8 | ||
|
|
b96031965a | ||
|
|
b2a2e5c222 | ||
|
|
27285a94ac | ||
|
|
b3f01973ec | ||
|
|
17ffd62480 | ||
|
|
86f6aba362 | ||
|
|
718e11ccd0 | ||
|
|
e68b0e80f1 | ||
|
|
9a14f2acb7 | ||
|
|
01ff1486e0 | ||
|
|
b91f77a554 |
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.41.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.41.1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -947,7 +947,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.41.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.41.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
59
api/gitops/workflows/git_phases.go
Normal file
59
api/gitops/workflows/git_phases.go
Normal 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}
|
||||
}
|
||||
162
api/gitops/workflows/git_phases_test.go
Normal file
162
api/gitops/workflows/git_phases_test.go
Normal 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")
|
||||
}
|
||||
137
api/gitops/workflows/mapping.go
Normal file
137
api/gitops/workflows/mapping.go
Normal 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
|
||||
}
|
||||
149
api/gitops/workflows/mapping_test.go
Normal file
149
api/gitops/workflows/mapping_test.go
Normal 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)])
|
||||
})
|
||||
}
|
||||
112
api/gitops/workflows/status.go
Normal file
112
api/gitops/workflows/status.go
Normal 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
|
||||
}
|
||||
}
|
||||
151
api/gitops/workflows/status_test.go
Normal file
151
api/gitops/workflows/status_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
98
api/gitops/workflows/types.go
Normal file
98
api/gitops/workflows/types.go
Normal 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"`
|
||||
}
|
||||
65
api/gitops/workflows/types_test.go
Normal file
65
api/gitops/workflows/types_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
229
api/http/handler/endpoints/endpoint_summary_counts.go
Normal file
229
api/http/handler/endpoints/endpoint_summary_counts.go
Normal 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
|
||||
}
|
||||
349
api/http/handler/endpoints/endpoint_summary_counts_test.go
Normal file
349
api/http/handler/endpoints/endpoint_summary_counts_test.go
Normal 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(" "))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
122
api/http/handler/gitops/workflows/filter.go
Normal file
122
api/http/handler/gitops/workflows/filter.go
Normal 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
|
||||
}
|
||||
80
api/http/handler/gitops/workflows/filter_test.go
Normal file
80
api/http/handler/gitops/workflows/filter_test.go
Normal 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)
|
||||
}
|
||||
32
api/http/handler/gitops/workflows/git_phases.go
Normal file
32
api/http/handler/gitops/workflows/git_phases.go
Normal 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 "", ""
|
||||
}
|
||||
42
api/http/handler/gitops/workflows/handler.go
Normal file
42
api/http/handler/gitops/workflows/handler.go
Normal 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
|
||||
}
|
||||
42
api/http/handler/gitops/workflows/helpers_test.go
Normal file
42
api/http/handler/gitops/workflows/helpers_test.go
Normal 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"}
|
||||
}
|
||||
269
api/http/handler/gitops/workflows/list.go
Normal file
269
api/http/handler/gitops/workflows/list.go
Normal 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, ",")
|
||||
}
|
||||
386
api/http/handler/gitops/workflows/list_test.go
Normal file
386
api/http/handler/gitops/workflows/list_test.go
Normal 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)
|
||||
}
|
||||
84
api/http/handler/gitops/workflows/status_test.go
Normal file
84
api/http/handler/gitops/workflows/status_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
35
api/http/handler/gitops/workflows/summary.go
Normal file
35
api/http/handler/gitops/workflows/summary.go
Normal 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))
|
||||
}
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.41.0
|
||||
// @version 2.41.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1942,7 +1945,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.41.0"
|
||||
APIVersion = "2.41.1"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
@@ -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
|
||||
|
||||
@@ -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
10
api/slicesx/group_by.go
Normal 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
|
||||
}
|
||||
21
api/slicesx/group_by_test.go
Normal file
21
api/slicesx/group_by_test.go
Normal 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
19
api/slicesx/partition.go
Normal 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
|
||||
}
|
||||
32
api/slicesx/partition_test.go
Normal file
32
api/slicesx/partition_test.go
Normal 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 },
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
13
app/portainer/helpers/stateParamHelper.js
Normal file
13
app/portainer/helpers/stateParamHelper.js
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -85,7 +85,7 @@ export function withPaginationQueryParams({
|
||||
}
|
||||
|
||||
export type PaginatedResults<T> = {
|
||||
data: T;
|
||||
data: T | null;
|
||||
totalCount: number;
|
||||
totalAvailable: number;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
app/react/components/FilterBarActiveIndicator.tsx
Normal file
29
app/react/components/FilterBarActiveIndicator.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
app/react/components/FilterBarButton.test.tsx
Normal file
45
app/react/components/FilterBarButton.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
107
app/react/components/FilterBarButton.tsx
Normal file
107
app/react/components/FilterBarButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
669
app/react/components/GroupSortTable/GroupSortTable.test.tsx
Normal file
669
app/react/components/GroupSortTable/GroupSortTable.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
248
app/react/components/GroupSortTable/GroupSortTable.tsx
Normal file
248
app/react/components/GroupSortTable/GroupSortTable.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
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}
|
||||
sortDesc={tableState.sortBy?.desc ?? false}
|
||||
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);
|
||||
}
|
||||
|
||||
const header = renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0);
|
||||
if (header == null) {
|
||||
return renderRow(row);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={Number.MAX_SAFE_INTEGER} className="!p-0">
|
||||
{header}
|
||||
</td>
|
||||
</tr>
|
||||
{renderRow(row)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function handleSortChange(key: string) {
|
||||
tableState.setPage(1);
|
||||
const newDesc =
|
||||
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
|
||||
tableState.setSortBy(key, newDesc);
|
||||
}
|
||||
|
||||
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],
|
||||
};
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,13 +29,20 @@ type SortKey = (typeof sortOptions)[number]['key'];
|
||||
|
||||
export function Interactive() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('name');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
@@ -50,12 +57,19 @@ export function Interactive() {
|
||||
|
||||
export function WithGroupFilter() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('group');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
@@ -70,13 +84,20 @@ export function WithGroupFilter() {
|
||||
|
||||
export function WithActionButton() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('name');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
|
||||
@@ -21,6 +21,7 @@ function renderHeader(
|
||||
) {
|
||||
const props = {
|
||||
sortBy: 'Group' as string,
|
||||
sortDesc: false,
|
||||
onSortChange: vi.fn(),
|
||||
searchTerm: '',
|
||||
onSearchChange: vi.fn(),
|
||||
@@ -52,14 +53,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();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
@@ -13,19 +13,22 @@ export type { SortOption };
|
||||
|
||||
interface Props<TSortKey extends string> {
|
||||
sortBy: TSortKey;
|
||||
sortDesc: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
searchTerm: 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>({
|
||||
sortBy,
|
||||
sortDesc,
|
||||
onSortChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
@@ -35,26 +38,28 @@ 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',
|
||||
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10',
|
||||
'border-0 border-b border-solid border-gray-4'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<SortByGroup
|
||||
sortBy={sortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={onSortChange}
|
||||
sortOptions={sortOptions}
|
||||
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}
|
||||
|
||||
228
app/react/components/GroupSortTable/SortByGroup.test.tsx
Normal file
228
app/react/components/GroupSortTable/SortByGroup.test.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
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}
|
||||
sortDesc={false}
|
||||
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 non-grouped button calls onSortChange to toggle sort order', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSortChange } = renderComponent({ sortBy: 'Name' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Name Asc/i }));
|
||||
|
||||
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,13 @@ export interface SortOption<TSortKey extends string = string> {
|
||||
key: TSortKey;
|
||||
label: string;
|
||||
grouped?: boolean;
|
||||
descendingLabel?: string;
|
||||
ascendingLabel?: string;
|
||||
}
|
||||
|
||||
export interface SortByGroupProps<TSortKey extends string> {
|
||||
sortBy: TSortKey;
|
||||
sortDesc: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
sortOptions: SortOption<TSortKey>[];
|
||||
groupFilter: string | null;
|
||||
@@ -20,6 +23,7 @@ export interface SortByGroupProps<TSortKey extends string> {
|
||||
|
||||
export function SortByGroup<TSortKey extends string>({
|
||||
sortBy,
|
||||
sortDesc,
|
||||
onSortChange,
|
||||
sortOptions,
|
||||
groupFilter,
|
||||
@@ -28,7 +32,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"
|
||||
@@ -49,6 +53,7 @@ export function SortByGroup<TSortKey extends string>({
|
||||
key={option.key}
|
||||
option={option}
|
||||
isActive={sortBy === option.key}
|
||||
sortDesc={sortDesc}
|
||||
isFirst={index === 0}
|
||||
isLast={index === sortOptions.length - 1}
|
||||
onSortChange={onSortChange}
|
||||
@@ -59,26 +64,30 @@ 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>;
|
||||
isActive: boolean;
|
||||
sortDesc: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
@@ -91,6 +100,7 @@ interface SortOptionItemProps<TSortKey extends string> {
|
||||
function SortOptionItem<TSortKey extends string>({
|
||||
option,
|
||||
isActive,
|
||||
sortDesc,
|
||||
isFirst,
|
||||
isLast,
|
||||
onSortChange,
|
||||
@@ -112,31 +122,61 @@ 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = isActive
|
||||
? sortDesc
|
||||
? option.descendingLabel || 'Desc'
|
||||
: option.ascendingLabel || 'Asc'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => {
|
||||
onSortChange(option.key);
|
||||
if (!isActive) {
|
||||
onSortChange(option.key);
|
||||
onGroupFilterChange(null);
|
||||
}
|
||||
}}
|
||||
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
|
||||
>
|
||||
{option.label}
|
||||
{badge && (
|
||||
<span className="py-0.2 ml-1 rounded-md bg-blue-7 px-1 text-[10px] font-normal text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
||||
18
app/react/components/GroupSortTable/store.ts
Normal file
18
app/react/components/GroupSortTable/store.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
22
app/react/components/SnapshotBadge.tsx
Normal file
22
app/react/components/SnapshotBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -58,20 +58,19 @@ export function SortableList<T>({
|
||||
<SortableListCard>
|
||||
<GroupSortTableHeader
|
||||
sortBy={activeSortKey}
|
||||
sortDesc={tableState.sortBy?.desc ?? false}
|
||||
onSortChange={(key) => {
|
||||
tableState.setSortBy(key, false);
|
||||
tableState.setGroupFilter(null);
|
||||
tableState.setPage(0);
|
||||
const newDesc =
|
||||
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
|
||||
tableState.setSortBy(key, newDesc);
|
||||
}}
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
130
app/react/components/datatables/useTableStateFromUrl.test.ts
Normal file
130
app/react/components/datatables/useTableStateFromUrl.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
app/react/components/datatables/useTableStateFromUrl.ts
Normal file
106
app/react/components/datatables/useTableStateFromUrl.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -106,6 +106,7 @@ export function buildLinuxPodmanCommand(
|
||||
agentSecret,
|
||||
useAsyncMode
|
||||
),
|
||||
'PODMAN=1',
|
||||
...metaEnvVars(properties),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
20
app/react/hooks/useUpdateEffect.ts
Normal file
20
app/react/hooks/useUpdateEffect.ts
Normal 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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user