mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:40:13 +00:00
feat(environment-groups): replace Datatable with SortableList and update list UI [R8S-827] (#2661)
This commit is contained in:
+15
-19
@@ -2,23 +2,18 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
|
|
||||||
"github.com/go-git/go-billy/v5"
|
"github.com/go-git/go-billy/v5"
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/config"
|
"github.com/go-git/go-git/v5/config"
|
||||||
"github.com/go-git/go-git/v5/plumbing/cache"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||||
"github.com/go-git/go-git/v5/plumbing/object"
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
|
|
||||||
"github.com/go-git/go-git/v5/storage/memory"
|
"github.com/go-git/go-git/v5/storage/memory"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
|
// noSymlinkFS wraps a billy.Filesystem and rejects symlink creation to prevent
|
||||||
@@ -47,27 +42,28 @@ func NewGitClient(preserveGitDir bool) *gitClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
func (c *gitClient) Download(ctx context.Context, dst string, opt *git.CloneOptions) error {
|
||||||
wt := NewNoSymlinkFS(osfs.New(dst))
|
if c.preserveGitDirectory {
|
||||||
dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
|
_, err := git.PlainCloneContext(ctx, dst, false, opt)
|
||||||
storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
|
if err != nil {
|
||||||
|
if err.Error() == "authentication required" {
|
||||||
|
return gittypes.ErrAuthenticationFailure
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "failed to clone git repository")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
_, err := git.CloneContext(ctx, storer, wt, opt)
|
// Memory storage avoids a macOS filesystem conflict where go-git's init
|
||||||
|
// creates dst/.git as a directory before checkout, causing EISDIR errors
|
||||||
|
// that mask ErrSymlinkDetected from noSymlinkFS.
|
||||||
|
wt := NewNoSymlinkFS(osfs.New(dst))
|
||||||
|
_, err := git.CloneContext(ctx, memory.NewStorage(), wt, opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "authentication required" {
|
if err.Error() == "authentication required" {
|
||||||
return gittypes.ErrAuthenticationFailure
|
return gittypes.ErrAuthenticationFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.Wrap(err, "failed to clone git repository")
|
return errors.Wrap(err, "failed to clone git repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.preserveGitDirectory {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.RemoveAll(filesystem.JoinPaths(dst, ".git")); err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to remove .git directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,29 @@ package endpointgroups
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/set"
|
||||||
|
endpointutils "github.com/portainer/portainer/pkg/endpoints"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type endpointGroupTypeInfo struct {
|
||||||
|
Docker int `json:"Docker"`
|
||||||
|
Kubernetes int `json:"Kubernetes"`
|
||||||
|
Podman int `json:"Podman"`
|
||||||
|
Mixed bool `json:"Mixed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type endpointGroupResponse struct {
|
||||||
|
portainer.EndpointGroup
|
||||||
|
Total int `json:"Total,omitzero"`
|
||||||
|
TypeInfo endpointGroupTypeInfo `json:"TypeInfo,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
// @id EndpointGroupList
|
// @id EndpointGroupList
|
||||||
// @summary List Environment(Endpoint) groups
|
// @summary List Environment(Endpoint) groups
|
||||||
// @description List all environment(endpoint) groups based on the current user authorizations. Will
|
// @description List all environment(endpoint) groups based on the current user authorizations. Will
|
||||||
@@ -18,13 +36,41 @@ import (
|
|||||||
// @security ApiKeyAuth
|
// @security ApiKeyAuth
|
||||||
// @security jwt
|
// @security jwt
|
||||||
// @produce json
|
// @produce json
|
||||||
// @success 200 {array} portainer.EndpointGroup "Environment(Endpoint) group"
|
// @param size query boolean false "If true, each environment(endpoint) group will include the number of environments(endpoints) associated to it and breakdown by type"
|
||||||
|
// @success 200 {array} endpointGroupResponse "Environment(Endpoint) group"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /endpoint_groups [get]
|
// @router /endpoint_groups [get]
|
||||||
func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
|
includeSize, err := request.RetrieveBooleanQueryParameter(r, "size", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
|
return httperror.BadRequest("Invalid query parameter: size", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endpoints []portainer.Endpoint
|
||||||
|
var endpointGroups []portainer.EndpointGroup
|
||||||
|
var handlerErr *httperror.HandlerError
|
||||||
|
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
|
var txErr error
|
||||||
|
endpointGroups, txErr = handler.DataStore.EndpointGroup().ReadAll()
|
||||||
|
if txErr != nil {
|
||||||
|
handlerErr = httperror.InternalServerError("Unable to retrieve environment groups from the database", txErr)
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeSize {
|
||||||
|
endpoints, txErr = tx.Endpoint().Endpoints()
|
||||||
|
if txErr != nil {
|
||||||
|
handlerErr = httperror.InternalServerError("Unable to retrieve endpoints from the database", txErr)
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
if handlerErr != nil {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
return httperror.InternalServerError("Unable to retrieve data from the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
@@ -33,5 +79,69 @@ func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext)
|
endpointGroups = security.FilterEndpointGroups(endpointGroups, securityContext)
|
||||||
return response.JSON(w, endpointGroups)
|
|
||||||
|
if len(endpointGroups) == 0 {
|
||||||
|
return response.JSON(w, []portainer.EndpointGroup{})
|
||||||
|
}
|
||||||
|
endpointGroupSet := set.Set[portainer.EndpointGroupID]{}
|
||||||
|
if includeSize {
|
||||||
|
for i := range endpointGroups {
|
||||||
|
endpointGroupSet[endpointGroups[i].ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var endpointGroupCountMap map[portainer.EndpointGroupID]int
|
||||||
|
var endpointGroupTypeInfoMap map[portainer.EndpointGroupID]endpointGroupTypeInfo
|
||||||
|
if includeSize {
|
||||||
|
endpointGroupCountMap = make(map[portainer.EndpointGroupID]int)
|
||||||
|
endpointGroupTypeInfoMap = make(map[portainer.EndpointGroupID]endpointGroupTypeInfo)
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if _, ok := endpointGroupSet[endpoint.GroupID]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endpointGroupCountMap[endpoint.GroupID]++
|
||||||
|
|
||||||
|
typeInfo := endpointGroupTypeInfoMap[endpoint.GroupID]
|
||||||
|
|
||||||
|
if endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||||
|
typeInfo.Kubernetes++
|
||||||
|
} else if endpoint.ContainerEngine == "podman" {
|
||||||
|
typeInfo.Podman++
|
||||||
|
} else {
|
||||||
|
typeInfo.Docker++
|
||||||
|
}
|
||||||
|
endpointGroupTypeInfoMap[endpoint.GroupID] = typeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
for groupID, typeInfo := range endpointGroupTypeInfoMap {
|
||||||
|
var bits int
|
||||||
|
if typeInfo.Docker > 0 {
|
||||||
|
bits |= 1
|
||||||
|
}
|
||||||
|
if typeInfo.Kubernetes > 0 {
|
||||||
|
bits |= 2
|
||||||
|
}
|
||||||
|
if typeInfo.Podman > 0 {
|
||||||
|
bits |= 4
|
||||||
|
}
|
||||||
|
typeInfo.Mixed = bits&(bits-1) != 0
|
||||||
|
endpointGroupTypeInfoMap[groupID] = typeInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointGroupsResponse := make([]endpointGroupResponse, len(endpointGroups))
|
||||||
|
for i := range endpointGroups {
|
||||||
|
groupID := endpointGroups[i].ID
|
||||||
|
|
||||||
|
endpointGroupsResponse[i] = endpointGroupResponse{
|
||||||
|
EndpointGroup: endpointGroups[i],
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeSize {
|
||||||
|
endpointGroupsResponse[i].Total = endpointGroupCountMap[groupID]
|
||||||
|
endpointGroupsResponse[i].TypeInfo = endpointGroupTypeInfoMap[groupID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, endpointGroupsResponse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package endpointgroups
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/segmentio/encoding/json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandler_endpointGroupList(t *testing.T) {
|
||||||
|
_, store := datastore.MustNewTestStore(t, true, false)
|
||||||
|
handler := setUpHandler(t, store)
|
||||||
|
|
||||||
|
groups := setUpGroups(t, store)
|
||||||
|
|
||||||
|
t.Run("with groups, no size flag", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups", nil)
|
||||||
|
rrc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
res := make([]endpointGroupResponse, 0)
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||||
|
require.Len(t, res, len(groups)+1, "should contain an additional default group")
|
||||||
|
for _, group := range res {
|
||||||
|
assert.Zero(t, group.Total)
|
||||||
|
assert.Zero(t, group.TypeInfo.Docker)
|
||||||
|
assert.Zero(t, group.TypeInfo.Kubernetes)
|
||||||
|
assert.Zero(t, group.TypeInfo.Podman)
|
||||||
|
assert.False(t, group.TypeInfo.Mixed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with size flag, no endpoints", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||||
|
rrc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
res := make([]endpointGroupResponse, 0)
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||||
|
for _, group := range res {
|
||||||
|
assert.Zero(t, group.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with size flag and single docker endpoint", func(t *testing.T) {
|
||||||
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: 1,
|
||||||
|
GroupID: groups[0].ID,
|
||||||
|
Type: portainer.DockerEnvironment,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.Endpoint().Create(endpoint))
|
||||||
|
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(endpoint.ID) })
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||||
|
rrc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
res := make([]endpointGroupResponse, 0)
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||||
|
|
||||||
|
var group1 *endpointGroupResponse
|
||||||
|
for i := range res {
|
||||||
|
if res[i].ID == groups[0].ID {
|
||||||
|
group1 = &res[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, group1)
|
||||||
|
assert.Equal(t, 1, group1.Total)
|
||||||
|
assert.Equal(t, 1, group1.TypeInfo.Docker)
|
||||||
|
assert.Equal(t, 0, group1.TypeInfo.Kubernetes)
|
||||||
|
assert.Equal(t, 0, group1.TypeInfo.Podman)
|
||||||
|
assert.False(t, group1.TypeInfo.Mixed)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with mixed endpoint types", func(t *testing.T) {
|
||||||
|
dockerEndpoint := &portainer.Endpoint{
|
||||||
|
ID: 2,
|
||||||
|
GroupID: groups[1].ID,
|
||||||
|
Type: portainer.DockerEnvironment,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.Endpoint().Create(dockerEndpoint))
|
||||||
|
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) })
|
||||||
|
|
||||||
|
k8sEndpoint := &portainer.Endpoint{
|
||||||
|
ID: 3,
|
||||||
|
GroupID: groups[1].ID,
|
||||||
|
Type: portainer.KubernetesLocalEnvironment,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.Endpoint().Create(k8sEndpoint))
|
||||||
|
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(k8sEndpoint.ID) })
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||||
|
rrc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
res := make([]endpointGroupResponse, 0)
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||||
|
|
||||||
|
var group2 *endpointGroupResponse
|
||||||
|
for i := range res {
|
||||||
|
if res[i].ID == groups[1].ID {
|
||||||
|
group2 = &res[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, group2)
|
||||||
|
assert.Equal(t, 2, group2.Total)
|
||||||
|
assert.Equal(t, 1, group2.TypeInfo.Docker)
|
||||||
|
assert.Equal(t, 1, group2.TypeInfo.Kubernetes)
|
||||||
|
assert.Equal(t, 0, group2.TypeInfo.Podman)
|
||||||
|
assert.True(t, group2.TypeInfo.Mixed, "should be marked as mixed when multiple types exist")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with podman endpoint", func(t *testing.T) {
|
||||||
|
dockerEndpoint := &portainer.Endpoint{
|
||||||
|
ID: 4,
|
||||||
|
GroupID: groups[0].ID,
|
||||||
|
Type: portainer.DockerEnvironment,
|
||||||
|
}
|
||||||
|
require.NoError(t, store.Endpoint().Create(dockerEndpoint))
|
||||||
|
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(dockerEndpoint.ID) })
|
||||||
|
|
||||||
|
podmanEndpoint := &portainer.Endpoint{
|
||||||
|
ID: 5,
|
||||||
|
GroupID: groups[0].ID,
|
||||||
|
Type: portainer.DockerEnvironment,
|
||||||
|
ContainerEngine: "podman",
|
||||||
|
}
|
||||||
|
require.NoError(t, store.Endpoint().Create(podmanEndpoint))
|
||||||
|
t.Cleanup(func() { _ = store.Endpoint().DeleteEndpoint(podmanEndpoint.ID) })
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/endpoint_groups?size=true", nil)
|
||||||
|
rrc := &security.RestrictedRequestContext{
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
res := make([]endpointGroupResponse, 0)
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&res))
|
||||||
|
|
||||||
|
var group1 *endpointGroupResponse
|
||||||
|
for i := range res {
|
||||||
|
if res[i].ID == groups[0].ID {
|
||||||
|
group1 = &res[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, group1)
|
||||||
|
assert.Equal(t, 2, group1.Total)
|
||||||
|
assert.Equal(t, 1, group1.TypeInfo.Docker)
|
||||||
|
assert.Equal(t, 0, group1.TypeInfo.Kubernetes)
|
||||||
|
assert.Equal(t, 1, group1.TypeInfo.Podman)
|
||||||
|
assert.True(t, group1.TypeInfo.Mixed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUpGroups(t *testing.T, store *datastore.Store) []portainer.EndpointGroup {
|
||||||
|
group1 := &portainer.EndpointGroup{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Group 1",
|
||||||
|
}
|
||||||
|
group2 := &portainer.EndpointGroup{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Group 2",
|
||||||
|
}
|
||||||
|
require.NoError(t, store.EndpointGroup().Create(group1))
|
||||||
|
require.NoError(t, store.EndpointGroup().Create(group2))
|
||||||
|
|
||||||
|
return []portainer.EndpointGroup{*group1, *group2}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package endpointgroups
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/datastore"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setUpHandler(t *testing.T, store *datastore.Store) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||||
|
handler.DataStore = store
|
||||||
|
return handler
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export function PageTitle({
|
|||||||
children,
|
children,
|
||||||
}: PropsWithChildren<{ title: string }>) {
|
}: PropsWithChildren<{ title: string }>) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex items-center gap-2 px-[15px]">
|
<div className="mx-5 mb-2 flex items-center gap-2">
|
||||||
<h1
|
<h1
|
||||||
className="m-0 text-lg font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white"
|
className="m-0 text-lg font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white"
|
||||||
data-cy="page-title"
|
data-cy="page-title"
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
.btn-blue {
|
||||||
|
@apply border-blue-8 bg-blue-8 text-white;
|
||||||
|
@apply hover:border-blue-9 hover:bg-blue-9 hover:text-white;
|
||||||
|
@apply th-dark:border-blue-7 th-dark:bg-blue-7 th-dark:text-white th-dark:hover:border-blue-6 th-dark:hover:bg-blue-6 th-dark:hover:text-white;
|
||||||
|
@apply th-highcontrast:border th-highcontrast:border-solid th-highcontrast:border-white th-highcontrast:bg-blue-8 th-highcontrast:text-white th-highcontrast:hover:bg-blue-9 th-highcontrast:hover:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-none {
|
.btn-none {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ type Color =
|
|||||||
| 'warninglight'
|
| 'warninglight'
|
||||||
| 'warning'
|
| 'warning'
|
||||||
| 'success'
|
| 'success'
|
||||||
|
| 'blue'
|
||||||
| 'none';
|
| 'none';
|
||||||
type Size = 'xsmall' | 'small' | 'medium' | 'large';
|
type Size = 'xsmall' | 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
export interface Props<TasProps = unknown>
|
export interface Props<TasProps = unknown>
|
||||||
extends AriaAttributes, AutomationTestingProps {
|
extends AriaAttributes,
|
||||||
|
AutomationTestingProps {
|
||||||
icon?: ReactNode | ComponentType<unknown>;
|
icon?: ReactNode | ComponentType<unknown>;
|
||||||
|
|
||||||
color?: Color;
|
color?: Color;
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { useRouter } from '@uirouter/react';
|
import { useRouter } from '@uirouter/react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { FormikHelpers } from 'formik';
|
import { FormikHelpers } from 'formik';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useIdParam } from '@/react/hooks/useIdParam';
|
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
import { Alert } from '@@/Alert';
|
import { Alert } from '@@/Alert';
|
||||||
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
|
||||||
import { useGroup } from '../queries/useGroup';
|
import { useGroup } from '../queries/useGroup';
|
||||||
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
|
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
|
||||||
|
import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
|
||||||
|
import { queryKeys } from '../queries/query-keys';
|
||||||
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
import { GroupForm, GroupFormValues } from '../components/GroupForm';
|
||||||
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
|
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
|
||||||
|
|
||||||
export function EditGroupView() {
|
export function EditGroupView() {
|
||||||
const groupId = useIdParam();
|
const groupId = useIdParam();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const groupQuery = useGroup(groupId);
|
const groupQuery = useGroup(groupId);
|
||||||
const isUnassignedGroup = groupId === 1;
|
const isUnassignedGroup = groupId === 1;
|
||||||
const updateMutation = useUpdateGroupMutation();
|
const updateMutation = useUpdateGroupMutation();
|
||||||
|
const deleteMutation = useDeleteEnvironmentGroupMutation();
|
||||||
|
|
||||||
const initialValues: GroupFormValues = useMemo(
|
const initialValues: GroupFormValues = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -37,7 +44,15 @@ export function EditGroupView() {
|
|||||||
{ label: 'Groups', link: 'portainer.groups' },
|
{ label: 'Groups', link: 'portainer.groups' },
|
||||||
{ label: groupQuery.data?.Name ?? 'Edit group' },
|
{ label: groupQuery.data?.Name ?? 'Edit group' },
|
||||||
]}
|
]}
|
||||||
/>
|
>
|
||||||
|
<DeleteButton
|
||||||
|
disabled={isUnassignedGroup || !groupQuery.data}
|
||||||
|
confirmMessage="Are you sure you want to delete this environment group? Environments within it will become unassigned."
|
||||||
|
onConfirmed={handleDelete}
|
||||||
|
isLoading={deleteMutation.isLoading}
|
||||||
|
data-cy="delete-environment-group-button"
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
@@ -73,6 +88,13 @@ export function EditGroupView() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await deleteMutation.mutateAsync(groupId);
|
||||||
|
notifySuccess('Success', 'Environment group successfully deleted');
|
||||||
|
await router.stateService.go('portainer.groups');
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.base(), exact: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(
|
async function handleSubmit(
|
||||||
values: GroupFormValues,
|
values: GroupFormValues,
|
||||||
{ resetForm }: FormikHelpers<GroupFormValues>
|
{ resetForm }: FormikHelpers<GroupFormValues>
|
||||||
|
|||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
import { Dice4 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Datatable } from '@@/datatables';
|
|
||||||
import { createPersistedStore } from '@@/datatables/types';
|
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
|
||||||
|
|
||||||
import { useEnvironmentGroups } from '../../queries/useEnvironmentGroups';
|
|
||||||
|
|
||||||
import { columns } from './columns';
|
|
||||||
import { TableActions } from './TableActions';
|
|
||||||
|
|
||||||
const tableKey = 'environment-groups';
|
|
||||||
const store = createPersistedStore(tableKey);
|
|
||||||
|
|
||||||
export function EnvironmentGroupsDatatable() {
|
|
||||||
const query = useEnvironmentGroups();
|
|
||||||
const tableState = useTableState(store, tableKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Datatable
|
|
||||||
columns={columns}
|
|
||||||
isLoading={query.isLoading}
|
|
||||||
dataset={query.data || []}
|
|
||||||
settingsManager={tableState}
|
|
||||||
title="Environment Groups"
|
|
||||||
titleIcon={Dice4}
|
|
||||||
renderTableActions={(selectedItems) => (
|
|
||||||
<TableActions selectedItems={selectedItems} />
|
|
||||||
)}
|
|
||||||
data-cy="environment-groups-datatable"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
|
||||||
|
|
||||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
|
||||||
import { AddButton } from '@@/buttons';
|
|
||||||
|
|
||||||
import { EnvironmentGroup } from '../../types';
|
|
||||||
|
|
||||||
import { useDeleteEnvironmentGroupsMutation } from './useDeleteEnvironmentGroupsMutation';
|
|
||||||
|
|
||||||
export function TableActions({
|
|
||||||
selectedItems,
|
|
||||||
}: {
|
|
||||||
selectedItems: EnvironmentGroup[];
|
|
||||||
}) {
|
|
||||||
const deleteMutation = useDeleteEnvironmentGroupsMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DeleteButton
|
|
||||||
disabled={selectedItems.length === 0}
|
|
||||||
confirmMessage="Are you sure you want to remove the selected environment group(s)?"
|
|
||||||
onConfirmed={handleRemove}
|
|
||||||
data-cy="remove-environment-groups-button"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddButton data-cy="add-environment-group-button">Add group</AddButton>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleRemove() {
|
|
||||||
const ids = selectedItems.map((item) => item.Id);
|
|
||||||
deleteMutation.mutate(ids, {
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess('Success', 'Environment Group(s) removed');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
import { CellContext, createColumnHelper } from '@tanstack/react-table';
|
|
||||||
import { Users } from 'lucide-react';
|
|
||||||
|
|
||||||
import { buildNameColumn } from '@@/datatables/buildNameColumn';
|
|
||||||
import { Button } from '@@/buttons';
|
|
||||||
import { Link } from '@@/Link';
|
|
||||||
|
|
||||||
import { EnvironmentGroup } from '../../types';
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<EnvironmentGroup>();
|
|
||||||
|
|
||||||
export const columns = [
|
|
||||||
buildNameColumn<EnvironmentGroup>('Name', '.group', 'environment-group-name'),
|
|
||||||
columnHelper.display({
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ActionsCell,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
function ActionsCell({
|
|
||||||
row: { original: item },
|
|
||||||
}: CellContext<EnvironmentGroup, unknown>) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
as={Link}
|
|
||||||
props={{
|
|
||||||
to: '.group.access',
|
|
||||||
params: { id: item.Id },
|
|
||||||
}}
|
|
||||||
color="link"
|
|
||||||
icon={Users}
|
|
||||||
data-cy={`manage-access-button_${item.Name}`}
|
|
||||||
>
|
|
||||||
Manage access
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
export { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { withError, withInvalidate } from '@/react-tools/react-query';
|
|
||||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
|
||||||
|
|
||||||
import { EnvironmentGroup } from '../../types';
|
|
||||||
import { buildUrl } from '../../queries/build-url';
|
|
||||||
import { queryKeys } from '../../queries/query-keys';
|
|
||||||
|
|
||||||
export function useDeleteEnvironmentGroupsMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: deleteEnvironmentGroups,
|
|
||||||
...withError('Failed to delete environment groups'),
|
|
||||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEnvironmentGroups(
|
|
||||||
environmentGroupIds: Array<EnvironmentGroup['Id']>
|
|
||||||
) {
|
|
||||||
return promiseSequence(
|
|
||||||
environmentGroupIds.map(
|
|
||||||
(environmentGroupId) => () => deleteEnvironmentGroup(environmentGroupId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteEnvironmentGroup(id: EnvironmentGroup['Id']) {
|
|
||||||
try {
|
|
||||||
await axios.delete(buildUrl(id));
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e, 'Unable to delete environment group');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
import { LayoutGrid, Columns3Cog, Users } from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { Tag } from '@/portainer/tags/types';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
import { Badge } from '@@/Badge';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
import { EnvironmentGroup } from '../../types';
|
||||||
|
import { EnvironmentTypeBreakdown } from '../../components/EnvironmentTypeBreakdown';
|
||||||
|
import { PlatformBadge } from '../../components/PlatformBadge';
|
||||||
|
import { isUngoverned } from '../../utils/getPlatformLabel';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: EnvironmentGroup;
|
||||||
|
tags?: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentGroupRow({ group, tags }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const ungoverned = isUngoverned(group);
|
||||||
|
|
||||||
|
function handleRowClick() {
|
||||||
|
if (!ungoverned) {
|
||||||
|
router.stateService.go('portainer.groups.group', { id: group.Id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (!ungoverned && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRowClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'group flex items-center gap-5 border-0 border-t border-solid border-gray-4 px-5 py-4 transition-colors',
|
||||||
|
ungoverned
|
||||||
|
? 'cursor-default bg-gray-2 th-highcontrast:bg-gray-10 th-dark:bg-gray-10'
|
||||||
|
: 'cursor-pointer hover:bg-gray-2 th-dark:hover:bg-gray-9',
|
||||||
|
'th-dark:border-gray-8'
|
||||||
|
)}
|
||||||
|
data-cy={`environment-group-row-${group.Name}`}
|
||||||
|
onClick={ungoverned ? undefined : handleRowClick}
|
||||||
|
onKeyDown={ungoverned ? undefined : handleKeyDown}
|
||||||
|
role={ungoverned ? undefined : 'button'}
|
||||||
|
tabIndex={ungoverned ? undefined : 0}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-2xl',
|
||||||
|
ungoverned
|
||||||
|
? 'bg-warning-7 th-dark:bg-warning-10'
|
||||||
|
: 'bg-blue-3 th-dark:bg-blue-9'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ungoverned ? (
|
||||||
|
<Columns3Cog
|
||||||
|
className={clsx(
|
||||||
|
'h-6 w-6',
|
||||||
|
'!text-warning-4 th-dark:text-warning-3'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LayoutGrid
|
||||||
|
className={clsx('h-6 w-6', 'text-blue-9 th-dark:text-blue-3')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Name + Tag badges */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'text-sm font-bold',
|
||||||
|
ungoverned
|
||||||
|
? 'text-warning-7 th-dark:text-warning-3'
|
||||||
|
: 'text-gray-8 th-highcontrast:text-white th-dark:text-gray-2'
|
||||||
|
)}
|
||||||
|
data-cy={`environment-group-name-${group.Name}`}
|
||||||
|
>
|
||||||
|
{group.Name}
|
||||||
|
</span>
|
||||||
|
{!ungoverned && <PlatformBadge group={group} />}
|
||||||
|
{!ungoverned &&
|
||||||
|
group.TagIds?.map((tagId) => {
|
||||||
|
const tag = tags?.find((t) => t.ID === tagId);
|
||||||
|
return (
|
||||||
|
<Badge key={tagId} type="info" className="text-xs">
|
||||||
|
{tag?.Name ?? `Tag ${tagId}`}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'mt-1 flex items-center gap-4 text-xs',
|
||||||
|
ungoverned
|
||||||
|
? 'text-warning-7 th-dark:text-warning-4'
|
||||||
|
: 'text-gray-7 th-highcontrast:text-white group-hover:th-highcontrast:text-gray-11 th-dark:text-gray-5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<EnvironmentTypeBreakdown group={group} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{!ungoverned && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
props={{
|
||||||
|
to: 'portainer.groups.group.access',
|
||||||
|
params: { id: group.Id },
|
||||||
|
}}
|
||||||
|
color="link"
|
||||||
|
icon={Users}
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
data-cy={`manage-access-button_${group.Name}`}
|
||||||
|
>
|
||||||
|
Manage access
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+677
@@ -0,0 +1,677 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
import { server } from '@/setup-tests/server';
|
||||||
|
|
||||||
|
import { EnvironmentGroup } from '../../types';
|
||||||
|
|
||||||
|
import { EnvironmentGroupsTable } from './EnvironmentGroupsTable';
|
||||||
|
|
||||||
|
vi.mock('@@/Link', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<{ to: string; params?: Record<string, unknown> }>) => (
|
||||||
|
<span role="button" tabIndex={0} {...props}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildGroup(
|
||||||
|
overrides: Partial<EnvironmentGroup> & { Id: number; Name: string }
|
||||||
|
): EnvironmentGroup {
|
||||||
|
return {
|
||||||
|
Description: '',
|
||||||
|
TagIds: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockGroups: Array<EnvironmentGroup> = [
|
||||||
|
buildGroup({
|
||||||
|
Id: 2,
|
||||||
|
Name: 'Production Kubernetes',
|
||||||
|
Description: 'Production k8s cluster',
|
||||||
|
TagIds: [1, 2],
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 3,
|
||||||
|
Name: 'Development Kubernetes',
|
||||||
|
TagIds: [1],
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 4,
|
||||||
|
Name: 'Docker Hosts',
|
||||||
|
TagIds: [3],
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 5,
|
||||||
|
Name: 'Staging Mixed',
|
||||||
|
TagIds: [2],
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 6,
|
||||||
|
Name: 'Edge Fleet',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
function searchInput() {
|
||||||
|
return screen.getByTestId('environment-groups-list-header-search');
|
||||||
|
}
|
||||||
|
function sortByName() {
|
||||||
|
return screen.getByTestId(
|
||||||
|
'environment-groups-list-header-sort-sort-by-name-button'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function nextPageBtn() {
|
||||||
|
return screen.getByTitle('Next page');
|
||||||
|
}
|
||||||
|
function prevPageBtn() {
|
||||||
|
return screen.getByTitle('Previous page');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLoaded() {
|
||||||
|
await waitFor(() => {
|
||||||
|
const rows = screen.queryAllByTestId(/^environment-group-row-/);
|
||||||
|
const empty = screen.queryByText(
|
||||||
|
/No environment groups found|No groups match/
|
||||||
|
);
|
||||||
|
if (rows.length === 0 && !empty) throw new Error('Still loading');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(mockGroups)),
|
||||||
|
http.get('/api/tags', () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{ ID: 1, Name: 'production' },
|
||||||
|
{ ID: 2, Name: 'staging' },
|
||||||
|
{ ID: 3, Name: 'docker' },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(<Wrapped />);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyTable() {
|
||||||
|
server.use(http.get('/api/endpoint_groups', () => HttpResponse.json([])));
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
|
||||||
|
return render(<Wrapped />);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EnvironmentGroupsTable', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should show loading state initially', () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', async () => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
return HttpResponse.json(mockGroups);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
// Header renders immediately; group rows are not yet visible
|
||||||
|
expect(
|
||||||
|
screen.getByTestId(
|
||||||
|
'environment-groups-list-header-sort-sort-by-name-button'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId(/^environment-group-row-/)
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render all groups after loading', async () => {
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
mockGroups.forEach((group) => {
|
||||||
|
expect(
|
||||||
|
screen.getByTestId(`environment-group-row-${group.Name}`)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show empty state when no groups exist', async () => {
|
||||||
|
renderEmptyTable();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('No environment groups found')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Filtering', () => {
|
||||||
|
it('should filter groups by name', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
await user.type(searchInput(), 'Production');
|
||||||
|
|
||||||
|
// SearchBar debounces input; wait for the filter to apply
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('environment-group-row-Docker Hosts')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('environment-group-row-Production Kubernetes')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter case-insensitively', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
await user.type(searchInput(), 'docker');
|
||||||
|
|
||||||
|
// SearchBar debounces input; wait for the filter to apply
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('environment-group-row-Docker Hosts')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show no results message when filter matches nothing', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
await user.type(searchInput(), 'nonexistent');
|
||||||
|
|
||||||
|
// SearchBar debounces input; wait for the empty state to render
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('No groups match your search')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sorting', () => {
|
||||||
|
it('should sort by name ascending by default', async () => {
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows[0]).toHaveAttribute(
|
||||||
|
'data-cy',
|
||||||
|
'environment-group-row-Development Kubernetes'
|
||||||
|
);
|
||||||
|
expect(rows[1]).toHaveAttribute(
|
||||||
|
'data-cy',
|
||||||
|
'environment-group-row-Docker Hosts'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle sort direction when clicking the active sort', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Click name again to reverse
|
||||||
|
await user.click(sortByName());
|
||||||
|
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows[0]).toHaveAttribute(
|
||||||
|
'data-cy',
|
||||||
|
'environment-group-row-Staging Mixed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pagination', () => {
|
||||||
|
it('should show pagination info when items exist', async () => {
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// SortableListPagerInfo always shows when totalCount > 0
|
||||||
|
expect(screen.getByRole('combobox')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show pagination when items exceed page size', async () => {
|
||||||
|
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
expect(nextPageBtn()).toBeVisible();
|
||||||
|
// Should show 10 items on first page
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate to next page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
await user.click(nextPageBtn());
|
||||||
|
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset to page 1 when filtering', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Go to page 2
|
||||||
|
await user.click(nextPageBtn());
|
||||||
|
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(5);
|
||||||
|
|
||||||
|
// Type a filter - safePage clamps to page 1
|
||||||
|
await user.type(searchInput(), 'Group 0');
|
||||||
|
|
||||||
|
// Should show filtered results from page 1
|
||||||
|
await waitFor(() => {
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
expect(rows.length).toBeLessThanOrEqual(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset to page 1 when changing sort', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Go to page 2
|
||||||
|
await user.click(nextPageBtn());
|
||||||
|
|
||||||
|
// Toggle Name sort direction
|
||||||
|
await user.click(sortByName());
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sort header', () => {
|
||||||
|
it('should render the name sort button', async () => {
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
expect(sortByName()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the filter input', async () => {
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
expect(searchInput()).toBeVisible();
|
||||||
|
expect(searchInput()).toHaveAttribute('placeholder', 'Filter groups...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the add group button in the page header (not in the table)', async () => {
|
||||||
|
// The Add Group button lives in ListView's PageHeader, not in EnvironmentGroupsTable.
|
||||||
|
// This test confirms it is absent from the table component itself.
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('add-environment-group-button')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pagination - Advanced', () => {
|
||||||
|
it('should navigate to previous page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Go to page 2
|
||||||
|
await user.click(nextPageBtn());
|
||||||
|
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(5);
|
||||||
|
|
||||||
|
// Go back to page 1
|
||||||
|
await user.click(prevPageBtn());
|
||||||
|
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable next button on last page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
const next = nextPageBtn();
|
||||||
|
expect(next).not.toBeDisabled();
|
||||||
|
|
||||||
|
// Navigate to the last page (page 2 of 2 with 15 items at 10/page)
|
||||||
|
await user.click(next);
|
||||||
|
|
||||||
|
expect(next).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change items per page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 25 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Initial page should have 10 items
|
||||||
|
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(10);
|
||||||
|
|
||||||
|
// Change to 25 items per page
|
||||||
|
const select = screen.getByRole('combobox');
|
||||||
|
await user.selectOptions(select, '25');
|
||||||
|
|
||||||
|
// Should now show 25 items
|
||||||
|
expect(screen.getAllByTestId(/^environment-group-row-/)).toHaveLength(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate to specific page number', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 30 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Click page 3
|
||||||
|
await user.click(screen.getByRole('button', { name: '3' }));
|
||||||
|
|
||||||
|
// Should show items from page 3
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset to page 1 after filtering results', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 25 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: i % 2 === 0 ? `ProductionGroup ${i}` : `Group ${i}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Go to page 2
|
||||||
|
await user.click(nextPageBtn());
|
||||||
|
|
||||||
|
// Filter - safePage clamps back to page 1
|
||||||
|
await user.type(searchInput(), 'Production');
|
||||||
|
|
||||||
|
// Should show filtered results from page 1
|
||||||
|
await waitFor(() => {
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Group Row Features', () => {
|
||||||
|
it('should display ungoverned group', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
buildGroup({
|
||||||
|
Id: 1,
|
||||||
|
Name: 'Unassigned',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('environment-group-row-Unassigned')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Combinations - Sorting + Pagination + Filtering', () => {
|
||||||
|
it('should maintain sort order after pagination', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 25 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: `Group ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
const firstPageOrder = screen
|
||||||
|
.getAllByTestId(/^environment-group-row-/)[0]
|
||||||
|
.getAttribute('data-cy');
|
||||||
|
|
||||||
|
// Go to next page
|
||||||
|
await user.click(nextPageBtn());
|
||||||
|
|
||||||
|
// Go back to first page
|
||||||
|
await user.click(prevPageBtn());
|
||||||
|
|
||||||
|
// Order should be the same
|
||||||
|
const returnedFirstRow = screen
|
||||||
|
.getAllByTestId(/^environment-group-row-/)[0]
|
||||||
|
.getAttribute('data-cy');
|
||||||
|
expect(firstPageOrder).toBe(returnedFirstRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply filter and paginate together', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const manyGroups = Array.from({ length: 20 }, (_, i) =>
|
||||||
|
buildGroup({
|
||||||
|
Id: i + 1,
|
||||||
|
Name: i % 2 === 0 ? `ProdGroup ${i}` : `DevGroup ${i}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(manyGroups))
|
||||||
|
);
|
||||||
|
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable))
|
||||||
|
);
|
||||||
|
render(<Wrapped />);
|
||||||
|
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Filter by "Prod"
|
||||||
|
await user.type(searchInput(), 'Prod');
|
||||||
|
|
||||||
|
// SearchBar debounces input; wait for filtered rows to settle
|
||||||
|
await waitFor(() => {
|
||||||
|
const rows = screen.getAllByTestId(/^environment-group-row-/);
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
rows.forEach((row) => {
|
||||||
|
expect(row).toHaveAttribute(
|
||||||
|
'data-cy',
|
||||||
|
expect.stringContaining('ProdGroup')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useTags } from '@/portainer/tags/queries';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SortableGroup,
|
||||||
|
SortableList,
|
||||||
|
SortOption,
|
||||||
|
} from '@@/SortableList/SortableList';
|
||||||
|
import { useSortableListState } from '@@/SortableList/sortable-list.store';
|
||||||
|
|
||||||
|
import { useEnvironmentGroups } from '../../queries/useEnvironmentGroups';
|
||||||
|
import { EnvironmentGroup } from '../../types';
|
||||||
|
import { isUngoverned } from '../../utils/getPlatformLabel';
|
||||||
|
|
||||||
|
import { EnvironmentGroupRow } from './EnvironmentGroupRow';
|
||||||
|
|
||||||
|
const SORT_OPTIONS: SortOption[] = [{ key: 'Name', label: 'Name' }];
|
||||||
|
|
||||||
|
export function EnvironmentGroupsTable() {
|
||||||
|
const groupsQuery = useEnvironmentGroups();
|
||||||
|
const tagsQuery = useTags();
|
||||||
|
const tableState = useSortableListState('environment_groups', 'Name');
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const data = groupsQuery.data ?? [];
|
||||||
|
const governed = data.filter((g) => !isUngoverned(g));
|
||||||
|
const ungoverned = data.filter((g) => isUngoverned(g));
|
||||||
|
const sortedGoverned = [...governed].sort((a, b) => {
|
||||||
|
const cmp = a.Name.localeCompare(b.Name);
|
||||||
|
return tableState.sortBy?.desc ? -cmp : cmp;
|
||||||
|
});
|
||||||
|
return [...sortedGoverned, ...ungoverned];
|
||||||
|
}, [groupsQuery.data, tableState.sortBy?.desc]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = tableState.search.toLowerCase();
|
||||||
|
if (!term) return sorted;
|
||||||
|
return sorted.filter(
|
||||||
|
(g) =>
|
||||||
|
g.Name.toLowerCase().includes(term) ||
|
||||||
|
g.Description?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [sorted, tableState.search]);
|
||||||
|
|
||||||
|
const pageItems = useMemo(() => {
|
||||||
|
const totalPages = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(filtered.length / tableState.pageSize)
|
||||||
|
);
|
||||||
|
const safePage = Math.min(tableState.page, totalPages - 1);
|
||||||
|
const start = safePage * tableState.pageSize;
|
||||||
|
return filtered.slice(start, start + tableState.pageSize);
|
||||||
|
}, [filtered, tableState.page, tableState.pageSize]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => buildGroups(pageItems), [pageItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableList
|
||||||
|
tableState={tableState}
|
||||||
|
sortOptions={SORT_OPTIONS}
|
||||||
|
groups={groups}
|
||||||
|
totalCount={filtered.length}
|
||||||
|
renderItem={(group) => (
|
||||||
|
<EnvironmentGroupRow group={group} tags={tagsQuery.data} />
|
||||||
|
)}
|
||||||
|
getItemKey={(group) => group.Id}
|
||||||
|
isLoading={groupsQuery.isLoading}
|
||||||
|
searchPlaceholder="Filter groups..."
|
||||||
|
emptyMessage={
|
||||||
|
tableState.search
|
||||||
|
? 'No groups match your search'
|
||||||
|
: 'No environment groups found'
|
||||||
|
}
|
||||||
|
data-cy="environment-groups-list"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroups(
|
||||||
|
items: EnvironmentGroup[]
|
||||||
|
): SortableGroup<EnvironmentGroup>[] {
|
||||||
|
const governed = items.filter((g) => !isUngoverned(g));
|
||||||
|
const ungoverned = items.filter((g) => isUngoverned(g));
|
||||||
|
const result: SortableGroup<EnvironmentGroup>[] = [];
|
||||||
|
if (governed.length) {
|
||||||
|
result.push({ key: 'governed', label: 'Groups', items: governed });
|
||||||
|
}
|
||||||
|
if (ungoverned.length) {
|
||||||
|
result.push({ key: 'ungoverned', label: 'Ungoverned', items: ungoverned });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||||
|
import { ReactStateDeclaration } from '@uirouter/react';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
|
||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
|
import { EnvironmentGroupsTable } from './EnvironmentGroupsTable/EnvironmentGroupsTable';
|
||||||
|
|
||||||
|
function buildGroup(
|
||||||
|
overrides: Partial<EnvironmentGroup> & { Id: number; Name: string }
|
||||||
|
): EnvironmentGroup {
|
||||||
|
return {
|
||||||
|
Description: '',
|
||||||
|
TagIds: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleGroups: Array<EnvironmentGroup> = [
|
||||||
|
buildGroup({
|
||||||
|
Id: 2,
|
||||||
|
Name: 'Production - AWS',
|
||||||
|
Description: 'Production workloads on AWS EKS',
|
||||||
|
Total: 12,
|
||||||
|
TypeInfo: { Docker: 0, Kubernetes: 12, Podman: 0, Mixed: false },
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 3,
|
||||||
|
Name: 'Staging - Azure',
|
||||||
|
Description: 'Pre-production staging on Azure AKS',
|
||||||
|
Total: 5,
|
||||||
|
TypeInfo: { Docker: 0, Kubernetes: 5, Podman: 0, Mixed: false },
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 4,
|
||||||
|
Name: 'CI/CD Runners',
|
||||||
|
Description: 'Containerised build and test agents',
|
||||||
|
Total: 8,
|
||||||
|
TypeInfo: { Docker: 8, Kubernetes: 0, Podman: 0, Mixed: false },
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 5,
|
||||||
|
Name: 'Dev Workstations',
|
||||||
|
Description: 'Developer local Docker environments',
|
||||||
|
Total: 6,
|
||||||
|
TypeInfo: { Docker: 6, Kubernetes: 0, Podman: 0, Mixed: false },
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 6,
|
||||||
|
Name: 'Edge Devices',
|
||||||
|
Description: 'Edge computing nodes across regional sites',
|
||||||
|
Total: 14,
|
||||||
|
TypeInfo: { Docker: 0, Kubernetes: 0, Podman: 14, Mixed: false },
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 7,
|
||||||
|
Name: 'IoT Gateway Fleet',
|
||||||
|
Description: 'Podman-based IoT gateway nodes',
|
||||||
|
Total: 9,
|
||||||
|
TypeInfo: { Docker: 0, Kubernetes: 0, Podman: 9, Mixed: false },
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 8,
|
||||||
|
Name: 'Platform Services',
|
||||||
|
Description: 'Mixed workloads — Kubernetes, Docker, and Podman',
|
||||||
|
Total: 11,
|
||||||
|
TypeInfo: { Docker: 4, Kubernetes: 5, Podman: 2, Mixed: true },
|
||||||
|
}),
|
||||||
|
buildGroup({
|
||||||
|
Id: 1,
|
||||||
|
Name: 'Unassigned',
|
||||||
|
Description: '',
|
||||||
|
Total: 3,
|
||||||
|
TypeInfo: { Docker: 1, Kubernetes: 1, Podman: 1, Mixed: true },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateConfig: Array<ReactStateDeclaration> = [
|
||||||
|
{ name: 'portainer.groups', component: () => null },
|
||||||
|
{ name: 'portainer.groups.group', component: () => null },
|
||||||
|
{ name: 'portainer.groups.new', component: () => null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WrappedPage = withTestQueryProvider(
|
||||||
|
withTestRouter(withUserProvider(EnvironmentGroupsTable), {
|
||||||
|
route: 'portainer.groups',
|
||||||
|
stateConfig,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta: Meta<typeof WrappedPage> = {
|
||||||
|
component: WrappedPage,
|
||||||
|
title: 'Pages/Environment Groups',
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="p-4">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof WrappedPage>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get('/api/endpoint_groups', () => HttpResponse.json(sampleGroups)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [http.get('/api/endpoint_groups', () => HttpResponse.json([]))],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManyGroups: Story = {
|
||||||
|
parameters: {
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get('/api/endpoint_groups', () => {
|
||||||
|
const platforms: Array<Pick<EnvironmentGroup, 'TypeInfo' | 'Total'>> =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
TypeInfo: { Docker: 8, Kubernetes: 0, Podman: 0, Mixed: false },
|
||||||
|
Total: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TypeInfo: { Docker: 0, Kubernetes: 6, Podman: 0, Mixed: false },
|
||||||
|
Total: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TypeInfo: { Docker: 3, Kubernetes: 4, Podman: 1, Mixed: true },
|
||||||
|
Total: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TypeInfo: { Docker: 12, Kubernetes: 0, Podman: 2, Mixed: true },
|
||||||
|
Total: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TypeInfo: { Docker: 0, Kubernetes: 0, Podman: 5, Mixed: false },
|
||||||
|
Total: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const names = [
|
||||||
|
'Production',
|
||||||
|
'Staging',
|
||||||
|
'Development',
|
||||||
|
'Testing',
|
||||||
|
'Lab',
|
||||||
|
'Edge',
|
||||||
|
'Data',
|
||||||
|
'Monitoring',
|
||||||
|
'Security',
|
||||||
|
'Infra',
|
||||||
|
];
|
||||||
|
const regions = ['AWS', 'Azure', 'GCP', 'On-Premise', 'Hybrid'];
|
||||||
|
|
||||||
|
const groups = Array.from({ length: 30 }, (_, i) => {
|
||||||
|
const p = platforms[i % platforms.length];
|
||||||
|
return buildGroup({
|
||||||
|
Id: i + 2,
|
||||||
|
Name: `${names[i % names.length]} - ${
|
||||||
|
regions[i % regions.length]
|
||||||
|
}`,
|
||||||
|
Description: `Auto-generated group ${i + 1}`,
|
||||||
|
Total: p.Total,
|
||||||
|
TypeInfo: p.TypeInfo,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return HttpResponse.json([
|
||||||
|
...groups,
|
||||||
|
buildGroup({
|
||||||
|
Id: 1,
|
||||||
|
Name: 'Unassigned',
|
||||||
|
Total: 3,
|
||||||
|
TypeInfo: { Docker: 2, Kubernetes: 1, Podman: 0, Mixed: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,17 +1,35 @@
|
|||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
import { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
|
import { EnvironmentGroupsTable } from './EnvironmentGroupsTable/EnvironmentGroupsTable';
|
||||||
|
|
||||||
export function ListView() {
|
export function ListView() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Environment Groups"
|
title="Environment Groups"
|
||||||
breadcrumbs="Environment group management"
|
breadcrumbs="Environment group management"
|
||||||
reload
|
reload
|
||||||
/>
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.stateService.go('portainer.groups.new')}
|
||||||
|
icon={Plus}
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
data-cy="add-environment-group-button"
|
||||||
|
>
|
||||||
|
Add Group
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<EnvironmentGroupsDatatable />
|
<div className="mx-5">
|
||||||
|
<EnvironmentGroupsTable />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
|
import { EnvironmentTypeBreakdown } from './EnvironmentTypeBreakdown';
|
||||||
|
|
||||||
|
function buildGroup(
|
||||||
|
overrides: Partial<EnvironmentGroup> = {}
|
||||||
|
): EnvironmentGroup {
|
||||||
|
return {
|
||||||
|
Id: 2,
|
||||||
|
Name: 'test-group',
|
||||||
|
Description: '',
|
||||||
|
TagIds: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EnvironmentTypeBreakdown', () => {
|
||||||
|
it('renders nothing when Total is 0', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EnvironmentTypeBreakdown group={buildGroup({ Total: 0 })} />
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when Total is undefined', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EnvironmentTypeBreakdown group={buildGroup()} />
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders singular environment count when TypeInfo is absent', () => {
|
||||||
|
render(<EnvironmentTypeBreakdown group={buildGroup({ Total: 1 })} />);
|
||||||
|
expect(screen.getByText('1 Environment')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders plural environment count when TypeInfo is absent', () => {
|
||||||
|
render(<EnvironmentTypeBreakdown group={buildGroup({ Total: 5 })} />);
|
||||||
|
expect(screen.getByText('5 Environments')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies data-cy attribute using group name', () => {
|
||||||
|
render(<EnvironmentTypeBreakdown group={buildGroup({ Total: 3 })} />);
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('environment-group-size_test-group')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Kubernetes logo and count', () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentTypeBreakdown
|
||||||
|
group={buildGroup({
|
||||||
|
Total: 2,
|
||||||
|
TypeInfo: { Kubernetes: 2, Docker: 0, Podman: 0, Mixed: false },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByAltText('Kubernetes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText('Docker')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText('Podman')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Docker logo and count', () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentTypeBreakdown
|
||||||
|
group={buildGroup({
|
||||||
|
Total: 3,
|
||||||
|
TypeInfo: { Kubernetes: 0, Docker: 3, Podman: 0, Mixed: false },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByAltText('Docker')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText('Kubernetes')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText('Podman')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Podman logo and count', () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentTypeBreakdown
|
||||||
|
group={buildGroup({
|
||||||
|
Total: 1,
|
||||||
|
TypeInfo: { Kubernetes: 0, Docker: 0, Podman: 1, Mixed: true },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByAltText('Podman')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText('Kubernetes')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByAltText('Docker')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all three logos when all platform types are present', () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentTypeBreakdown
|
||||||
|
group={buildGroup({
|
||||||
|
Total: 6,
|
||||||
|
TypeInfo: { Kubernetes: 2, Docker: 3, Podman: 1, Mixed: true },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByAltText('Kubernetes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText('Docker')).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText('Podman')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import KubernetesLogo from '@/assets/ico/vendor/kubernetes.svg';
|
||||||
|
import DockerLogo from '@/assets/ico/vendor/docker.svg';
|
||||||
|
import PodmanLogo from '@/assets/ico/vendor/podman.svg';
|
||||||
|
|
||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: EnvironmentGroup;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvironmentTypeBreakdown({ group, className }: Props) {
|
||||||
|
if (!group.Total || group.Total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group.TypeInfo) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
data-cy={`environment-group-size_${group.Name}`}
|
||||||
|
>
|
||||||
|
{group.Total} {group.Total === 1 ? 'Environment' : 'Environments'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx('flex items-center gap-3', className)}
|
||||||
|
data-cy={`environment-group-size_${group.Name}`}
|
||||||
|
>
|
||||||
|
{group.TypeInfo.Kubernetes > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<img src={KubernetesLogo} alt="Kubernetes" className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<b>{group.TypeInfo.Kubernetes}</b> Kubernetes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.TypeInfo.Docker > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<img src={DockerLogo} alt="Docker" className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<b>{group.TypeInfo.Docker}</b> Docker
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.TypeInfo.Podman > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<img src={PodmanLogo} alt="Podman" className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<b>{group.TypeInfo.Podman}</b> Podman
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
import { getPlatformLabel } from '../utils/getPlatformLabel';
|
||||||
|
|
||||||
|
const colorByLabel: Record<string, string> = {
|
||||||
|
Docker: 'bg-green-2',
|
||||||
|
Kubernetes: 'bg-blue-2',
|
||||||
|
Podman: 'bg-orange-2',
|
||||||
|
Mixed: 'bg-purple-2',
|
||||||
|
Empty: 'bg-gray-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: EnvironmentGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlatformBadge({ group }: Props) {
|
||||||
|
const platformLabel = getPlatformLabel(group);
|
||||||
|
const colorClass = colorByLabel[platformLabel] ?? 'bg-gray-2';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`flex w-fit items-center rounded-xl ${colorClass} px-2 py-px text-xs font-bold text-gray-9 th-highcontrast:bg-gray-8 th-highcontrast:text-white th-dark:bg-gray-8 th-dark:text-gray-3`}
|
||||||
|
data-cy={`environment-group-platform_${group.Name}`}
|
||||||
|
>
|
||||||
|
{platformLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { EnvironmentGroupId } from '../../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
|
||||||
|
export function useDeleteEnvironmentGroupMutation() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: EnvironmentGroupId) => deleteEnvironmentGroup(id),
|
||||||
|
...withError('Failed to delete environment group'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEnvironmentGroup(id: EnvironmentGroupId) {
|
||||||
|
try {
|
||||||
|
await axios.delete(buildUrl(id));
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to delete environment group');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
import { EnvironmentGroup } from '../types';
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export function useEnvironmentGroups() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.base(),
|
queryKey: queryKeys.base(),
|
||||||
queryFn: () => getEnvironmentGroups(),
|
queryFn: () => getEnvironmentGroups(),
|
||||||
|
...withError('Unable to retrieve environment groups'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +21,6 @@ async function getEnvironmentGroups() {
|
|||||||
const { data } = await axios.get<Array<EnvironmentGroup>>(buildUrl());
|
const { data } = await axios.get<Array<EnvironmentGroup>>(buildUrl());
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e, 'Unable to get access tokens');
|
throw parseAxiosError(e, 'Unable to retrieve environment groups');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import {
|
|||||||
EnvironmentGroupId,
|
EnvironmentGroupId,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
export interface EnvironmentGroupTypeInfo {
|
||||||
|
Docker: number;
|
||||||
|
Kubernetes: number;
|
||||||
|
Podman: number;
|
||||||
|
Mixed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnvironmentGroup {
|
export interface EnvironmentGroup {
|
||||||
// Environment(Endpoint) group Identifier
|
// Environment(Endpoint) group Identifier
|
||||||
Id: EnvironmentGroupId;
|
Id: EnvironmentGroupId;
|
||||||
@@ -17,4 +24,6 @@ export interface EnvironmentGroup {
|
|||||||
TagIds: TagId[];
|
TagIds: TagId[];
|
||||||
UserAccessPolicies?: UserAccessPolicies;
|
UserAccessPolicies?: UserAccessPolicies;
|
||||||
TeamAccessPolicies?: TeamAccessPolicies;
|
TeamAccessPolicies?: TeamAccessPolicies;
|
||||||
|
Total?: number;
|
||||||
|
TypeInfo?: EnvironmentGroupTypeInfo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { EnvironmentGroup } from '../types';
|
||||||
|
|
||||||
|
export function getPlatformLabel(group: EnvironmentGroup): string {
|
||||||
|
if (!group.Total || group.Total === 0) {
|
||||||
|
return 'Empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeInfo = group.TypeInfo;
|
||||||
|
if (!typeInfo) {
|
||||||
|
return 'Mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!typeInfo.Mixed) {
|
||||||
|
if (typeInfo.Docker > 0) return 'Docker';
|
||||||
|
if (typeInfo.Kubernetes > 0) return 'Kubernetes';
|
||||||
|
if (typeInfo.Podman > 0) return 'Podman';
|
||||||
|
return 'Empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Mixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUngoverned(group: EnvironmentGroup): boolean {
|
||||||
|
return group.Id === 1;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ module.exports = {
|
|||||||
current: 'currentColor',
|
current: 'currentColor',
|
||||||
inherit: 'inherit',
|
inherit: 'inherit',
|
||||||
...colors,
|
...colors,
|
||||||
|
'group-accent': colors.violet,
|
||||||
|
|
||||||
'legacy-grey-3': 'var(--grey-3)',
|
'legacy-grey-3': 'var(--grey-3)',
|
||||||
'legacy-blue-2': 'var(--blue-2)',
|
'legacy-blue-2': 'var(--blue-2)',
|
||||||
|
|||||||
Reference in New Issue
Block a user