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 && ( +
+ Kubernetes + + {group.TypeInfo.Kubernetes} Kubernetes + +
+ )} + {group.TypeInfo.Docker > 0 && ( +
+ Docker + + {group.TypeInfo.Docker} Docker + +
+ )} + {group.TypeInfo.Podman > 0 && ( +
+ Podman + + {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)',