From d9a415f01145fc777bc4175104b91fa92c6163d0 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 12 May 2026 15:32:46 +0300 Subject: [PATCH] feat(gitops): introduce sources list view [BE-12902] (#2550) --- __mocks__/@reach/menu-button.tsx | 118 ++++++ api/gitops/workflows/fetch.go | 99 +++++ .../handler => }/gitops/workflows/filter.go | 68 +++- api/gitops/workflows/filter_test.go | 289 ++++++++++++++ api/gitops/workflows/mapping.go | 17 +- api/gitops/workflows/types.go | 9 +- api/http/handler/gitops/handler.go | 4 + api/http/handler/gitops/sources/handler.go | 43 ++ api/http/handler/gitops/sources/list.go | 163 ++++++++ api/http/handler/gitops/sources/summary.go | 58 +++ api/http/handler/gitops/sources/types.go | 70 ++++ .../handler/gitops/workflows/filter_test.go | 278 ------------- .../handler/gitops/workflows/git_phases.go | 32 -- api/http/handler/gitops/workflows/list.go | 114 +----- app/portainer/__module.js | 29 +- app/portainer/react/views/gitops.ts | 19 + app/portainer/react/views/index.ts | 7 +- .../components/DropdownMenu/DropdownMenu.tsx | 3 + .../components/FilterBarActiveIndicator.tsx | 29 -- app/react/components/FilterBarButton.test.tsx | 45 --- app/react/components/FilterBarButton.tsx | 107 ----- .../SortableList/SortByGroup.test.tsx | 200 +++------- .../components/SortableList/SortByGroup.tsx | 46 +-- .../SortableList/SortableList.test.tsx | 45 +-- .../components/SortableList/SortableList.tsx | 52 ++- .../SortableListHeader.stories.tsx | 69 ++-- .../SortableList/SortableListHeader.test.tsx | 62 ++- .../SortableList/SortableListHeader.tsx | 20 +- .../StatusFilteredList.stories.tsx | 356 +++++++++++++++++ .../SortableList/StatusFilteredList.test.tsx | 375 ++++++++++++++++++ .../SortableList/sortable-list.store.ts | 21 +- .../FilterBarActiveIndicator.tsx | 1 + .../StatusSummaryBar/FilterBarButton.tsx | 10 +- .../StatusSummaryBar.test.tsx | 2 +- .../StatusSummaryBar/StatusSummaryBar.tsx | 3 + .../datatables/groupSortState.test.ts | 207 ++++++++++ .../components/datatables/groupSortState.ts | 98 +++++ .../datatables/useTableStateFromUrl.ts | 15 +- .../portainer/HomeView/useHomeViewState.ts | 1 + .../gitops/WorkflowsView/WorkflowsView.tsx | 1 + .../gitops/WorkflowsView/useListState.ts | 76 ++-- .../gitops/sources/ListView/ListView.tsx | 129 ++++++ .../gitops/sources/ListView/SourceCard.tsx | 59 +++ .../gitops/sources/ListView/StatBlock.tsx | 23 ++ .../gitops/sources/ListView/useListState.ts | 48 +++ .../gitops/sources/queries/query-keys.ts | 5 + .../gitops/sources/queries/useSources.ts | 32 ++ .../sources/queries/useSourcesSummary.ts | 23 ++ app/react/portainer/gitops/sources/types.ts | 31 ++ app/react/sidebar/AppDeliverySidebar.tsx | 25 ++ app/react/sidebar/Sidebar.tsx | 10 +- eslint.config.mjs | 4 +- 52 files changed, 2655 insertions(+), 995 deletions(-) create mode 100644 __mocks__/@reach/menu-button.tsx create mode 100644 api/gitops/workflows/fetch.go rename api/{http/handler => }/gitops/workflows/filter.go (71%) create mode 100644 api/gitops/workflows/filter_test.go create mode 100644 api/http/handler/gitops/sources/handler.go create mode 100644 api/http/handler/gitops/sources/list.go create mode 100644 api/http/handler/gitops/sources/summary.go create mode 100644 api/http/handler/gitops/sources/types.go delete mode 100644 api/http/handler/gitops/workflows/git_phases.go create mode 100644 app/portainer/react/views/gitops.ts delete mode 100644 app/react/components/FilterBarActiveIndicator.tsx delete mode 100644 app/react/components/FilterBarButton.test.tsx delete mode 100644 app/react/components/FilterBarButton.tsx create mode 100644 app/react/components/SortableList/StatusFilteredList.stories.tsx create mode 100644 app/react/components/SortableList/StatusFilteredList.test.tsx create mode 100644 app/react/components/datatables/groupSortState.test.ts create mode 100644 app/react/components/datatables/groupSortState.ts create mode 100644 app/react/portainer/gitops/sources/ListView/ListView.tsx create mode 100644 app/react/portainer/gitops/sources/ListView/SourceCard.tsx create mode 100644 app/react/portainer/gitops/sources/ListView/StatBlock.tsx create mode 100644 app/react/portainer/gitops/sources/ListView/useListState.ts create mode 100644 app/react/portainer/gitops/sources/queries/query-keys.ts create mode 100644 app/react/portainer/gitops/sources/queries/useSources.ts create mode 100644 app/react/portainer/gitops/sources/queries/useSourcesSummary.ts create mode 100644 app/react/portainer/gitops/sources/types.ts create mode 100644 app/react/sidebar/AppDeliverySidebar.tsx diff --git a/__mocks__/@reach/menu-button.tsx b/__mocks__/@reach/menu-button.tsx new file mode 100644 index 0000000000..175568761a --- /dev/null +++ b/__mocks__/@reach/menu-button.tsx @@ -0,0 +1,118 @@ +import { + Children, + useState, + useEffect, + useRef, + useContext, + createContext, + ReactNode, +} from 'react'; + +type MenuCtxType = { + isOpen: boolean; + setOpen: (v: boolean) => void; + menuRef: React.RefObject; + label: string; + setLabel: (v: string) => void; +}; + +const MenuCtx = createContext(null); + +export function Menu({ children }: { children?: ReactNode }) { + const [isOpen, setOpen] = useState(false); + const [label, setLabel] = useState(''); + const menuRef = useRef(null); + + useEffect(() => { + function handleDocDown(e: MouseEvent) { + const target = e.target as Node | null; + if ( + isOpen && + menuRef.current && + target && + !menuRef.current.contains(target) + ) { + setOpen(false); + } + } + + document.addEventListener('mousedown', handleDocDown); + return () => document.removeEventListener('mousedown', handleDocDown); + }, [isOpen]); + + return ( + +
{children}
+
+ ); +} + +export function MenuButton({ + children, + onClick: externalOnClick, + ...props +}: { + children?: ReactNode; + onClick?: () => void; + [key: string]: unknown; +}) { + const ctx = useContext(MenuCtx); + + useEffect(() => { + const firstText = Children.toArray(children).find( + (c) => typeof c === 'string' + ); + if (firstText) ctx?.setLabel(firstText as string); + }); + + function handleClick() { + externalOnClick?.(); + ctx?.setOpen(!ctx.isOpen); + } + + return ( + + ); +} + +export function MenuList({ + children, + className, +}: { + children?: ReactNode; + className?: string; +}) { + const ctx = useContext(MenuCtx); + if (!ctx?.isOpen) return null; + return ( +
+ {children} +
+ ); +} + +export function MenuItem({ + children, + onSelect, + className, +}: { + children?: ReactNode; + onSelect?: () => void; + className?: string; +}) { + const ctx = useContext(MenuCtx); + + function handleClick() { + onSelect?.(); + ctx?.setOpen(false); + } + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus +
+ {children} +
+ ); +} diff --git a/api/gitops/workflows/fetch.go b/api/gitops/workflows/fetch.go new file mode 100644 index 0000000000..54c0129c86 --- /dev/null +++ b/api/gitops/workflows/fetch.go @@ -0,0 +1,99 @@ +package workflows + +import ( + "context" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/set" +) + +// FetchWorkflows returns all GitOps workflows visible to the given user. +func FetchWorkflows( + ctx context.Context, + dataStore dataservices.DataStore, + gitService portainer.GitService, + k8sFactory *cli.ClientFactory, + sc *security.RestrictedRequestContext, + endpointIDSet set.Set[portainer.EndpointID], +) ([]Workflow, error) { + var entries []portainer.Stack + var endpointMap map[portainer.EndpointID]portainer.Endpoint + + err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { + return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID)) + }) + if err != nil { + return err + } + + endpointMap, err = buildEndpointMap(tx, stacks) + if err != nil { + return err + } + + stacks, err = filterDockerStacksByAccess(tx, stacks, sc) + if err != nil { + return err + } + + for i := range stacks { + s := stacks[i] + + if ep, ok := endpointMap[s.EndpointID]; ok && !EndpointMatchesStackType(ep, s.Type) { + continue + } + entries = append(entries, s) + } + + return nil + }) + if err != nil { + return nil, err + } + + accessMap, err := buildEndpointAccessMap(k8sFactory, sc, endpointMap) + if err != nil { + return nil, err + } + + entries, err = filterK8SStacks(entries, endpointMap, k8sFactory, accessMap) + if err != nil { + return nil, err + } + + items := make([]Workflow, 0, len(entries)) + for _, s := range entries { + source, artifact := computePhases(ctx, gitService, s.GitConfig) + items = append(items, MapStackToWorkflow(s, s.GitConfig, source, artifact)) + } + + return items, nil +} + +func computePhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact WorkflowPhaseStatus) { + if gitSvc == nil || cfg == nil { + return WorkflowPhaseStatus{Status: StatusUnknown}, WorkflowPhaseStatus{Status: StatusUnknown} + } + + username, password := gitCredentials(cfg) + return ComputeGitPhases(ctx, cfg.ReferenceName, cfg.ConfigFilePath, + func(ctx context.Context) ([]string, error) { + return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify) + }, + func(ctx context.Context, exts []string) ([]string, error) { + return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, false, false, exts, cfg.TLSSkipVerify) + }, + ) +} + +func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) { + if cfg.Authentication != nil { + return cfg.Authentication.Username, cfg.Authentication.Password + } + return "", "" +} diff --git a/api/http/handler/gitops/workflows/filter.go b/api/gitops/workflows/filter.go similarity index 71% rename from api/http/handler/gitops/workflows/filter.go rename to api/gitops/workflows/filter.go index a063688bb6..e3b7472d2d 100644 --- a/api/http/handler/gitops/workflows/filter.go +++ b/api/gitops/workflows/filter.go @@ -2,9 +2,12 @@ package workflows import ( "fmt" + "slices" + "strconv" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/models/kubernetes" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/endpointutils" @@ -15,7 +18,7 @@ import ( "github.com/portainer/portainer/api/stacks/stackutils" ) -func endpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool { +func EndpointMatchesStackType(ep portainer.Endpoint, stackType portainer.StackType) bool { switch stackType { case portainer.DockerSwarmStack: return len(ep.Snapshots) > 0 && ep.Snapshots[0].Swarm @@ -47,11 +50,6 @@ func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (ma return m, nil } -type endpointAccess struct { - isKubeAdmin bool - nonAdminNamespaces []string -} - // filterDockerStacksByAccess filters stacks to only those the current user can access. func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) { if sc.IsAdmin { @@ -102,6 +100,11 @@ func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedReq return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil } +type endpointAccess struct { + isKubeAdmin bool + nonAdminNamespaces []string +} + func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) { result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap)) @@ -120,3 +123,56 @@ func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.Restrict return result, nil } + +// lookup only if env is kube and either not edge or (edge + not async) +func ShouldPerformEnvLookup(endpoint *portainer.Endpoint) bool { + return endpointutils.IsKubernetesEndpoint(endpoint) && + (!endpointutils.IsEdgeEndpoint(endpoint) || + (endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode)) +} + +func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) { + k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool { + return s.Type == portainer.KubernetesStack + }) + + groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID { + return s.EndpointID + }) + + for envID, stacks := range groupedByEnvId { + ep, ok := endpointMap[envID] + if !ok || !ShouldPerformEnvLookup(&ep) { + continue + } + + kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep) + if err != nil { + return nil, err + } + + access := accessMap[envID] + kcl.SetIsKubeAdmin(access.isKubeAdmin) + kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces) + + apps, err := kcl.GetApplications("", "") + if err != nil { + return nil, err + } + + for _, s := range stacks { + idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool { + return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID)) + }) + if idx == -1 { + continue + } + + app := apps[idx] + s.Name = app.Name + s.Namespace = app.ResourcePool + result = append(result, s) + } + } + return result, nil +} diff --git a/api/gitops/workflows/filter_test.go b/api/gitops/workflows/filter_test.go new file mode 100644 index 0000000000..7d7982084e --- /dev/null +++ b/api/gitops/workflows/filter_test.go @@ -0,0 +1,289 @@ +package workflows + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/kubernetes/cli" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) { + t.Parallel() + _, store := datastore.MustNewTestStore(t, false, true) + + user := &portainer.User{ + ID: 1, + Username: "standard", + Role: portainer.StandardUserRole, + PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), + } + require.NoError(t, store.User().Create(user)) + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + } + + kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack} + dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack} + + stacks := []portainer.Stack{kubeStack, dockerStack} + + var result []portainer.Stack + err := store.ViewTx(func(tx dataservices.DataStoreTx) error { + var txErr error + result, txErr = filterDockerStacksByAccess(tx, stacks, sc) + return txErr + }) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, "kube-stack", result[0].Name) +} + +func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) { + t.Parallel() + + sc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}, + {ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}, + } + + result, err := filterDockerStacksByAccess(nil, stacks, sc) + require.NoError(t, err) + require.Len(t, result, 2) +} + +func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) { + t.Parallel() + + sc := &security.RestrictedRequestContext{ + IsAdmin: true, + UserID: 1, + } + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + 2: {ID: 2, Type: portainer.DockerEnvironment}, + } + + result, err := buildEndpointAccessMap(nil, sc, endpointMap) + require.NoError(t, err) + require.Len(t, result, 1) + require.True(t, result[1].isKubeAdmin) + require.Empty(t, result[1].nonAdminNamespaces) +} + +func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "default", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: true}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "my-app", result[0].Name) + assert.Equal(t, "default", result[0].Namespace) +} + +func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: true}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Empty(t, result) +} + +func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "ns1", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: false, nonAdminNamespaces: []string{"ns1"}}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "my-app", result[0].Name) +} + +func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + ep := &portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + } + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + UserMemberships: []portainer.TeamMembership{ + {TeamID: 5}, + }, + } + + access, err := resolveKubeAccess(factory, sc, ep) + require.NoError(t, err) + require.False(t, access.isKubeAdmin) + require.Equal(t, []string{"default"}, access.nonAdminNamespaces) +} + +func TestResolveKubeAccess_NonAdmin(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + ep := &portainer.Endpoint{ + ID: 1, + Type: portainer.KubernetesLocalEnvironment, + } + + sc := &security.RestrictedRequestContext{ + IsAdmin: false, + UserID: 1, + } + + access, err := resolveKubeAccess(factory, sc, ep) + require.NoError(t, err) + require.False(t, access.isKubeAdmin) + require.Equal(t, []string{"default"}, access.nonAdminNamespaces) +} + +func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) { + t.Parallel() + + fakeKubeClient := kfake.NewSimpleClientset() + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "ns1", + Labels: map[string]string{ + "io.portainer.kubernetes.application.stackid": "1", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, + }, + } + + _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) + require.NoError(t, err) + + kcl := cli.NewTestKubeClient(fakeKubeClient) + factory := cli.NewTestClientFactory(1, kcl) + + endpointMap := map[portainer.EndpointID]portainer.Endpoint{ + 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, + } + + stacks := []portainer.Stack{ + {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, + } + + accessMap := map[portainer.EndpointID]endpointAccess{ + 1: {isKubeAdmin: false, nonAdminNamespaces: []string{}}, + } + + result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) + require.NoError(t, err) + require.Empty(t, result) +} diff --git a/api/gitops/workflows/mapping.go b/api/gitops/workflows/mapping.go index 4ac28cf9f0..20fd04a71b 100644 --- a/api/gitops/workflows/mapping.go +++ b/api/gitops/workflows/mapping.go @@ -3,6 +3,7 @@ package workflows import ( portainer "github.com/portainer/portainer/api" gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/set" ) // MapStackToWorkflow converts a stack to a Workflow. gitConfig is passed separately @@ -49,8 +50,9 @@ func MapEdgeStackToWorkflow(es portainer.EdgeStack, gitConfig *gittypes.RepoConf }, GitConfig: gitConfig, Target: Target{ - EdgeGroupIDs: es.EdgeGroups, - GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints), + EdgeGroupIDs: es.EdgeGroups, + GroupStatus: edgeStackTargetStatuses(es.EdgeGroups, statuses, groupEndpoints), + ResolvedEndpointIDs: resolveEdgeGroupEndpoints(es.EdgeGroups, groupEndpoints), }, CreationDate: es.CreationDate, LastSyncDate: edgeStackLastSyncDate(statuses), @@ -112,6 +114,17 @@ func isEdgeStackHealthyStatus(t portainer.EdgeStackStatusType) bool { return false } +func resolveEdgeGroupEndpoints(groups []portainer.EdgeGroupID, groupEndpoints map[portainer.EdgeGroupID][]portainer.EndpointID) []portainer.EndpointID { + seen := set.Set[portainer.EndpointID]{} + for _, gid := range groups { + for _, epID := range groupEndpoints[gid] { + seen.Add(epID) + + } + } + return seen.Keys() +} + func edgeStackTargetStatuses( groups []portainer.EdgeGroupID, statuses []portainer.EdgeStackStatusForEnv, diff --git a/api/gitops/workflows/types.go b/api/gitops/workflows/types.go index 16df512c70..83ac502754 100644 --- a/api/gitops/workflows/types.go +++ b/api/gitops/workflows/types.go @@ -57,10 +57,11 @@ func ParsePlatform(s string) (DeploymentPlatform, error) { } type Target struct { - EndpointID portainer.EndpointID `json:"endpointId,omitempty"` - Namespace string `json:"namespace,omitempty"` - EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"` - GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"` + EndpointID portainer.EndpointID `json:"endpointId,omitempty"` + Namespace string `json:"namespace,omitempty"` + EdgeGroupIDs []portainer.EdgeGroupID `json:"edgeGroupIds,omitempty"` + GroupStatus map[portainer.EdgeGroupID]Status `json:"groupStatus,omitempty"` + ResolvedEndpointIDs []portainer.EndpointID `json:"resolvedEndpointIds,omitempty"` } // WorkflowPhaseStatus represents the status of one phase (source, artifact, or target) of a workflow. diff --git a/api/http/handler/gitops/handler.go b/api/http/handler/gitops/handler.go index cca16ba10b..2fb1553ef2 100644 --- a/api/http/handler/gitops/handler.go +++ b/api/http/handler/gitops/handler.go @@ -11,6 +11,7 @@ import ( "github.com/gorilla/mux" + "github.com/portainer/portainer/api/http/handler/gitops/sources" "github.com/portainer/portainer/api/http/handler/gitops/workflows" ) @@ -38,5 +39,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor workflowsHandler := workflows.NewHandler(dataStore, gitService, k8sFactory) authenticatedRouter.PathPrefix("/gitops/workflows").Handler(workflowsHandler) + sourcesHandler := sources.NewHandler(dataStore, gitService, k8sFactory) + authenticatedRouter.PathPrefix("/gitops/sources").Handler(sourcesHandler) + return h } diff --git a/api/http/handler/gitops/sources/handler.go b/api/http/handler/gitops/sources/handler.go new file mode 100644 index 0000000000..e61b259fc7 --- /dev/null +++ b/api/http/handler/gitops/sources/handler.go @@ -0,0 +1,43 @@ +package sources + +import ( + "net/http" + "time" + + gocache "github.com/patrickmn/go-cache" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/kubernetes/cli" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + + "github.com/gorilla/mux" +) + +const ( + cacheTTL = 30 * time.Second + cacheCleanupInterval = 10 * time.Minute +) + +// Handler is the HTTP handler for the GitOps sources API. +type Handler struct { + *mux.Router + dataStore dataservices.DataStore + gitService portainer.GitService + cache *gocache.Cache + k8sFactory *cli.ClientFactory +} + +func NewHandler(dataStore dataservices.DataStore, gitService portainer.GitService, k8sFactory *cli.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + dataStore: dataStore, + gitService: gitService, + cache: gocache.New(cacheTTL, cacheCleanupInterval), + k8sFactory: k8sFactory, + } + + router := h.PathPrefix("/gitops/sources").Subrouter() + router.Handle("", httperror.LoggerHandler(h.list)).Methods(http.MethodGet) + router.Handle("/summary", httperror.LoggerHandler(h.summary)).Methods(http.MethodGet) + return h +} diff --git a/api/http/handler/gitops/sources/list.go b/api/http/handler/gitops/sources/list.go new file mode 100644 index 0000000000..a14190ac8b --- /dev/null +++ b/api/http/handler/gitops/sources/list.go @@ -0,0 +1,163 @@ +package sources + +import ( + "context" + "net/http" + "slices" + "strconv" + "strings" + + gocache "github.com/patrickmn/go-cache" + portainer "github.com/portainer/portainer/api" + ceWorkflows "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/http/utils/filters" + "github.com/portainer/portainer/api/set" + "github.com/portainer/portainer/api/slicesx" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsSourcesList +// @summary List all GitOps sources +// @description Returns a deduplicated list of git repositories used across all GitOps workflows. +// @description **Access policy**: admin +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param search query string false "Search term (matches URL)" +// @param sort query string false "Sort field: name | status | type" +// @param order query string false "Sort order: asc or desc" +// @param start query int false "Pagination start index" +// @param limit query int false "Pagination limit (0 = unlimited)" +// @param status query string false "Filter by status: healthy | syncing | error | paused | unknown" +// @param type query SourceType false "Filter by source type: git | oci | helm" +// @success 200 {array} Source +// @failure 400 "Invalid status parameter" +// @failure 403 "Access denied" +// @failure 500 "Server error" +// @router /gitops/sources [get] +func (h *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + params := filters.ExtractListModifiersQueryParams(r) + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !securityContext.IsAdmin { + return httperror.Forbidden("Access denied", nil) + } + + key := cacheKey(securityContext) + + sources, err := h.getSources(r.Context(), key, securityContext) + if err != nil { + return httperror.InternalServerError("Unable to retrieve sources", err) + } + + if status, _ := request.RetrieveQueryParameter(r, "status", true); status != "" { + s, err := ceWorkflows.ParseStatus(status) + if err != nil { + return httperror.BadRequest("Invalid status parameter", err) + } + sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Status == s }) + } + + if sourceType, _ := request.RetrieveQueryParameter(r, "type", true); sourceType != "" { + t, err := parseSourceType(sourceType) + if err != nil { + return httperror.BadRequest("Invalid type parameter", err) + } + sources = slicesx.FilterInPlace(sources, func(i Source) bool { return i.Type == t }) + } + + results := filters.SearchOrderAndPaginate(sources, params, filters.Config[Source]{ + SearchAccessors: []filters.SearchAccessor[Source]{ + func(s Source) (string, error) { return s.URL, nil }, + }, + SortBindings: []filters.SortBinding[Source]{ + {Key: "name", Fn: func(a, b Source) int { return strings.Compare(a.Name, b.Name) }}, + {Key: "status", Fn: func(a, b Source) int { return strings.Compare(string(a.Status), string(b.Status)) }}, + {Key: "type", Fn: func(a, b Source) int { return strings.Compare(a.Type, b.Type) }}, + }, + }) + + filters.ApplyFilterResultsHeaders(&w, results) + return response.JSON(w, results.Items) +} + +func (h *Handler) getSources(ctx context.Context, key string, sc *security.RestrictedRequestContext) ([]Source, error) { + if cached, ok := h.cache.Get(key); ok { + return slices.Clone(cached.([]Source)), nil + } + + result, err := h.fetchSources(ctx, sc) + if err != nil { + return nil, err + } + h.cache.Set(key, result, gocache.DefaultExpiration) + return slices.Clone(result), nil +} + +func cacheKey(sc *security.RestrictedRequestContext) string { + teamIDs := make([]string, len(sc.UserMemberships)) + for i, membership := range sc.UserMemberships { + teamIDs[i] = strconv.Itoa(int(membership.TeamID)) + } + slices.Sort(teamIDs) + + return strconv.Itoa(int(sc.UserID)) + ":" + strconv.FormatBool(sc.IsAdmin) + ":" + strings.Join(teamIDs, ",") +} + +func (h *Handler) fetchSources(ctx context.Context, sc *security.RestrictedRequestContext) ([]Source, error) { + workflows, err := ceWorkflows.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, nil) + if err != nil { + return nil, err + } + + byURL := make(map[string][]ceWorkflows.Workflow) + for _, wf := range workflows { + if wf.GitConfig != nil { + byURL[wf.GitConfig.URL] = append(byURL[wf.GitConfig.URL], wf) + } + } + + sources := make([]Source, 0, len(byURL)) + for url, wfs := range byURL { + statuses := make([]ceWorkflows.Status, 0, len(wfs)) + var sourceError string + var lastSync int64 + endpointIDs := make(set.Set[portainer.EndpointID]) + for _, wf := range wfs { + statuses = append(statuses, wf.Status.Source.Status) + if sourceError == "" && wf.Status.Source.Status == ceWorkflows.StatusError { + sourceError = wf.Status.Source.Error + } + if wf.LastSyncDate > lastSync { + lastSync = wf.LastSyncDate + } + if wf.Target.EndpointID != 0 { + endpointIDs.Add(wf.Target.EndpointID) + } + for _, id := range wf.Target.ResolvedEndpointIDs { + endpointIDs.Add(id) + } + } + + sources = append(sources, Source{ + ID: sourceID(url), + Name: repoName(url), + Type: "git", + URL: url, + Status: worstCaseStatus(statuses), + Error: sourceError, + UsedBy: len(wfs), + Environments: len(endpointIDs), + LastSync: lastSync, + }) + } + return sources, nil +} diff --git a/api/http/handler/gitops/sources/summary.go b/api/http/handler/gitops/sources/summary.go new file mode 100644 index 0000000000..ef9f62a27a --- /dev/null +++ b/api/http/handler/gitops/sources/summary.go @@ -0,0 +1,58 @@ +package sources + +import ( + "net/http" + + ce "github.com/portainer/portainer/api/gitops/workflows" + "github.com/portainer/portainer/api/http/security" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/response" +) + +// @id GitOpsSourcesSummary +// @summary Summarize GitOps source status counts +// @description Returns a count of sources per status. +// @description **Access policy**: admin +// @tags gitops +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {object} ce.StatusSummary +// @failure 403 "Access denied" +// @failure 500 "Server error" +// @router /gitops/sources/summary [get] +func (h *Handler) summary(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + if !securityContext.IsAdmin { + return httperror.Forbidden("Access denied", nil) + } + + key := cacheKey(securityContext) + + sources, err := h.getSources(r.Context(), key, securityContext) + if err != nil { + return httperror.InternalServerError("Unable to retrieve sources", err) + } + + summary := ce.StatusSummary{} + for _, s := range sources { + switch s.Status { + case ce.StatusHealthy: + summary.Healthy++ + case ce.StatusSyncing: + summary.Syncing++ + case ce.StatusError: + summary.Error++ + case ce.StatusPaused: + summary.Paused++ + default: + summary.Unknown++ + } + } + + return response.JSON(w, summary) +} diff --git a/api/http/handler/gitops/sources/types.go b/api/http/handler/gitops/sources/types.go new file mode 100644 index 0000000000..b9665b4eeb --- /dev/null +++ b/api/http/handler/gitops/sources/types.go @@ -0,0 +1,70 @@ +package sources + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "path" + "strings" + + ce "github.com/portainer/portainer/api/gitops/workflows" +) + +// Source represents a unique git repository used as a GitOps source across one or more workflows. +type Source struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Status ce.Status `json:"status"` + Error string `json:"error,omitempty"` + UsedBy int `json:"usedBy"` + Environments int `json:"environments"` + LastSync int64 `json:"lastSync"` +} + +type SourceType string + +const ( + SourceTypeGit SourceType = "git" + SourceTypeHelm SourceType = "helm" + SourceTypeOCI SourceType = "oci" +) + +func parseSourceType(s string) (string, error) { + switch SourceType(s) { + case SourceTypeGit, SourceTypeHelm, SourceTypeOCI: + return s, nil + default: + return "", fmt.Errorf("invalid source type %q: must be git, helm, or oci", s) + } +} + +func sourceID(url string) string { + h := sha256.Sum256([]byte(url)) + return hex.EncodeToString(h[:8]) +} + +// repoName extracts the repository name from a URL. +// e.g. "https://github.com/org/app-config.git" → "app-config" +func repoName(rawURL string) string { + base := path.Base(rawURL) + return strings.TrimSuffix(base, ".git") +} + +func worstCaseStatus(statuses []ce.Status) ce.Status { + priority := map[ce.Status]int{ + ce.StatusError: 4, + ce.StatusSyncing: 3, + ce.StatusPaused: 2, + ce.StatusHealthy: 1, + ce.StatusUnknown: 0, + } + worst := ce.StatusUnknown + for _, s := range statuses { + if priority[s] > priority[worst] { + worst = s + } + } + return worst +} diff --git a/api/http/handler/gitops/workflows/filter_test.go b/api/http/handler/gitops/workflows/filter_test.go index 283051f7b2..ccaca02a68 100644 --- a/api/http/handler/gitops/workflows/filter_test.go +++ b/api/http/handler/gitops/workflows/filter_test.go @@ -5,17 +5,10 @@ import ( "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" - "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/stacks/stackutils" - appsv1 "k8s.io/api/apps/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - kfake "k8s.io/client-go/kubernetes/fake" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -85,274 +78,3 @@ func TestWorkflowsList_RBAC_NonAdminWithAccess(t *testing.T) { require.Len(t, items, 1) assert.Equal(t, stackName, items[0].Name) } - -func TestFilterDockerStacksByAccess_KubeStacksPassThrough(t *testing.T) { - t.Parallel() - _, store := datastore.MustNewTestStore(t, false, true) - - user := &portainer.User{ - ID: 1, - Username: "standard", - Role: portainer.StandardUserRole, - PortainerAuthorizations: authorization.DefaultPortainerAuthorizations(), - } - require.NoError(t, store.User().Create(user)) - - sc := &security.RestrictedRequestContext{ - IsAdmin: false, - UserID: 1, - } - - kubeStack := portainer.Stack{ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack} - dockerStack := portainer.Stack{ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack} - - stacks := []portainer.Stack{kubeStack, dockerStack} - - var result []portainer.Stack - err := store.ViewTx(func(tx dataservices.DataStoreTx) error { - var txErr error - result, txErr = filterDockerStacksByAccess(tx, stacks, sc) - - return txErr - }) - require.NoError(t, err) - require.Len(t, result, 1) - require.Equal(t, "kube-stack", result[0].Name) -} - -func TestFilterDockerStacksByAccess_AdminGetsAll(t *testing.T) { - t.Parallel() - - sc := &security.RestrictedRequestContext{ - IsAdmin: true, - UserID: 1, - } - - stacks := []portainer.Stack{ - {ID: 1, Name: "kube-stack", Type: portainer.KubernetesStack}, - {ID: 2, Name: "docker-stack", Type: portainer.DockerComposeStack}, - } - - result, err := filterDockerStacksByAccess(nil, stacks, sc) - require.NoError(t, err) - require.Len(t, result, 2) -} - -func TestBuildEndpointAccessMap_AdminIsKubeAdmin(t *testing.T) { - t.Parallel() - - sc := &security.RestrictedRequestContext{ - IsAdmin: true, - UserID: 1, - } - - endpointMap := map[portainer.EndpointID]portainer.Endpoint{ - 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, - 2: {ID: 2, Type: portainer.DockerEnvironment}, - } - - result, err := buildEndpointAccessMap(nil, sc, endpointMap) - require.NoError(t, err) - require.Len(t, result, 1) - require.True(t, result[1].isKubeAdmin) - require.Empty(t, result[1].nonAdminNamespaces) -} - -func TestFilterK8SStacks_IncludesMatchingStack(t *testing.T) { - t.Parallel() - - fakeKubeClient := kfake.NewSimpleClientset() - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-app", - Namespace: "default", - Labels: map[string]string{ - "io.portainer.kubernetes.application.stackid": "1", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, - }, - } - - _, err := fakeKubeClient.AppsV1().Deployments("default").Create(t.Context(), deployment, metav1.CreateOptions{}) - require.NoError(t, err) - - kcl := cli.NewTestKubeClient(fakeKubeClient) - factory := cli.NewTestClientFactory(1, kcl) - - endpointMap := map[portainer.EndpointID]portainer.Endpoint{ - 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, - } - - stacks := []portainer.Stack{ - {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, - } - - accessMap := map[portainer.EndpointID]endpointAccess{ - 1: {isKubeAdmin: true}, - } - - result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) - require.NoError(t, err) - require.Len(t, result, 1) - assert.Equal(t, "my-app", result[0].Name) - assert.Equal(t, "default", result[0].Namespace) -} - -func TestFilterK8SStacks_ExcludesStackWhenNoMatchingDeployment(t *testing.T) { - t.Parallel() - - fakeKubeClient := kfake.NewSimpleClientset() - kcl := cli.NewTestKubeClient(fakeKubeClient) - factory := cli.NewTestClientFactory(1, kcl) - - endpointMap := map[portainer.EndpointID]portainer.Endpoint{ - 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, - } - - stacks := []portainer.Stack{ - {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, - } - - accessMap := map[portainer.EndpointID]endpointAccess{ - 1: {isKubeAdmin: true}, - } - - result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) - require.NoError(t, err) - require.Empty(t, result) -} - -func TestFilterK8SStacks_NonAdminWithNamespaceAccess(t *testing.T) { - t.Parallel() - - fakeKubeClient := kfake.NewSimpleClientset() - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-app", - Namespace: "ns1", - Labels: map[string]string{ - "io.portainer.kubernetes.application.stackid": "1", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, - }, - } - - _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) - require.NoError(t, err) - - kcl := cli.NewTestKubeClient(fakeKubeClient) - factory := cli.NewTestClientFactory(1, kcl) - - endpointMap := map[portainer.EndpointID]portainer.Endpoint{ - 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, - } - - stacks := []portainer.Stack{ - {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, - } - - accessMap := map[portainer.EndpointID]endpointAccess{ - 1: {isKubeAdmin: false, nonAdminNamespaces: []string{"ns1"}}, - } - - result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) - require.NoError(t, err) - require.Len(t, result, 1) - assert.Equal(t, "my-app", result[0].Name) -} - -func TestResolveKubeAccess_NonAdminWithTeamMemberships(t *testing.T) { - t.Parallel() - - fakeKubeClient := kfake.NewSimpleClientset() - kcl := cli.NewTestKubeClient(fakeKubeClient) - factory := cli.NewTestClientFactory(1, kcl) - - ep := &portainer.Endpoint{ - ID: 1, - Type: portainer.KubernetesLocalEnvironment, - } - - sc := &security.RestrictedRequestContext{ - IsAdmin: false, - UserID: 1, - UserMemberships: []portainer.TeamMembership{ - {TeamID: 5}, - }, - } - - access, err := resolveKubeAccess(factory, sc, ep) - require.NoError(t, err) - require.False(t, access.isKubeAdmin) - require.Equal(t, []string{"default"}, access.nonAdminNamespaces) -} - -func TestResolveKubeAccess_NonAdmin(t *testing.T) { - t.Parallel() - - fakeKubeClient := kfake.NewSimpleClientset() - kcl := cli.NewTestKubeClient(fakeKubeClient) - factory := cli.NewTestClientFactory(1, kcl) - - ep := &portainer.Endpoint{ - ID: 1, - Type: portainer.KubernetesLocalEnvironment, - } - - sc := &security.RestrictedRequestContext{ - IsAdmin: false, - UserID: 1, - } - - access, err := resolveKubeAccess(factory, sc, ep) - require.NoError(t, err) - require.False(t, access.isKubeAdmin) - require.Equal(t, []string{"default"}, access.nonAdminNamespaces) -} - -func TestFilterK8SStacks_NonAdminWithoutNamespaceAccess(t *testing.T) { - t.Parallel() - - fakeKubeClient := kfake.NewSimpleClientset() - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-app", - Namespace: "ns1", - Labels: map[string]string{ - "io.portainer.kubernetes.application.stackid": "1", - }, - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "my-app"}}, - }, - } - - _, err := fakeKubeClient.AppsV1().Deployments("ns1").Create(t.Context(), deployment, metav1.CreateOptions{}) - require.NoError(t, err) - - kcl := cli.NewTestKubeClient(fakeKubeClient) - factory := cli.NewTestClientFactory(1, kcl) - - endpointMap := map[portainer.EndpointID]portainer.Endpoint{ - 1: {ID: 1, Type: portainer.KubernetesLocalEnvironment}, - } - - stacks := []portainer.Stack{ - {ID: 1, Name: "stack-name", EndpointID: 1, Type: portainer.KubernetesStack}, - } - - accessMap := map[portainer.EndpointID]endpointAccess{ - 1: {isKubeAdmin: false, nonAdminNamespaces: []string{}}, - } - - result, err := filterK8SStacks(stacks, endpointMap, factory, accessMap) - require.NoError(t, err) - require.Empty(t, result) -} diff --git a/api/http/handler/gitops/workflows/git_phases.go b/api/http/handler/gitops/workflows/git_phases.go deleted file mode 100644 index 59dd6757ae..0000000000 --- a/api/http/handler/gitops/workflows/git_phases.go +++ /dev/null @@ -1,32 +0,0 @@ -package workflows - -import ( - "context" - - portainer "github.com/portainer/portainer/api" - gittypes "github.com/portainer/portainer/api/git/types" - wf "github.com/portainer/portainer/api/gitops/workflows" -) - -func computeGitPhases(ctx context.Context, gitSvc portainer.GitService, cfg *gittypes.RepoConfig) (source, artifact wf.WorkflowPhaseStatus) { - if gitSvc == nil || cfg == nil { - return wf.WorkflowPhaseStatus{Status: wf.StatusUnknown}, wf.WorkflowPhaseStatus{Status: wf.StatusUnknown} - } - - username, password := gitCredentials(cfg) - return wf.ComputeGitPhases(ctx, cfg.ReferenceName, cfg.ConfigFilePath, - func(ctx context.Context) ([]string, error) { - return gitSvc.ListRefs(ctx, cfg.URL, username, password, false, cfg.TLSSkipVerify) - }, - func(ctx context.Context, exts []string) ([]string, error) { - return gitSvc.ListFiles(ctx, cfg.URL, cfg.ReferenceName, username, password, false, false, exts, cfg.TLSSkipVerify) - }, - ) -} - -func gitCredentials(cfg *gittypes.RepoConfig) (username, password string) { - if cfg.Authentication != nil { - return cfg.Authentication.Username, cfg.Authentication.Password - } - return "", "" -} diff --git a/api/http/handler/gitops/workflows/list.go b/api/http/handler/gitops/workflows/list.go index 47f84e6c12..18b09776a6 100644 --- a/api/http/handler/gitops/workflows/list.go +++ b/api/http/handler/gitops/workflows/list.go @@ -10,13 +10,9 @@ import ( gocache "github.com/patrickmn/go-cache" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" svc "github.com/portainer/portainer/api/gitops/workflows" - "github.com/portainer/portainer/api/http/models/kubernetes" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/utils/filters" - "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/set" "github.com/portainer/portainer/api/slicesx" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -141,115 +137,7 @@ func (h *Handler) getWorkflows(ctx context.Context, key string, sc *security.Res } func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedRequestContext, endpointIDSet set.Set[portainer.EndpointID]) ([]svc.Workflow, error) { - var entries []portainer.Stack - var endpointMap map[portainer.EndpointID]portainer.Endpoint - - err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error { - stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { - return s.GitConfig != nil && (len(endpointIDSet) == 0 || endpointIDSet.Contains(s.EndpointID)) - }) - if err != nil { - return err - } - - endpointMap, err = buildEndpointMap(tx, stacks) - if err != nil { - return err - } - - stacks, err = filterDockerStacksByAccess(tx, stacks, sc) - if err != nil { - return err - } - - for i := range stacks { - s := stacks[i] - - if ep, ok := endpointMap[s.EndpointID]; ok && !endpointMatchesStackType(ep, s.Type) { - continue - } - entries = append(entries, s) - } - - return nil - }) - if err != nil { - return nil, err - } - accessMap, err := buildEndpointAccessMap(h.k8sFactory, sc, endpointMap) - if err != nil { - return nil, err - } - - entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, accessMap) - if err != nil { - return nil, err - } - - items := make([]svc.Workflow, 0, len(entries)) - for _, s := range entries { - source, artifact := computeGitPhases(ctx, h.gitService, s.GitConfig) - items = append(items, svc.MapStackToWorkflow(s, s.GitConfig, source, artifact)) - } - - return items, nil -} - -// lookup only if env is kube and either not edge or (edge + not async) -func shouldPerformEnvLookup(endpoint *portainer.Endpoint) bool { - return endpointutils.IsKubernetesEndpoint(endpoint) && - (!endpointutils.IsEdgeEndpoint(endpoint) || - (endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode)) -} - -func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) { - k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool { - return s.Type == portainer.KubernetesStack - }) - - groupedByEnvId := slicesx.GroupBy(k8sStacks, func(s portainer.Stack) portainer.EndpointID { - return s.EndpointID - }) - - for envID, stacks := range groupedByEnvId { - ep, ok := endpointMap[envID] - if !ok || !shouldPerformEnvLookup(&ep) { - continue - } - - kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep) - if err != nil { - return nil, err - } - - access := accessMap[envID] - kcl.SetIsKubeAdmin(access.isKubeAdmin) - kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces) - - apps, err := kcl.GetApplications("", "") - if err != nil { - return nil, err - } - - for _, s := range stacks { - idx := slices.IndexFunc(apps, func(app kubernetes.K8sApplication) bool { - return app.StackKind != "edge" && app.StackID == strconv.Itoa(int(s.ID)) - }) - if idx == -1 { - // if we don't find a matching application (deployment/statefulset/daemonset) in the environment workloads - // this workflow (stack) wouldn't show in the Applications list, so we don't keep it - continue - } - - app := apps[idx] - - s.Name = app.Name - s.Namespace = app.ResourcePool - - result = append(result, s) - } - } - return result, nil + return svc.FetchWorkflows(ctx, h.dataStore, h.gitService, h.k8sFactory, sc, endpointIDSet) } func cacheKey(sc *security.RestrictedRequestContext, endpointIDs []portainer.EndpointID) string { diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 4693c779c5..685d6c3a00 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -280,8 +280,8 @@ angular name: 'portainer.home', url: '/home?redirect&environmentId&environmentName&route&groupBy&groupFilter&search&order', params: { - ...paginationParams('id'), - groupBy: filterParam(), + ...paginationParams('Id'), + groupBy: filterParam('Id'), groupFilter: filterParam(), }, views: { @@ -294,8 +294,14 @@ angular }, }; + var gitopsBase = { + name: 'portainer.gitops', + url: '/gitops', + abstract: true, + }; + var workflows = { - name: 'portainer.workflows', + name: 'portainer.gitops.workflows', url: '/workflows?search&sort&order&page&pageSize&status&type&platform&groupBy&groupFilter', params: { ...paginationParams('name'), @@ -312,6 +318,21 @@ angular }, }; + var gitopsSources = { + name: 'portainer.gitops.sources', + url: '/sources?search&sort&order&page&pageSize&status&type', + params: { + ...paginationParams('name'), + status: filterParam(), + type: filterParam(), + }, + views: { + 'content@': { + component: 'sourcesListView', + }, + }, + }; + var init = { name: 'portainer.init', abstract: true, @@ -432,7 +453,9 @@ angular $stateRegistryProvider.register(groupAccess); $stateRegistryProvider.register(groupCreation); $stateRegistryProvider.register(home); + $stateRegistryProvider.register(gitopsBase); $stateRegistryProvider.register(workflows); + $stateRegistryProvider.register(gitopsSources); $stateRegistryProvider.register(init); $stateRegistryProvider.register(initAdmin); $stateRegistryProvider.register(settings); diff --git a/app/portainer/react/views/gitops.ts b/app/portainer/react/views/gitops.ts new file mode 100644 index 0000000000..b9bb6ef0bd --- /dev/null +++ b/app/portainer/react/views/gitops.ts @@ -0,0 +1,19 @@ +import angular from 'angular'; + +import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView'; +import { ListView as SourcesListView } from '@/react/portainer/gitops/sources/ListView/ListView'; +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; + +export const gitopsViewsModule = angular + .module('portainer.app.react.views.gitops', []) + + .component( + 'workflowsView', + r2a(withUIRouter(withCurrentUser(WorkflowsView)), []) + ) + .component( + 'sourcesListView', + r2a(withUIRouter(withCurrentUser(SourcesListView)), []) + ).name; diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts index 0a9c40aee6..566e51030d 100644 --- a/app/portainer/react/views/index.ts +++ b/app/portainer/react/views/index.ts @@ -10,7 +10,6 @@ import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeV import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel'; import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView'; import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView'; -import { WorkflowsView } from '@/react/portainer/gitops/WorkflowsView/WorkflowsView'; import { wizardModule } from './wizard'; import { teamsModule } from './teams'; @@ -21,6 +20,7 @@ import { activityLogsModule } from './activity-logs'; import { templatesModule } from './templates'; import { usersModule } from './users'; import { environmentsModule } from './environments'; +import { gitopsViewsModule } from './gitops'; export const viewsModule = angular .module('portainer.app.react.views', [ @@ -33,6 +33,7 @@ export const viewsModule = angular templatesModule, usersModule, environmentsModule, + gitopsViewsModule, ]) .component( 'homeView', @@ -67,8 +68,4 @@ export const viewsModule = angular withUIRouter(withReactQuery(withCurrentUser(CreateHelmRepositoriesView))), [] ) - ) - .component( - 'workflowsView', - r2a(withUIRouter(withReactQuery(withCurrentUser(WorkflowsView))), []) ).name; diff --git a/app/react/components/DropdownMenu/DropdownMenu.tsx b/app/react/components/DropdownMenu/DropdownMenu.tsx index 94618800f4..94db5e8548 100644 --- a/app/react/components/DropdownMenu/DropdownMenu.tsx +++ b/app/react/components/DropdownMenu/DropdownMenu.tsx @@ -23,6 +23,7 @@ interface Props { badge?: string | null; onClick?: () => void; className?: string; + 'aria-pressed'?: boolean; 'data-cy'?: string; } @@ -59,6 +60,7 @@ export function DropdownMenu({ badge, onClick, className, + 'aria-pressed': ariaPressed, 'data-cy': dataCy, }: Props) { return ( @@ -66,6 +68,7 @@ export function DropdownMenu({ onClick?.()} + aria-pressed={ariaPressed} data-cy={dataCy} > {label} diff --git a/app/react/components/FilterBarActiveIndicator.tsx b/app/react/components/FilterBarActiveIndicator.tsx deleted file mode 100644 index 0a829bb2d6..0000000000 --- a/app/react/components/FilterBarActiveIndicator.tsx +++ /dev/null @@ -1,29 +0,0 @@ -interface Props { - label: string; - onClear: () => void; -} - -export function FilterBarActiveIndicator({ label, onClear }: Props) { - return ( -
- - Showing:{' '} - - {label} - - - -
- ); -} diff --git a/app/react/components/FilterBarButton.test.tsx b/app/react/components/FilterBarButton.test.tsx deleted file mode 100644 index 0d7fbd2558..0000000000 --- a/app/react/components/FilterBarButton.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { FilterBarButton } from './FilterBarButton'; - -function renderComponent( - props: Partial> = {} -) { - const defaultProps: React.ComponentProps = { - count: 5, - label: 'Running', - isSelected: false, - onClick: vi.fn(), - name: 'status-filter', - 'data-cy': 'filter-bar-button', - ...props, - }; - - return { - ...render(), - props: defaultProps, - }; -} - -describe('FilterBarButton', () => { - it('should render count and label, and call onClick when clicked', async () => { - const user = userEvent.setup(); - const onClick = vi.fn(); - renderComponent({ onClick, colorScheme: 'success' }); - - expect(screen.getByText('5')).toBeVisible(); - expect(screen.getByText('Running')).toBeVisible(); - expect( - screen.getByRole('radio', { name: /filter by running/i }) - ).toBeVisible(); - - await user.click(screen.getByText('Running')); - expect(onClick).toHaveBeenCalledOnce(); - }); - - it('should return null when count is 0', () => { - const { container } = renderComponent({ count: 0 }); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/app/react/components/FilterBarButton.tsx b/app/react/components/FilterBarButton.tsx deleted file mode 100644 index 9ff390a70c..0000000000 --- a/app/react/components/FilterBarButton.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import clsx from 'clsx'; - -import { AutomationTestingProps } from '@/types'; - -export type FilterBarColorScheme = - | 'success' - | 'error' - | 'warning' - | 'blue' - | 'gray'; - -const colorSchemeStyles: Record< - FilterBarColorScheme, - { dot: string; text: string } -> = { - success: { dot: 'bg-success-7', text: 'text-success-7' }, - error: { dot: 'bg-error-7', text: 'text-error-7' }, - warning: { dot: 'bg-warning-7', text: 'text-warning-7' }, - blue: { dot: 'bg-blue-7', text: 'text-blue-7' }, - gray: { dot: 'bg-gray-7', text: 'text-gray-7' }, -}; - -interface Props extends AutomationTestingProps { - count: number; - label: string; - isSelected: boolean; - onClick: () => void; - name: string; - colorScheme?: FilterBarColorScheme; -} - -export function FilterBarButton({ - count, - label, - isSelected, - onClick, - name, - colorScheme, - 'data-cy': dataCy, -}: Props) { - if (count === 0) { - return null; - } - - const colors = colorScheme ? colorSchemeStyles[colorScheme] : undefined; - return ( - - ); -} diff --git a/app/react/components/SortableList/SortByGroup.test.tsx b/app/react/components/SortableList/SortByGroup.test.tsx index 339030f247..7b22f2bfcb 100644 --- a/app/react/components/SortableList/SortByGroup.test.tsx +++ b/app/react/components/SortableList/SortByGroup.test.tsx @@ -1,11 +1,3 @@ -import { - useState, - useEffect, - useRef, - useContext, - createContext, - ReactNode, -} from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -14,108 +6,7 @@ import { withTestRouter } from '@/react/test-utils/withRouter'; import { SortByGroup, SortOption } from './SortByGroup'; -type MenuCtxType = { - isOpen: boolean; - setOpen: (v: boolean) => void; - menuRef: React.RefObject; -}; - -vi.mock('@reach/menu-button', () => { - const MenuCtx = createContext(null); - - function Menu({ children }: { children?: ReactNode }) { - const [isOpen, setOpen] = useState(false); - const menuRef = useRef(null); - - useEffect(() => { - function handleDocDown(e: MouseEvent) { - const target = e.target as Node | null; - if ( - isOpen && - menuRef.current && - target && - !menuRef.current.contains(target) - ) { - setOpen(false); - } - } - - document.addEventListener('mousedown', handleDocDown); - return () => document.removeEventListener('mousedown', handleDocDown); - }, [isOpen]); - - return ( - -
{children}
-
- ); - } - - function MenuButton({ - children, - onClick: externalOnClick, - ...props - }: { - children?: ReactNode; - onClick?: () => void; - [key: string]: unknown; - }) { - const ctx = useContext(MenuCtx); - - function handleClick() { - externalOnClick?.(); - ctx?.setOpen(!ctx.isOpen); - } - - return ( - - ); - } - - function MenuList({ - children, - className, - }: { - children?: ReactNode; - className?: string; - }) { - const ctx = useContext(MenuCtx); - if (!ctx?.isOpen) return null; - return ( -
- {children} -
- ); - } - - function MenuItem({ - children, - onSelect, - className, - }: { - children?: ReactNode; - onSelect?: () => void; - className?: string; - }) { - const ctx = useContext(MenuCtx); - - function handleClick() { - onSelect?.(); - ctx?.setOpen(false); - } - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus -
- {children} -
- ); - } - - return { Menu, MenuButton, MenuList, MenuItem }; -}); +vi.mock('@reach/menu-button'); const sortOptions: SortOption[] = [ { key: 'Group', label: 'Group', grouped: true }, @@ -135,105 +26,106 @@ const groupOptions = { }; function renderComponent({ - activeKey = 'Group' as string, - groupFilter = null as string | null, - onSortChange = vi.fn(), - onGroupFilterChange = vi.fn(), + group = 'Group' as string, + groupValue = null as string | null, + onChange = vi.fn(), } = {}) { const Wrapped = withTestQueryProvider( withTestRouter(() => ( )) ); - return { ...render(), onSortChange, onGroupFilterChange }; + return { ...render(), onChange }; } describe('SortByGroup', () => { describe('grouped: false option', () => { - test('clicking an inactive button calls onSortChange', async () => { + test('clicking an inactive button calls onChange with { group, groupValue: null }', async () => { const user = userEvent.setup(); - const { onSortChange } = renderComponent({ activeKey: 'Group' }); + const { onChange } = renderComponent({ group: 'Group' }); await user.click(screen.getByRole('button', { name: /^Name$/i })); - expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name'); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + group: 'Name', + groupValue: null, + }); }); - test('clicking the already-active non-grouped button calls onSortChange to toggle sort order', async () => { + test('clicking the already-active button does calls onChange', async () => { const user = userEvent.setup(); - const { onSortChange } = renderComponent({ activeKey: 'Name' }); + const { onChange } = renderComponent({ group: 'Name' }); await user.click(screen.getByRole('button', { name: /^Name Asc/i })); - expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name'); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + group: 'Name', + groupValue: null, + }); }); }); describe('grouped: true option', () => { - test('clicking the dropdown button to open it does not call onSortChange', async () => { + test('clicking the dropdown button to open it does not call onChange', async () => { const user = userEvent.setup(); - const { onSortChange } = renderComponent({ activeKey: 'Group' }); + const { onChange } = renderComponent({ group: 'Group' }); - // Click Platform button (inactive grouped option) — should just open menu await user.click(screen.getByRole('button', { name: /^Platform$/i })); - expect(onSortChange).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('menu')).toBeVisible(); }); - test('selecting a filter from an inactive grouped option calls onGroupFilterChange with the group key and value', async () => { + test('selecting a filter from an inactive grouped option calls onChange with both keys', async () => { const user = userEvent.setup(); - const { onSortChange, onGroupFilterChange } = renderComponent({ - activeKey: 'Group', - }); + const { onChange } = renderComponent({ group: 'Group' }); await user.click(screen.getByRole('button', { name: /^Platform$/i })); await user.click(screen.getByRole('menuitem', { name: /Docker/ })); - expect(onSortChange).not.toHaveBeenCalled(); - expect(onGroupFilterChange).toHaveBeenCalledExactlyOnceWith( - 'Platform', - 'Docker' - ); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + group: 'Platform', + groupValue: 'Docker', + }); }); - test('selecting a filter from the already-active grouped option does not call onSortChange', async () => { + test('selecting a filter from the already-active grouped option calls onChange', async () => { const user = userEvent.setup(); - const { onSortChange, onGroupFilterChange } = renderComponent({ - activeKey: 'Group', - groupFilter: null, + const { onChange } = renderComponent({ + group: 'Group', + groupValue: null, }); await user.click(screen.getByRole('button', { name: /^Group$/i })); await user.click(screen.getByRole('menuitem', { name: /GroupA/ })); - expect(onSortChange).not.toHaveBeenCalled(); - expect(onGroupFilterChange).toHaveBeenCalledExactlyOnceWith( - 'Group', - 'GroupA' - ); + expect(onChange).toHaveBeenCalledExactlyOnceWith({ + group: 'Group', + groupValue: 'GroupA', + }); }); - test('selecting All from a grouped dropdown calls onGroupFilterChange with null', async () => { + test('selecting All from a grouped dropdown calls onChange with (key, null)', async () => { const user = userEvent.setup(); - const { onSortChange, onGroupFilterChange } = renderComponent({ - activeKey: 'Group', - groupFilter: 'GroupA', + const { onChange } = renderComponent({ + group: 'Group', + groupValue: 'GroupA', }); await user.click(screen.getByRole('button', { name: /^Group/i })); await user.click(screen.getByRole('menuitem', { name: /^All$/ })); - expect(onSortChange).not.toHaveBeenCalled(); - expect(onGroupFilterChange).toHaveBeenCalledWith('Group', null); + expect(onChange).toHaveBeenCalledWith({ + group: 'Group', + groupValue: null, + }); }); }); }); diff --git a/app/react/components/SortableList/SortByGroup.tsx b/app/react/components/SortableList/SortByGroup.tsx index d3cd5c608a..581d7286aa 100644 --- a/app/react/components/SortableList/SortByGroup.tsx +++ b/app/react/components/SortableList/SortByGroup.tsx @@ -10,25 +10,26 @@ export interface SortOption { ascendingLabel?: string; } +type Value = { + group: TSortKey; + groupValue: string | null; +}; + export interface SortByGroupProps { - activeKey: TSortKey; + value: Value; sortDesc: boolean; - onSortChange: (key: TSortKey) => void; + onChange: (value: Value) => void; sortOptions: SortOption[]; - groupFilter: string | null; groupOptions?: Record; - onGroupFilterChange: (group: string | null, filter: string | null) => void; dataCy?: string; } export function SortByGroup({ - activeKey, + value, sortDesc, - onSortChange, + onChange, sortOptions, - groupFilter, groupOptions, - onGroupFilterChange, dataCy, }: SortByGroupProps) { return ( @@ -52,14 +53,13 @@ export function SortByGroup({ onChange(value)} groupOptions={groupOptions} - onGroupFilterChange={onGroupFilterChange} dataCy={dataCy} /> ))} @@ -90,10 +90,9 @@ interface SortOptionItemProps { sortDesc: boolean; isFirst: boolean; isLast: boolean; - onSortChange: (key: TSortKey) => void; - groupFilter: string | null; + value: Value; + onChange: (value: Value) => void; groupOptions?: Record; - onGroupFilterChange: (group: string | null, filter: string | null) => void; dataCy?: string; } @@ -103,10 +102,9 @@ function SortOptionItem({ sortDesc, isFirst, isLast, - onSortChange, - groupFilter, + value, + onChange, groupOptions, - onGroupFilterChange, dataCy, }: SortOptionItemProps) { const className = clsx( @@ -121,16 +119,17 @@ function SortOptionItem({ { - onGroupFilterChange(option.key, value); + selected={isActive ? value.groupValue ?? null : null} + onSelect={(selected) => { + onChange({ group: option.key, groupValue: selected }); }} badge={ isActive - ? getFilterBadge(groupOptions, option.key, groupFilter) + ? getFilterBadge(groupOptions, option.key, value.groupValue ?? null) : undefined } className={className} + aria-pressed={isActive} data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`} /> ); @@ -146,8 +145,9 @@ function SortOptionItem({