diff --git a/api/git/git.go b/api/git/git.go
index 7fe245c23c..32a1d05860 100644
--- a/api/git/git.go
+++ b/api/git/git.go
@@ -2,23 +2,18 @@ package git
import (
"context"
- "os"
"strings"
- "github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"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/object"
- gogitfs "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/pkg/errors"
- "github.com/rs/zerolog/log"
)
// 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 {
- wt := NewNoSymlinkFS(osfs.New(dst))
- dot := osfs.New(filesystem.JoinPaths(dst, ".git"))
- storer := gogitfs.NewStorage(dot, cache.NewObjectLRU(0))
+ if c.preserveGitDirectory {
+ _, err := git.PlainCloneContext(ctx, dst, false, opt)
+ 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.Error() == "authentication required" {
return gittypes.ErrAuthenticationFailure
}
-
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
}
diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go
index 1b2ed5170d..e733b4b2d8 100644
--- a/api/http/handler/endpointgroups/endpointgroup_list.go
+++ b/api/http/handler/endpointgroups/endpointgroup_list.go
@@ -3,11 +3,29 @@ package endpointgroups
import (
"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/set"
+ endpointutils "github.com/portainer/portainer/pkg/endpoints"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
"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
// @summary List Environment(Endpoint) groups
// @description List all environment(endpoint) groups based on the current user authorizations. Will
@@ -18,13 +36,41 @@ import (
// @security ApiKeyAuth
// @security jwt
// @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"
// @router /endpoint_groups [get]
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 {
- 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)
@@ -33,5 +79,69 @@ func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request
}
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)
}
diff --git a/api/http/handler/endpointgroups/endpointgroup_list_test.go b/api/http/handler/endpointgroups/endpointgroup_list_test.go
new file mode 100644
index 0000000000..98599c9b78
--- /dev/null
+++ b/api/http/handler/endpointgroups/endpointgroup_list_test.go
@@ -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}
+}
diff --git a/api/http/handler/endpointgroups/handler_test.go b/api/http/handler/endpointgroups/handler_test.go
new file mode 100644
index 0000000000..e24873b59d
--- /dev/null
+++ b/api/http/handler/endpointgroups/handler_test.go
@@ -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
+}
diff --git a/app/react/components/PageHeader/PageTitle.tsx b/app/react/components/PageHeader/PageTitle.tsx
index 483a397b8d..01cc8c7430 100644
--- a/app/react/components/PageHeader/PageTitle.tsx
+++ b/app/react/components/PageHeader/PageTitle.tsx
@@ -5,7 +5,7 @@ export function PageTitle({
children,
}: PropsWithChildren<{ title: string }>) {
return (
-
+
- extends AriaAttributes, AutomationTestingProps {
+ extends AriaAttributes,
+ AutomationTestingProps {
icon?: ReactNode | ComponentType;
color?: Color;
diff --git a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx
index 2927f93e0d..577e5682b9 100644
--- a/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx
+++ b/app/react/portainer/environments/environment-groups/ItemView/EditGroupView.tsx
@@ -1,24 +1,31 @@
import { useRouter } from '@uirouter/react';
import { useMemo } from 'react';
import { FormikHelpers } from 'formik';
+import { useQueryClient } from '@tanstack/react-query';
import { useIdParam } from '@/react/hooks/useIdParam';
+import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { PageHeader } from '@@/PageHeader';
import { Alert } from '@@/Alert';
+import { DeleteButton } from '@@/buttons/DeleteButton';
import { useGroup } from '../queries/useGroup';
import { useUpdateGroupMutation } from '../queries/useUpdateGroupMutation';
+import { useDeleteEnvironmentGroupMutation } from '../queries/useDeleteEnvironmentGroupMutation';
+import { queryKeys } from '../queries/query-keys';
import { GroupForm, GroupFormValues } from '../components/GroupForm';
import { AssociatedEnvironmentsSelector } from '../components/AssociatedEnvironmentsSelector/AssociatedEnvironmentsSelector';
export function EditGroupView() {
const groupId = useIdParam();
const router = useRouter();
+ const queryClient = useQueryClient();
const groupQuery = useGroup(groupId);
const isUnassignedGroup = groupId === 1;
const updateMutation = useUpdateGroupMutation();
+ const deleteMutation = useDeleteEnvironmentGroupMutation();
const initialValues: GroupFormValues = useMemo(
() => ({
@@ -37,7 +44,15 @@ export function EditGroupView() {
{ label: 'Groups', link: 'portainer.groups' },
{ label: groupQuery.data?.Name ?? 'Edit group' },
]}
- />
+ >
+
+
@@ -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(
values: GroupFormValues,
{ resetForm }: FormikHelpers
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/EnvironmentGroupsDatatable.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/EnvironmentGroupsDatatable.tsx
deleted file mode 100644
index 37c3771592..0000000000
--- a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/EnvironmentGroupsDatatable.tsx
+++ /dev/null
@@ -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 (
- (
-
- )}
- data-cy="environment-groups-datatable"
- />
- );
-}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/TableActions.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/TableActions.tsx
deleted file mode 100644
index 9630dd78d1..0000000000
--- a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/TableActions.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- Add group
- >
- );
-
- function handleRemove() {
- const ids = selectedItems.map((item) => item.Id);
- deleteMutation.mutate(ids, {
- onSuccess() {
- notifySuccess('Success', 'Environment Group(s) removed');
- },
- });
- }
-}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/columns.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/columns.tsx
deleted file mode 100644
index 98118911b5..0000000000
--- a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/columns.tsx
+++ /dev/null
@@ -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();
-
-export const columns = [
- buildNameColumn('Name', '.group', 'environment-group-name'),
- columnHelper.display({
- header: 'Actions',
- cell: ActionsCell,
- }),
-];
-
-function ActionsCell({
- row: { original: item },
-}: CellContext) {
- return (
-
- );
-}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/index.ts b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/index.ts
deleted file mode 100644
index 9d5fbf0df1..0000000000
--- a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/useDeleteEnvironmentGroupsMutation.ts b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/useDeleteEnvironmentGroupsMutation.ts
deleted file mode 100644
index e4bb035f3e..0000000000
--- a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsDatatable/useDeleteEnvironmentGroupsMutation.ts
+++ /dev/null
@@ -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
-) {
- 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');
- }
-}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupRow.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupRow.tsx
new file mode 100644
index 0000000000..e7821f6a2a
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupRow.tsx
@@ -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 (
+
+ {/* Icon */}
+
+ {ungoverned ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Content */}
+
+ {/* Name + Tag badges */}
+
+
+ {group.Name}
+
+ {!ungoverned &&
}
+ {!ungoverned &&
+ group.TagIds?.map((tagId) => {
+ const tag = tags?.find((t) => t.ID === tagId);
+ return (
+
+ {tag?.Name ?? `Tag ${tagId}`}
+
+ );
+ })}
+
+
+ {/* Meta info */}
+
+
+
+
+
+ {/* Action buttons */}
+ {!ungoverned && (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.test.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.test.tsx
new file mode 100644
index 0000000000..4a761393fc
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.test.tsx
@@ -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 }>) => (
+
+ {children}
+
+ ),
+}));
+
+function buildGroup(
+ overrides: Partial & { Id: number; Name: string }
+): EnvironmentGroup {
+ return {
+ Description: '',
+ TagIds: [],
+ ...overrides,
+ };
+}
+
+const mockGroups: Array = [
+ 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();
+}
+
+function renderEmptyTable() {
+ server.use(http.get('/api/endpoint_groups', () => HttpResponse.json([])));
+
+ const Wrapped = withTestQueryProvider(
+ withTestRouter(withUserProvider(EnvironmentGroupsTable))
+ );
+
+ return render();
+}
+
+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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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')
+ );
+ });
+ });
+ });
+ });
+});
diff --git a/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.tsx b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.tsx
new file mode 100644
index 0000000000..18d7c5a147
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/EnvironmentGroupsTable/EnvironmentGroupsTable.tsx
@@ -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 (
+ (
+
+ )}
+ 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[] {
+ const governed = items.filter((g) => !isUngoverned(g));
+ const ungoverned = items.filter((g) => isUngoverned(g));
+ const result: SortableGroup[] = [];
+ if (governed.length) {
+ result.push({ key: 'governed', label: 'Groups', items: governed });
+ }
+ if (ungoverned.length) {
+ result.push({ key: 'ungoverned', label: 'Ungoverned', items: ungoverned });
+ }
+ return result;
+}
diff --git a/app/react/portainer/environments/environment-groups/ListView/ListView.stories.tsx b/app/react/portainer/environments/environment-groups/ListView/ListView.stories.tsx
new file mode 100644
index 0000000000..2a9fd08000
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/ListView/ListView.stories.tsx
@@ -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 & { Id: number; Name: string }
+): EnvironmentGroup {
+ return {
+ Description: '',
+ TagIds: [],
+ ...overrides,
+ };
+}
+
+const sampleGroups: Array = [
+ 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 = [
+ { 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 = {
+ component: WrappedPage,
+ title: 'Pages/Environment Groups',
+ parameters: {
+ layout: 'fullscreen',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+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> =
+ [
+ {
+ 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 },
+ }),
+ ]);
+ }),
+ ],
+ },
+ },
+};
diff --git a/app/react/portainer/environments/environment-groups/ListView/ListView.tsx b/app/react/portainer/environments/environment-groups/ListView/ListView.tsx
index 9413cd238e..e37f340dba 100644
--- a/app/react/portainer/environments/environment-groups/ListView/ListView.tsx
+++ b/app/react/portainer/environments/environment-groups/ListView/ListView.tsx
@@ -1,17 +1,35 @@
+import { useRouter } from '@uirouter/react';
+import { Plus } from 'lucide-react';
+
+import { Button } from '@@/buttons';
import { PageHeader } from '@@/PageHeader';
-import { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
+import { EnvironmentGroupsTable } from './EnvironmentGroupsTable/EnvironmentGroupsTable';
export function ListView() {
+ const router = useRouter();
+
return (
<>
+ >
+
+
-
+
+
+
>
);
}
diff --git a/app/react/portainer/environments/environment-groups/components/EnvironmentTypeBreakdown.test.tsx b/app/react/portainer/environments/environment-groups/components/EnvironmentTypeBreakdown.test.tsx
new file mode 100644
index 0000000000..4e7222108b
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/components/EnvironmentTypeBreakdown.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen } from '@testing-library/react';
+
+import { EnvironmentGroup } from '../types';
+
+import { EnvironmentTypeBreakdown } from './EnvironmentTypeBreakdown';
+
+function buildGroup(
+ overrides: Partial = {}
+): EnvironmentGroup {
+ return {
+ Id: 2,
+ Name: 'test-group',
+ Description: '',
+ TagIds: [],
+ ...overrides,
+ };
+}
+
+describe('EnvironmentTypeBreakdown', () => {
+ it('renders nothing when Total is 0', () => {
+ const { container } = render(
+
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing when Total is undefined', () => {
+ const { container } = render(
+
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders singular environment count when TypeInfo is absent', () => {
+ render();
+ expect(screen.getByText('1 Environment')).toBeInTheDocument();
+ });
+
+ it('renders plural environment count when TypeInfo is absent', () => {
+ render();
+ expect(screen.getByText('5 Environments')).toBeInTheDocument();
+ });
+
+ it('applies data-cy attribute using group name', () => {
+ render();
+ expect(
+ screen.getByTestId('environment-group-size_test-group')
+ ).toBeInTheDocument();
+ });
+
+ it('renders Kubernetes logo and count', () => {
+ render(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ expect(screen.getByAltText('Kubernetes')).toBeInTheDocument();
+ expect(screen.getByAltText('Docker')).toBeInTheDocument();
+ expect(screen.getByAltText('Podman')).toBeInTheDocument();
+ });
+});
diff --git a/app/react/portainer/environments/environment-groups/components/EnvironmentTypeBreakdown.tsx b/app/react/portainer/environments/environment-groups/components/EnvironmentTypeBreakdown.tsx
new file mode 100644
index 0000000000..40818328fe
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/components/EnvironmentTypeBreakdown.tsx
@@ -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 (
+
+ {group.Total} {group.Total === 1 ? 'Environment' : 'Environments'}
+
+ );
+ }
+
+ return (
+
+ {group.TypeInfo.Kubernetes > 0 && (
+
+

+
+ {group.TypeInfo.Kubernetes} Kubernetes
+
+
+ )}
+ {group.TypeInfo.Docker > 0 && (
+
+

+
+ {group.TypeInfo.Docker} Docker
+
+
+ )}
+ {group.TypeInfo.Podman > 0 && (
+
+

+
+ {group.TypeInfo.Podman} Podman
+
+
+ )}
+
+ );
+}
diff --git a/app/react/portainer/environments/environment-groups/components/PlatformBadge.tsx b/app/react/portainer/environments/environment-groups/components/PlatformBadge.tsx
new file mode 100644
index 0000000000..dee14ef3ae
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/components/PlatformBadge.tsx
@@ -0,0 +1,28 @@
+import { EnvironmentGroup } from '../types';
+import { getPlatformLabel } from '../utils/getPlatformLabel';
+
+const colorByLabel: Record = {
+ 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 (
+
+ {platformLabel}
+
+ );
+}
diff --git a/app/react/portainer/environments/environment-groups/queries/useDeleteEnvironmentGroupMutation.ts b/app/react/portainer/environments/environment-groups/queries/useDeleteEnvironmentGroupMutation.ts
new file mode 100644
index 0000000000..d2166d764a
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/queries/useDeleteEnvironmentGroupMutation.ts
@@ -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');
+ }
+}
diff --git a/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts b/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts
index 113c64c192..47bb005c2b 100644
--- a/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts
+++ b/app/react/portainer/environments/environment-groups/queries/useEnvironmentGroups.ts
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
+import { withError } from '@/react-tools/react-query';
import { EnvironmentGroup } from '../types';
@@ -11,6 +12,7 @@ export function useEnvironmentGroups() {
return useQuery({
queryKey: queryKeys.base(),
queryFn: () => getEnvironmentGroups(),
+ ...withError('Unable to retrieve environment groups'),
});
}
@@ -19,6 +21,6 @@ async function getEnvironmentGroups() {
const { data } = await axios.get>(buildUrl());
return data;
} catch (e) {
- throw parseAxiosError(e, 'Unable to get access tokens');
+ throw parseAxiosError(e, 'Unable to retrieve environment groups');
}
}
diff --git a/app/react/portainer/environments/environment-groups/types.ts b/app/react/portainer/environments/environment-groups/types.ts
index 0a7fd7a875..d8c4117216 100644
--- a/app/react/portainer/environments/environment-groups/types.ts
+++ b/app/react/portainer/environments/environment-groups/types.ts
@@ -6,6 +6,13 @@ import {
EnvironmentGroupId,
} from '../types';
+export interface EnvironmentGroupTypeInfo {
+ Docker: number;
+ Kubernetes: number;
+ Podman: number;
+ Mixed: boolean;
+}
+
export interface EnvironmentGroup {
// Environment(Endpoint) group Identifier
Id: EnvironmentGroupId;
@@ -17,4 +24,6 @@ export interface EnvironmentGroup {
TagIds: TagId[];
UserAccessPolicies?: UserAccessPolicies;
TeamAccessPolicies?: TeamAccessPolicies;
+ Total?: number;
+ TypeInfo?: EnvironmentGroupTypeInfo;
}
diff --git a/app/react/portainer/environments/environment-groups/utils/getPlatformLabel.ts b/app/react/portainer/environments/environment-groups/utils/getPlatformLabel.ts
new file mode 100644
index 0000000000..7c9e13d46f
--- /dev/null
+++ b/app/react/portainer/environments/environment-groups/utils/getPlatformLabel.ts
@@ -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;
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 69ed9c0ddb..3c7caa4509 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -14,6 +14,7 @@ module.exports = {
current: 'currentColor',
inherit: 'inherit',
...colors,
+ 'group-accent': colors.violet,
'legacy-grey-3': 'var(--grey-3)',
'legacy-blue-2': 'var(--blue-2)',