feat(environment-groups): replace Datatable with SortableList and update list UI [R8S-827] (#2661)

This commit is contained in:
Josiah Clumont
2026-05-29 10:08:35 +12:00
committed by GitHub
parent f7b8e3d84b
commit 98b1d7f585
26 changed files with 1768 additions and 176 deletions
+12 -16
View File
@@ -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))
_, err := git.CloneContext(ctx, storer, wt, opt)
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")
}
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")
// 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")
}
return nil
}
@@ -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)
}
@@ -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,
}: PropsWithChildren<{ title: string }>) {
return (
<div className="mb-2 flex items-center gap-2 px-[15px]">
<div className="mx-5 mb-2 flex items-center gap-2">
<h1
className="m-0 text-lg font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white"
data-cy="page-title"
+7
View File
@@ -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 {
padding: 0;
margin: 0;
+3 -1
View File
@@ -25,11 +25,13 @@ type Color =
| 'warninglight'
| 'warning'
| 'success'
| 'blue'
| 'none';
type Size = 'xsmall' | 'small' | 'medium' | 'large';
export interface Props<TasProps = unknown>
extends AriaAttributes, AutomationTestingProps {
extends AriaAttributes,
AutomationTestingProps {
icon?: ReactNode | ComponentType<unknown>;
color?: Color;
@@ -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' },
]}
>
<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="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(
values: GroupFormValues,
{ resetForm }: FormikHelpers<GroupFormValues>
@@ -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"
/>
);
}
@@ -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');
},
});
}
}
@@ -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 +0,0 @@
export { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
@@ -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');
}
}
@@ -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>
);
}
@@ -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')
);
});
});
});
});
});
@@ -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 { EnvironmentGroupsDatatable } from './EnvironmentGroupsDatatable';
import { EnvironmentGroupsTable } from './EnvironmentGroupsTable/EnvironmentGroupsTable';
export function ListView() {
const router = useRouter();
return (
<>
<PageHeader
title="Environment Groups"
breadcrumbs="Environment group management"
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>
</>
);
}
@@ -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();
});
});
@@ -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>
);
}
@@ -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 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<Array<EnvironmentGroup>>(buildUrl());
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to get access tokens');
throw parseAxiosError(e, 'Unable to retrieve environment groups');
}
}
@@ -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;
}
@@ -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;
}
+1
View File
@@ -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)',