feat(gitops): introduce sources list view [BE-12902] (#2550)

This commit is contained in:
Chaim Lev-Ari
2026-05-12 15:32:46 +03:00
committed by GitHub
parent edff47fd41
commit d9a415f011
52 changed files with 2655 additions and 995 deletions
+118
View File
@@ -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<HTMLDivElement>;
label: string;
setLabel: (v: string) => void;
};
const MenuCtx = createContext<MenuCtxType | null>(null);
export function Menu({ children }: { children?: ReactNode }) {
const [isOpen, setOpen] = useState(false);
const [label, setLabel] = useState('');
const menuRef = useRef<HTMLDivElement>(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 (
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef, label, setLabel }}>
<div ref={menuRef}>{children}</div>
</MenuCtx.Provider>
);
}
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 (
<button type="button" onClick={handleClick} {...props}>
{children}
</button>
);
}
export function MenuList({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return (
<div role="menu" aria-label={ctx.label || undefined} className={className}>
{children}
</div>
);
}
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
<div role="menuitem" onClick={handleClick} className={className}>
{children}
</div>
);
}
+99
View File
@@ -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 "", ""
}
@@ -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
}
+289
View File
@@ -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)
}
+15 -2
View File
@@ -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,
+5 -4
View File
@@ -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.
+4
View File
@@ -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
}
@@ -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
}
+163
View File
@@ -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
}
@@ -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)
}
+70
View File
@@ -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
}
@@ -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)
}
@@ -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 "", ""
}
+1 -113
View File
@@ -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 {
+26 -3
View File
@@ -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);
+19
View File
@@ -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;
+2 -5
View File
@@ -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;
@@ -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({
<ReachMenuButton
className={clsx('group flex gap-1', className)}
onClick={() => onClick?.()}
aria-pressed={ariaPressed}
data-cy={dataCy}
>
{label}
@@ -1,29 +0,0 @@
interface Props {
label: string;
onClear: () => void;
}
export function FilterBarActiveIndicator({ label, onClear }: Props) {
return (
<div
className="flex items-center gap-4 whitespace-nowrap bg-[var(--bg-blocklist-hover-color)] px-5"
data-cy="active-filter-indicator"
>
<span className="text-sm text-[var(--text-muted-color)]">
Showing:{' '}
<span className="font-semibold text-[var(--text-summary-color)]">
{label}
</span>
</span>
<button
type="button"
className="close-button cursor-pointer border-0 bg-transparent p-0 text-[21px] font-bold leading-none text-[var(--button-close-color)] opacity-[var(--button-opacity)] hover:opacity-[var(--button-opacity-hover)]"
onClick={onClear}
aria-label="Clear filter"
data-cy="clear-filter-button"
>
&times;
</button>
</div>
);
}
@@ -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<React.ComponentProps<typeof FilterBarButton>> = {}
) {
const defaultProps: React.ComponentProps<typeof FilterBarButton> = {
count: 5,
label: 'Running',
isSelected: false,
onClick: vi.fn(),
name: 'status-filter',
'data-cy': 'filter-bar-button',
...props,
};
return {
...render(<FilterBarButton {...defaultProps} />),
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();
});
});
-107
View File
@@ -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 (
<label
className={clsx(
'relative mb-0 flex items-center gap-2',
'px-8 py-3',
'cursor-pointer border-0',
'text-sm font-medium',
'text-[var(--text-muted-color)]',
'hover:bg-[var(--bg-blocklist-item-selected-color)]',
'transition-colors duration-150',
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-blue-5 has-[:focus-visible]:ring-inset',
isSelected && 'bg-[var(--bg-blocklist-item-selected-color)]'
)}
data-cy={dataCy}
>
<input
type="radio"
className="sr-only"
name={name}
value={label}
checked={isSelected}
onClick={onClick}
readOnly
aria-label={`Filter by ${label}`}
tabIndex={0}
/>
{colors && (
<span
className={clsx('h-2.5 w-2.5 shrink-0 rounded-full', colors.dot)}
aria-hidden="true"
/>
)}
{colors && (
<span className="flex flex-col leading-tight">
<span className={clsx('text-2xl font-bold', colors.text)}>
{count}
</span>
<span className="text-xs uppercase tracking-wide text-[var(--text-muted-color)]">
{label}
</span>
</span>
)}
{!colors && (
<span className="flex items-baseline gap-2">
<span className="text-2xl font-bold">{count}</span>
<span className="text-base uppercase tracking-wide text-[var(--text-muted-color)]">
{label}
</span>
</span>
)}
{isSelected && (
<span
className={clsx(
'absolute bottom-0 left-0 right-0 h-1',
colors?.dot || 'bg-blue-7'
)}
aria-hidden="true"
/>
)}
</label>
);
}
@@ -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<HTMLDivElement>;
};
vi.mock('@reach/menu-button', () => {
const MenuCtx = createContext<MenuCtxType | null>(null);
function Menu({ children }: { children?: ReactNode }) {
const [isOpen, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(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 (
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef }}>
<div ref={menuRef}>{children}</div>
</MenuCtx.Provider>
);
}
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 (
<button type="button" onClick={handleClick} {...props}>
{children}
</button>
);
}
function MenuList({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const ctx = useContext(MenuCtx);
if (!ctx?.isOpen) return null;
return (
<div role="menu" className={className}>
{children}
</div>
);
}
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
<div role="menuitem" onClick={handleClick} className={className}>
{children}
</div>
);
}
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(() => (
<SortByGroup
activeKey={activeKey}
sortDesc={false}
onSortChange={onSortChange}
value={{ group, groupValue }}
onChange={onChange}
sortOptions={sortOptions}
groupFilter={groupFilter}
groupOptions={groupOptions}
onGroupFilterChange={onGroupFilterChange}
sortDesc={false}
dataCy="test"
/>
))
);
return { ...render(<Wrapped />), onSortChange, onGroupFilterChange };
return { ...render(<Wrapped />), 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,
});
});
});
});
@@ -10,25 +10,26 @@ export interface SortOption<TSortKey extends string = string> {
ascendingLabel?: string;
}
type Value<TSortKey> = {
group: TSortKey;
groupValue: string | null;
};
export interface SortByGroupProps<TSortKey extends string> {
activeKey: TSortKey;
value: Value<TSortKey>;
sortDesc: boolean;
onSortChange: (key: TSortKey) => void;
onChange: (value: Value<TSortKey>) => void;
sortOptions: SortOption<TSortKey>[];
groupFilter: string | null;
groupOptions?: Record<string, DropdownOption[]>;
onGroupFilterChange: (group: string | null, filter: string | null) => void;
dataCy?: string;
}
export function SortByGroup<TSortKey extends string>({
activeKey,
value,
sortDesc,
onSortChange,
onChange,
sortOptions,
groupFilter,
groupOptions,
onGroupFilterChange,
dataCy,
}: SortByGroupProps<TSortKey>) {
return (
@@ -52,14 +53,13 @@ export function SortByGroup<TSortKey extends string>({
<SortOptionItem
key={option.key}
option={option}
isActive={activeKey === option.key}
isActive={value.group === option.key}
sortDesc={sortDesc}
isFirst={index === 0}
isLast={index === sortOptions.length - 1}
onSortChange={onSortChange}
groupFilter={groupFilter}
value={value}
onChange={(value) => onChange(value)}
groupOptions={groupOptions}
onGroupFilterChange={onGroupFilterChange}
dataCy={dataCy}
/>
))}
@@ -90,10 +90,9 @@ interface SortOptionItemProps<TSortKey extends string> {
sortDesc: boolean;
isFirst: boolean;
isLast: boolean;
onSortChange: (key: TSortKey) => void;
groupFilter: string | null;
value: Value<TSortKey>;
onChange: (value: Value<TSortKey>) => void;
groupOptions?: Record<string, DropdownOption[]>;
onGroupFilterChange: (group: string | null, filter: string | null) => void;
dataCy?: string;
}
@@ -103,10 +102,9 @@ function SortOptionItem<TSortKey extends string>({
sortDesc,
isFirst,
isLast,
onSortChange,
groupFilter,
value,
onChange,
groupOptions,
onGroupFilterChange,
dataCy,
}: SortOptionItemProps<TSortKey>) {
const className = clsx(
@@ -121,16 +119,17 @@ function SortOptionItem<TSortKey extends string>({
<DropdownMenu
label={option.label}
options={groupOptions?.[option.key]}
selected={groupFilter}
onSelect={(value) => {
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<TSortKey extends string>({
<button
type="button"
className={className}
aria-pressed={isActive}
onClick={() => {
onSortChange(option.key);
onChange({ group: option.key, groupValue: null });
}}
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
>
@@ -3,12 +3,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import {
SortableList,
SortableListState,
SortableGroup,
computeSortDesc,
} from './SortableList';
import { SortableList, SortableListState, SortableGroup } from './SortableList';
interface Item {
id: number;
@@ -40,14 +35,17 @@ describe('SortableList', () => {
expect(screen.getByText('Theta')).toBeInTheDocument();
});
it('calls setSortBy when a sort option is clicked', async () => {
it('calls setGroupFilter when a sort option is clicked', async () => {
const user = userEvent.setup();
const setSortBy = vi.fn();
renderList({ state: { setSortBy } });
const setGroupFilter = vi.fn();
renderList({ state: { setGroupFilter } });
await user.click(screen.getByText('Status'));
expect(setSortBy).toHaveBeenCalledWith('status', false);
expect(setGroupFilter).toHaveBeenCalledWith({
group: 'status',
groupValue: null,
});
});
it('shows group headers when showGroupHeaders=true and multiple groups', () => {
@@ -243,33 +241,6 @@ describe('SortableList', () => {
});
});
describe('computeSortDesc', () => {
it('flips ascending to descending when the same key is active', () => {
expect(computeSortDesc('name', 'name', false)).toBe(true);
});
it('flips descending to ascending when the same key is active', () => {
expect(computeSortDesc('name', 'name', true)).toBe(false);
});
it('resets to ascending when a different key is clicked (was ascending)', () => {
expect(computeSortDesc('status', 'name', false)).toBe(false);
});
it('resets to ascending when a different key is clicked (was descending)', () => {
expect(computeSortDesc('status', 'name', true)).toBe(false);
});
it('resets to ascending when a group key is active and a sort key is clicked', () => {
// groupBy is active so activeKey is the group option key, not the sort key
expect(computeSortDesc('name', 'Health', false)).toBe(false);
});
it('resets to ascending when there is no active key', () => {
expect(computeSortDesc('name', '', false)).toBe(false);
});
});
function renderList({
state = createMockState(),
groups = makeGroups(ITEMS),
@@ -51,35 +51,27 @@ export function SortableList<T>({
isLoading = false,
'data-cy': dataCy,
}: Props<T>) {
const rawSortKey = tableState.sortBy?.id ?? '';
const activeSortKey =
sortOptions
.filter((o) => !o.grouped)
.find((opt) => opt.key.toLowerCase() === rawSortKey.toLowerCase())?.key ??
null;
const rawGroupKey = tableState.groupBy ?? '';
const activeGroupKey =
sortOptions
.filter((o) => o.grouped)
.find((opt) => opt.key.toLowerCase() === rawGroupKey.toLowerCase())
?.key ?? null;
const activeKey = activeGroupKey ?? activeSortKey ?? '';
const activeKey = getSortKey(
sortOptions,
tableState.groupBy ?? tableState.sortBy?.id ?? ''
);
return (
<SortableListCard>
<SortableListHeader
activeKey={activeKey}
sortDesc={tableState.sortBy?.desc ?? false}
onSortChange={(key) => {
tableState.setSortBy(
key,
computeSortDesc(key, activeKey, tableState.sortBy?.desc ?? false)
);
value={{
group: activeKey,
groupValue: tableState.groupFilter,
}}
onChange={({ group, groupValue }) => {
tableState.setGroupFilter({
group,
groupValue,
});
}}
sortDesc={tableState.sortBy?.desc ?? false}
searchTerm={tableState.search}
onSearchChange={tableState.setSearch}
groupFilter={tableState.groupFilter}
onGroupFilterChange={tableState.setGroupFilter}
groupOptions={groupOptions}
sortOptions={sortOptions}
searchPlaceholder={searchPlaceholder}
@@ -114,10 +106,14 @@ export function SortableList<T>({
);
}
export function computeSortDesc(
key: string,
activeKey: string,
currentDesc: boolean
): boolean {
return activeKey === key ? !currentDesc : false;
function getSortKey(sortOptions: SortOption[], sortKey: string | undefined) {
if (!sortKey) {
return '';
}
const sortOption = sortOptions.find(
(opt) => opt.key.toLowerCase() === sortKey.toLowerCase()
);
return sortOption?.key ?? '';
}
@@ -28,27 +28,29 @@ const sortOptions = [
type SortKey = (typeof sortOptions)[number]['key'];
export function Interactive() {
const [activeKey, setActiveKey] = useState<SortKey>('name');
const [sortDesc, setSortDesc] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [groupFilter, setGroupFilter] = useState<string | null>(null);
function handleSortChange(key: string) {
setSortDesc((prev) => (activeKey === key ? !prev : false));
setActiveKey(key as SortKey);
}
const [value, setValue] = useState<{
group: SortKey;
groupValue: string | null;
}>({
group: 'name',
groupValue: null,
});
const [searchTerm, setSearchTerm] = useState('');
return (
<SortableListHeader
activeKey={activeKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
value={value}
onChange={(newValue) => {
setSortDesc((prev) => (value.group === newValue.group ? !prev : false));
setValue(newValue);
}}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
groupOptions={groupOptions}
groupFilter={groupFilter}
onGroupFilterChange={(_group, value) => setGroupFilter(value)}
searchPlaceholder="Search environments..."
data-cy="group-sort"
/>
@@ -56,26 +58,28 @@ export function Interactive() {
}
export function WithGroupFilter() {
const [activeKey, setActiveKey] = useState<SortKey>('group');
const [sortDesc, setSortDesc] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
function handleSortChange(key: string) {
setSortDesc((prev) => (activeKey === key ? !prev : false));
setActiveKey(key as SortKey);
}
const [value, setValue] = useState<{
group: SortKey;
groupValue: string | null;
}>({
group: 'group',
groupValue: 'Production',
});
return (
<SortableListHeader
activeKey={activeKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
value={value}
onChange={(newValue) => {
setSortDesc((prev) => (value.group === newValue.group ? !prev : false));
setValue(newValue);
}}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
groupOptions={groupOptions}
groupFilter="Production"
onGroupFilterChange={() => {}}
searchPlaceholder="Search environments..."
data-cy="group-sort"
/>
@@ -83,27 +87,28 @@ export function WithGroupFilter() {
}
export function WithActionButton() {
const [activeKey, setActiveKey] = useState<SortKey>('name');
const [sortDesc, setSortDesc] = useState(false);
const [value, setValue] = useState<{
group: SortKey;
groupValue: string | null;
}>({
group: 'name',
groupValue: null,
});
const [searchTerm, setSearchTerm] = useState('');
const [groupFilter, setGroupFilter] = useState<string | null>(null);
function handleSortChange(key: string) {
setSortDesc((prev) => (activeKey === key ? !prev : false));
setActiveKey(key as SortKey);
}
return (
<SortableListHeader
activeKey={activeKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
value={value}
onChange={(newValue) => {
setSortDesc((prev) => (value.group === newValue.group ? !prev : false));
setValue(newValue);
}}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
groupOptions={groupOptions}
groupFilter={groupFilter}
onGroupFilterChange={(_group, value) => setGroupFilter(value)}
actionButton={
<button
type="button"
@@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event';
import { SortableListHeader } from './SortableListHeader';
vi.mock('@reach/menu-button');
const defaultSortOptions = [
{ key: 'Group' as const, label: 'Group', grouped: true },
{ key: 'Platform' as const, label: 'Platform', grouped: true },
@@ -20,15 +22,13 @@ function renderHeader(
> = {}
) {
const props = {
activeKey: 'Group' as string,
sortDesc: false,
onSortChange: vi.fn(),
value: { group: 'Group' as string, groupValue: null as string | null },
onChange: vi.fn(),
searchTerm: '',
onSearchChange: vi.fn(),
sortOptions: defaultSortOptions,
groupFilter: null,
groupOptions: { Group: defaultGroups, Platform: defaultGroups },
onGroupFilterChange: vi.fn(),
sortDesc: false,
'data-cy': 'cy',
...overrides,
};
@@ -42,7 +42,7 @@ function renderHeader(
describe('GroupSortTableHeader', () => {
test('clicking the active sort button opens the dropdown with group options', async () => {
const user = userEvent.setup();
renderHeader({ activeKey: 'Group' });
renderHeader();
const groupBtn = screen.getByRole('button', { name: /Group/i });
await user.click(groupBtn);
@@ -53,21 +53,21 @@ describe('GroupSortTableHeader', () => {
expect(screen.getByRole('menuitem', { name: /Kubernetes/ })).toBeVisible();
});
test('clicking an inactive grouped sort button opens the dropdown without calling onSortChange', async () => {
test('clicking an inactive grouped sort button opens the dropdown without calling onChange', async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
renderHeader({ activeKey: 'Group', onSortChange });
const onChange = vi.fn();
renderHeader({ onChange, value: { group: 'Group', groupValue: null } });
await user.click(screen.getByRole('button', { name: /Platform/i }));
expect(onSortChange).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByRole('menu', { name: /Platform/i })).toBeVisible();
});
test('dropdown shows group options when opened', async () => {
const user = userEvent.setup();
renderHeader({
activeKey: 'Group',
value: { group: 'Group', groupValue: null },
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
});
@@ -79,7 +79,7 @@ describe('GroupSortTableHeader', () => {
});
test('active group filter is shown as a badge inside the active sort button', () => {
renderHeader({ activeKey: 'Group', groupFilter: 'Docker' });
renderHeader({ value: { group: 'Group', groupValue: 'Docker' } });
const groupBtn = screen.getByRole('button', { name: /Group/i });
expect(groupBtn).toHaveTextContent('Docker');
@@ -87,7 +87,7 @@ describe('GroupSortTableHeader', () => {
test('clicking outside the dropdown closes it', async () => {
const user = userEvent.setup();
renderHeader({ activeKey: 'Group' });
renderHeader({ value: { group: 'Group', groupValue: null } });
await user.click(screen.getByRole('button', { name: /Group/i }));
expect(screen.getByRole('menu', { name: /Group/i })).toBeVisible();
@@ -133,8 +133,7 @@ describe('GroupSortTableHeader', () => {
test('All option is present in dropdown menu', async () => {
const user = userEvent.setup();
renderHeader({
activeKey: 'Group',
groupFilter: 'Docker',
value: { group: 'Group', groupValue: 'Docker' },
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
});
@@ -146,7 +145,7 @@ describe('GroupSortTableHeader', () => {
test('displays group counts in dropdown', async () => {
const user = userEvent.setup();
renderHeader({
activeKey: 'Group',
value: { group: 'Group', groupValue: null },
sortOptions: [{ key: 'Group' as const, label: 'Group', grouped: true }],
});
@@ -166,4 +165,35 @@ describe('GroupSortTableHeader', () => {
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Health/i })).toBeInTheDocument();
});
test('selecting a group option calls onChange with the sort key and selected value', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderHeader({ onChange });
await user.click(screen.getByRole('button', { name: /Group/i }));
await user.click(screen.getByRole('menuitem', { name: /Docker/ }));
expect(onChange).toHaveBeenCalledWith({
group: 'Group',
groupValue: 'Docker',
});
});
test('selecting All calls onChange with the sort key and null', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderHeader({
value: { group: 'Group', groupValue: 'Docker' },
onChange,
});
await user.click(screen.getByRole('button', { name: /Group/i }));
await user.click(screen.getByRole('menuitem', { name: /All/ }));
expect(onChange).toHaveBeenCalledWith({
group: 'Group',
groupValue: null,
});
});
});
@@ -12,32 +12,28 @@ import { SortByGroup, SortOption } from './SortByGroup';
export type { SortOption };
interface Props<TSortKey extends string> {
activeKey: TSortKey;
sortDesc: boolean;
onSortChange: (key: TSortKey) => void;
value: { group: TSortKey; groupValue: string | null };
onChange: (value: { group: TSortKey; groupValue: string | null }) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortOptions: SortOption<TSortKey>[];
searchPlaceholder?: string;
actionButton?: ReactNode;
groupFilter: string | null;
groupOptions?: Record<string, DropdownOption[]>;
onGroupFilterChange: (group: string | null, filter: string | null) => void;
headerButtons?: ReactNode;
}
export function SortableListHeader<TSortKey extends string>({
activeKey,
sortDesc,
onSortChange,
value,
onChange,
searchTerm,
onSearchChange,
sortOptions,
searchPlaceholder = 'Filter...',
actionButton,
groupFilter,
groupOptions,
onGroupFilterChange,
headerButtons,
'data-cy': dataCy,
}: Props<TSortKey> & AutomationTestingProps) {
@@ -49,14 +45,12 @@ export function SortableListHeader<TSortKey extends string>({
)}
>
<SortByGroup
activeKey={activeKey}
value={value}
sortDesc={sortDesc}
onSortChange={onSortChange}
onChange={onChange}
sortOptions={sortOptions}
groupFilter={groupFilter}
groupOptions={groupOptions}
onGroupFilterChange={onGroupFilterChange}
data-cy={`${dataCy}-sort`}
dataCy={`${dataCy}-sort`}
/>
<div className="ml-auto flex items-center gap-2">
{headerButtons}
@@ -0,0 +1,356 @@
// This story demonstrates how StatusSummaryBar and SortableList integrate:
// the summary bar filter and the group-sort dimension filter share state so
// that selecting a status from either control keeps both in sync.
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { userEvent, within, expect } from 'storybook/test';
import { useState } from 'react';
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
import {
StatusSummaryBar,
StatusSegment,
} from '@@/StatusSummaryBar/StatusSummaryBar';
import { SortableList, SortableGroup, SortableListState } from './SortableList';
type Item = {
id: number;
name: string;
status: string;
type: string;
platform: string;
};
const ITEMS: Item[] = [
{ id: 1, name: 'Alpha', status: 'error', type: 'stack', platform: 'linux' },
{ id: 2, name: 'Beta', status: 'healthy', type: 'stack', platform: 'linux' },
{ id: 3, name: 'Gamma', status: 'error', type: 'edge', platform: 'windows' },
{
id: 4,
name: 'Delta',
status: 'healthy',
type: 'edge',
platform: 'windows',
},
{ id: 5, name: 'Eta', status: 'outdated', type: 'stack', platform: 'mac' },
];
const SEGMENTS: StatusSegment<string>[] = [
{ key: 'error', label: 'Error', count: 2, color: 'error' },
{ key: 'healthy', label: 'Healthy', count: 2, color: 'success' },
{ key: 'outdated', label: 'Outdated', count: 1, color: 'warning' },
];
const SORT_OPTIONS = [
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status', grouped: true },
{ key: 'type', label: 'Type', grouped: true },
{ key: 'platform', label: 'Platform', grouped: true },
];
const GROUP_OPTIONS: Record<string, Array<{ key: string; label: string }>> = {
status: SEGMENTS,
type: [
{ key: 'stack', label: 'Stack' },
{ key: 'edge', label: 'Edge' },
],
platform: [
{ key: 'linux', label: 'Linux' },
{ key: 'windows', label: 'Windows' },
{ key: 'mac', label: 'Mac' },
],
};
const SORT_KEYS = ['name', 'status', 'type', 'platform'] as const;
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }, { key: 'platform' }];
type Params = {
sort: string;
order: 'asc' | 'desc';
status: string | null;
type: string | null;
platform: string | null;
page: number;
pageSize: number;
search: string;
};
function buildGroups(items: Item[], sortBy: string): SortableGroup<Item>[] {
const options = GROUP_OPTIONS[sortBy];
if (!options) {
return items.length > 0 ? [{ key: 'all', label: 'All', items }] : [];
}
return options
.map(({ key, label }) => ({
key,
label,
items: items.filter((item) => item[sortBy as keyof Item] === key),
}))
.filter((g) => g.items.length > 0);
}
function Demo() {
const [params, setParams] = useState<Params>({
sort: 'name',
order: 'asc',
status: null,
type: null,
platform: null,
page: 0,
pageSize: 10,
search: '',
});
function patchParams(update: Record<string, unknown>) {
setParams((prev) => {
const next = { ...prev } as Record<string, unknown>;
for (const [k, v] of Object.entries(update)) {
next[k] = v ?? null;
}
return next as Params;
});
}
const { groupFilter, setGroupFilter } = buildGroupSortExtras({
urlState: params,
setUrlState: patchParams,
defaultSort: 'name',
sortKeys: SORT_KEYS,
dimensions: DIMENSIONS,
});
const isDimension = DIMENSIONS.some((d) => d.key === params.sort);
const tableState: SortableListState = {
sortBy: { id: params.sort, desc: params.order === 'desc' },
setSortBy: (id, desc) =>
patchParams({
sort: id ?? 'name',
order: desc ? 'desc' : 'asc',
page: 0,
}),
pageSize: params.pageSize,
setPageSize: (pageSize) => patchParams({ pageSize, page: 0 }),
page: params.page,
setPage: (page) => patchParams({ page }),
groupBy: isDimension ? params.sort : null,
setGroupBy: (groupBy) => patchParams({ sort: groupBy ?? 'name' }),
groupFilter,
setGroupFilter,
search: params.search,
setSearch: (search) => patchParams({ search, page: 0 }),
};
const filterValue = params.status;
function setFilter(v: string | null) {
patchParams({ status: v, page: 0 });
}
// Apply filters: summary-bar status filter + active dimension group filter
let filteredItems = ITEMS;
if (filterValue != null) {
filteredItems = filteredItems.filter((i) => i.status === filterValue);
}
const dimKey = isDimension && params.sort !== 'status' ? params.sort : null;
const dimFilter = dimKey
? (params[dimKey as keyof Params] as string | null)
: null;
if (dimKey && dimFilter != null) {
filteredItems = filteredItems.filter(
(i) => i[dimKey as keyof Item] === dimFilter
);
}
return (
<div className="flex flex-col gap-4">
<StatusSummaryBar
total={ITEMS.length}
segments={SEGMENTS}
value={filterValue}
onChange={setFilter}
data-cy="status-bar"
/>
<SortableList
tableState={tableState}
sortOptions={SORT_OPTIONS}
groupOptions={GROUP_OPTIONS}
groups={buildGroups(filteredItems, params.sort)}
totalCount={filteredItems.length}
renderItem={(item) => (
<div className="px-4 py-2 text-sm">{item.name}</div>
)}
showGroupHeaders
data-cy="list"
/>
</div>
);
}
const meta = {
title: 'Design System/SortableList/StatusFilteredList',
component: Demo,
parameters: { layout: 'padded' },
} satisfies Meta<typeof Demo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
// Click "Error" in summary bar → filter=error, sort=Name unchanged
export const SummaryBarFiltersToError: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSummaryBarOption(canvas, 'error');
expectFilterChecked(canvas, 'error');
expectSortActive(canvas, 'Name');
},
};
// Click "Error" again (active) → filter clears back to total
export const SummaryBarTogglesOff: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSummaryBarOption(canvas, 'error');
await clickSummaryBarOption(canvas, 'error');
expectFilterChecked(canvas, 'total');
},
};
// Summary bar active, click sort "Status" → status filter clears, sort=Status
export const SortStatusClearsSummaryBarFilter: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSummaryBarOption(canvas, 'error');
await clickSortOption(canvas, 'Status', 'All');
expectFilterChecked(canvas, 'total');
expectSortActive(canvas, 'Status');
},
};
// Summary bar active, click sort "Type" → status filter persists, sort=Type
export const SortTypePersistsSummaryBarFilter: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSummaryBarOption(canvas, 'error');
await clickSortOption(canvas, 'Type', 'Stack');
expectFilterChecked(canvas, 'error');
expectSortActive(canvas, 'Type');
},
};
// Switching to a non-grouped sort clears the group-set filter
export const SwitchToNameClearsGroupFilter: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSortOption(canvas, 'Status', 'Error');
expectFilterChecked(canvas, 'error');
await clickSortOption(canvas, 'Name');
expectFilterChecked(canvas, 'total');
expectSortActive(canvas, 'Name');
},
};
// Click "Error" in Status group sort → sort=Status, summary bar shows "Error"
export const StatusGroupFilterSyncsSummaryBar: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSortOption(canvas, 'Status', 'Error');
expectFilterChecked(canvas, 'error');
expectSortActive(canvas, 'Status');
},
};
// Summary bar filter persists when switching to a different dimension
export const SummaryBarFilterPersistsAcrossDimensions: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSummaryBarOption(canvas, 'error');
await clickSortOption(canvas, 'Type', 'Stack');
expectFilterChecked(canvas, 'error');
expectSortActive(canvas, 'Type');
},
};
// Second group switch clears the old dimension, summary bar filter persists
export const SecondGroupSwitchClearsOldDimension: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSummaryBarOption(canvas, 'error');
await clickSortOption(canvas, 'Type', 'Stack');
await clickSortOption(canvas, 'Platform', 'Linux');
expectFilterChecked(canvas, 'error');
expectSortActive(canvas, 'Platform');
},
};
// Deselecting a group value keeps the sort, clears the filter
export const DeselectGroupValueClearsFilter: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSortOption(canvas, 'Status', 'Error');
expectFilterChecked(canvas, 'error');
await clickSortOption(canvas, 'Status', 'All');
expectFilterChecked(canvas, 'total');
expectSortActive(canvas, 'Status');
},
};
// Summary bar overrides a group-set filter
export const SummaryBarOverridesGroupFilter: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSortOption(canvas, 'Status', 'Error');
expectFilterChecked(canvas, 'error');
await clickSummaryBarOption(canvas, 'healthy');
expectFilterChecked(canvas, 'healthy');
},
};
// Group value overrides a summary bar filter; summary bar updates
export const GroupFilterOverridesSummaryBar: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await clickSummaryBarOption(canvas, 'healthy');
expectFilterChecked(canvas, 'healthy');
await clickSortOption(canvas, 'Status', 'Outdated');
expectFilterChecked(canvas, 'outdated');
},
};
// Helpers shared across play functions
async function clickSummaryBarOption(
canvas: ReturnType<typeof within>,
label: string
) {
await userEvent.click(
canvas.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
);
}
async function clickSortOption(
canvas: ReturnType<typeof within>,
sortLabel: string,
itemLabel?: string
) {
await userEvent.click(
canvas.getByRole('button', { name: new RegExp(`^${sortLabel}`, 'i') })
);
if (itemLabel) {
await userEvent.click(
canvas.getByRole('menuitem', { name: new RegExp(itemLabel, 'i') })
);
}
}
function expectFilterChecked(canvas: ReturnType<typeof within>, label: string) {
expect(
canvas.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
).toBeChecked();
}
function expectSortActive(canvas: ReturnType<typeof within>, label: string) {
expect(
canvas.getByRole('button', { name: new RegExp(`^${label}`, 'i') })
).toHaveAttribute('aria-pressed', 'true');
}
@@ -0,0 +1,375 @@
// This story demonstrates how StatusSummaryBar and SortableList integrate:
// the summary bar filter and the group-sort dimension filter share state so
// that selecting a status from either control keeps both in sync.
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
useTableStateFromUrl,
asEnum,
} from '@@/datatables/useTableStateFromUrl';
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
import {
StatusSummaryBar,
StatusSegment,
} from '@@/StatusSummaryBar/StatusSummaryBar';
import { SortableList } from './SortableList';
import { SortableGroup } from './SortableListGroup';
describe('StatusFilteredList', () => {
it('Click "Error" in summary bar → list shows only error items, sort unchanged, summary bar highlights "Error"', async () => {
const user = userEvent.setup();
renderList();
expectDefaultValues();
await selectSummaryBarOption(user, 'error');
expectFilterChecked('error');
expect(screen.getByRole('status')).toBeInTheDocument();
expectSortActive('Name');
});
it('Click "Error" again (active) → filter clears, all items shown', async () => {
const user = userEvent.setup();
renderList();
expectDefaultValues();
await selectSummaryBarOption(user, 'error');
await selectSummaryBarOption(user, 'error');
expectFilterChecked('total');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('Summary bar active, click sort header "Status" → status filter clears', async () => {
const user = userEvent.setup();
renderList();
await selectSummaryBarOption(user, 'error');
await selectSortByFilter(user, 'Status', 'All');
expectFilterChecked('total');
expectSortActive('Status');
});
it('Summary bar active, click sort header "Type" → status persists', async () => {
const user = userEvent.setup();
renderList();
await selectSummaryBarOption(user, 'error');
await selectSortByFilter(user, 'Type', 'Stack');
expectFilterChecked('error');
expectSortActive('Type');
});
it('switching to a non-grouped sort clears a group-set filter', async () => {
const user = userEvent.setup();
renderList();
await selectSortByFilter(user, 'Status', 'Error');
expectFilterChecked('error');
await selectSortByFilter(user, 'Name');
expectFilterChecked('total');
expectSortActive('Name');
});
it('Click "Error" in status group → sort=Status, filter=Error, summary bar also shows "Error"', async () => {
const user = userEvent.setup();
renderList();
await selectSortByFilter(user, 'Status', 'Error');
expectFilterChecked('error');
expectSortActive('Status');
});
it('summary bar filter persists when switching to a different dimension', async () => {
const user = userEvent.setup();
renderList();
await selectSummaryBarOption(user, 'error');
await selectSortByFilter(user, 'Type', 'Stack');
expectFilterChecked('error');
expectSortActive('Type');
});
it('second group switch clears old dimension, summary bar filter persists', async () => {
const user = userEvent.setup();
renderList();
await selectSummaryBarOption(user, 'error');
await selectSortByFilter(user, 'Type', 'Stack');
expectFilterChecked('error');
await selectSortByFilter(user, 'Platform', 'Linux');
expectFilterChecked('error');
expectSortActive('Platform');
});
it('deselecting a group value keeps sort, clears filter', async () => {
const user = userEvent.setup();
renderList();
await selectSortByFilter(user, 'Status', 'Error');
expectFilterChecked('error');
await selectSortByFilter(user, 'Status', 'All');
expectFilterChecked('total');
expectSortActive('Status');
});
it('summary bar overrides a group-set filter', async () => {
const user = userEvent.setup();
renderList();
await selectSortByFilter(user, 'Status', 'Error');
expectFilterChecked('error');
await selectSummaryBarOption(user, 'healthy');
expectFilterChecked('healthy');
});
it('group value overrides a summary bar filter; summary bar updates', async () => {
const user = userEvent.setup();
renderList();
await selectSummaryBarOption(user, 'healthy');
expectFilterChecked('healthy');
await selectSortByFilter(user, 'Status', 'Outdated');
expectFilterChecked('outdated');
});
it('summary bar filter is preserved when switching dimensions after overriding a group-set filter', async () => {
const user = userEvent.setup();
renderList();
await selectSortByFilter(user, 'Status', 'Error');
expectFilterChecked('error');
await selectSummaryBarOption(user, 'healthy');
expectFilterChecked('healthy');
await selectSortByFilter(user, 'Type', 'Stack');
expectFilterChecked('healthy');
expectSortActive('Type');
});
});
type Item = {
id: number;
name: string;
status: string;
type: string;
platform: string;
};
const ITEMS: Item[] = [
{ id: 1, name: 'Alpha', status: 'error', type: 'stack', platform: 'linux' },
{
id: 2,
name: 'Beta',
status: 'healthy',
type: 'stack',
platform: 'linux',
},
{
id: 3,
name: 'Gamma',
status: 'error',
type: 'edge',
platform: 'windows',
},
{
id: 4,
name: 'Delta',
status: 'healthy',
type: 'edge',
platform: 'windows',
},
{ id: 5, name: 'Eta', status: 'outdated', type: 'stack', platform: 'mac' },
];
const SEGMENTS: StatusSegment<string>[] = [
{ key: 'error', label: 'Error', count: 2, color: 'error' },
{ key: 'healthy', label: 'Healthy', count: 2, color: 'success' },
{ key: 'outdated', label: 'Outdated', count: 1, color: 'warning' },
];
const SORT_OPTIONS = [
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status', grouped: true },
{ key: 'type', label: 'Type', grouped: true },
{ key: 'platform', label: 'Platform', grouped: true },
];
const GROUP_OPTIONS: Record<string, Array<{ key: string; label: string }>> = {
status: SEGMENTS,
type: [
{ key: 'stack', label: 'Stack' },
{ key: 'edge', label: 'Edge' },
],
platform: [
{ key: 'linux', label: 'Linux' },
{ key: 'windows', label: 'Windows' },
{ key: 'mac', label: 'Mac' },
],
};
const FILTER_SET = new Set(['error', 'healthy', 'outdated']);
const TYPE_SET = new Set(['stack', 'edge']);
const PLATFORM_SET = new Set(['linux', 'windows', 'mac']);
const SORT_KEYS = ['name', 'status', 'type', 'platform'] as const;
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }, { key: 'platform' }];
function buildGroups(items: Item[], sortBy: string): SortableGroup<Item>[] {
const options = GROUP_OPTIONS[sortBy];
if (!options) {
return items.length > 0 ? [{ key: 'all', label: 'All', items }] : [];
}
function getField(item: Item) {
return item[sortBy as keyof Item] as string;
}
return options
.map(({ key, label }) => ({
key,
label,
items: items.filter((item) => getField(item) === key),
}))
.filter((g) => g.items.length > 0);
}
function TestList() {
const tableState = useTableStateFromUrl({
localStorageKey: 'test-status-filtered-list',
defaultSort: 'name',
parseExtra: (p) => ({
status: asEnum(p.status, FILTER_SET),
type: asEnum(p.type, TYPE_SET),
platform: asEnum(p.platform, PLATFORM_SET),
}),
buildExtra: (urlState, setUrlState) => ({
filterValue: urlState.status,
setFilter: (v: string | null) =>
setUrlState({
status: v,
page: 0,
...(urlState.sort === 'status' ? { sort: 'name' } : {}),
}),
...buildGroupSortExtras({
urlState,
setUrlState,
defaultSort: 'name',
sortKeys: SORT_KEYS,
dimensions: DIMENSIONS,
}),
}),
});
const sortBy = tableState.sortBy?.id ?? 'name';
return (
<>
<StatusSummaryBar
total={ITEMS.length}
segments={SEGMENTS}
value={tableState.filterValue}
onChange={tableState.setFilter}
data-cy="test-list-status-bar"
/>
<SortableList
tableState={tableState}
sortOptions={SORT_OPTIONS}
groupOptions={GROUP_OPTIONS}
groups={buildGroups(ITEMS, sortBy)}
totalCount={ITEMS.length}
renderItem={(item) => <div>{item.name}</div>}
showGroupHeaders
data-cy="test-list-list"
/>
</>
);
}
function renderList() {
return render(<TestList />);
}
async function selectSummaryBarOption(
user: ReturnType<typeof userEvent.setup>,
label: string
) {
await user.click(
screen.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
);
}
async function selectSortByFilter(
user: ReturnType<typeof userEvent.setup>,
sortLabel: string,
itemLabel?: string
) {
await user.click(
screen.getByRole('button', { name: new RegExp(`^${sortLabel}`, 'i') })
);
if (itemLabel) {
await user.click(
screen.getByRole('menuitem', { name: new RegExp(itemLabel, 'i') })
);
}
}
function expectFilterChecked(label: string) {
expect(
screen.getByRole('radio', { name: new RegExp(`filter by ${label}`, 'i') })
).toBeChecked();
}
function expectSortActive(label: string) {
expect(
screen.getByRole('button', { name: new RegExp(`^${label}`, 'i') })
).toHaveAttribute('aria-pressed', 'true');
}
function expectDefaultValues() {
expectSortActive('Name');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
}
vi.mock('@reach/menu-button');
vi.mock('@/react/hooks/useParamState', () => ({
useParamsState: <T extends Record<string, unknown>>(
parseParams: (params: Record<string, string | undefined>) => T
) => {
const [params, setParams] = useState<Record<string, string | undefined>>(
{}
);
function setState(newState: Partial<T>) {
setParams((prev) => {
const next = { ...prev };
for (const [k, v] of Object.entries(newState)) {
if (v === null || v === undefined) {
delete next[k];
} else {
next[k] = String(v);
}
}
return next;
});
}
return [parseParams(params), setState] as const;
},
}));
@@ -16,7 +16,7 @@ interface SortableListSettings
groupBy: string | null;
setGroupBy: (group: string | null) => void;
groupFilter: string | null;
setGroupFilter: (group: string | null, filter: string | null) => void;
setGroupFilter: (value: { group: string; groupValue: string | null }) => void;
}
export type SortableListState = TableState<SortableListSettings>;
@@ -29,8 +29,23 @@ function sortableListExtras(
groupBy: null,
setGroupBy: (group) => set((s) => ({ ...s, groupBy: group })),
groupFilter: null,
setGroupFilter: (group: string | null, filter: string | null) =>
set((s) => ({ ...s, groupBy: group, groupFilter: filter, page: 0 })),
setGroupFilter: ({
group,
groupValue,
}: {
group: string;
groupValue: string | null;
}) =>
set((s) => ({
...s,
sortBy: {
id: group,
desc: s.sortBy?.id === group ? !s.sortBy.desc : false,
},
groupBy: group,
groupFilter: groupValue,
page: 0,
})),
};
}
@@ -6,6 +6,7 @@ interface Props {
export function FilterBarActiveIndicator({ label, onClear }: Props) {
return (
<div
role="status"
className="flex items-center gap-4 whitespace-nowrap bg-gray-2 px-5 th-highcontrast:bg-transparent th-dark:bg-gray-iron-10"
data-cy="active-filter-indicator"
>
@@ -19,6 +19,7 @@ interface Props extends AutomationTestingProps {
onClick: () => void;
name: string;
color?: Color;
isLoading?: boolean;
}
export function FilterBarButton({
@@ -28,9 +29,10 @@ export function FilterBarButton({
onClick,
name,
color,
isLoading = false,
'data-cy': dataCy,
}: Props) {
if (count === 0) {
if (!isLoading && count === 0) {
return null;
}
@@ -79,7 +81,11 @@ export function FilterBarButton({
)}
{!colors && (
<span className="flex items-baseline gap-2">
<span className="text-2xl font-bold">{count}</span>
{isLoading ? (
<span className="h-8 w-8 animate-pulse rounded bg-gray-3 th-dark:bg-gray-8" />
) : (
<span className="text-2xl font-bold">{count}</span>
)}
<span className="text-base uppercase tracking-wide text-[var(--text-muted-color)]">
{label}
</span>
@@ -79,7 +79,7 @@ describe('StatusSummaryBar', () => {
const onChange = vi.fn();
renderComponent({ value: 'down', onChange });
expect(screen.getByTestId('active-filter-indicator')).toBeVisible();
expect(screen.getByRole('status')).toBeVisible();
expect(
screen.getByRole('button', { name: /clear filter/i })
).toBeInTheDocument();
@@ -15,6 +15,7 @@ interface Props<TValue> {
onChange: (filter: TValue | null) => void;
radioGroupName?: string;
ariaLabel?: string;
isLoading?: boolean;
'data-cy'?: string;
}
@@ -25,6 +26,7 @@ export function StatusSummaryBar<TValue extends string = string>({
onChange,
radioGroupName = 'status-summary-filter',
ariaLabel = 'Filter by status',
isLoading = false,
'data-cy': dataCy = 'status-summary-bar',
}: Props<TValue>) {
const isAllSelected = !value || value === 'all' || value === 'custom';
@@ -47,6 +49,7 @@ export function StatusSummaryBar<TValue extends string = string>({
isSelected={isAllSelected}
onClick={() => onChange(null)}
name={radioGroupName}
isLoading={isLoading}
data-cy={`${dataCy}-total`}
/>
@@ -0,0 +1,207 @@
import { describe, it, expect, vi } from 'vitest';
import { buildGroupSortExtras } from './groupSortState';
describe('buildGroupSortExtras — summary bar scenarios', () => {
it('switching to status sort clears status set via summary bar', () => {
// sort='name', status='error' (set via summary bar) → setSortBy('status')
// → status cleared (entering grouped-by-status mode resets the filter)
const setUrlState = vi.fn();
const extras = makeExtras({ sort: 'name', status: 'error' }, setUrlState);
extras.setSortBy('status', false);
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'status',
order: 'asc',
page: 0,
status: null,
});
});
it('switching to a different sort preserves status set via summary bar', () => {
// sort='name', status='error' (set via summary bar) → setSortBy('type')
// → status persists (cross-dimension, type cleared, status untouched)
const setUrlState = vi.fn();
const extras = makeExtras({ sort: 'name', status: 'error' }, setUrlState);
extras.setSortBy('type', false);
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'type',
order: 'asc',
page: 0,
type: null,
});
});
});
describe('buildGroupSortExtras — sort bar group scenarios', () => {
it('leaving status sort clears status set via sort bar group', () => {
// sort='status', status='error' (set via sort bar group) → setSortBy('name')
// → status cleared (leaving grouped-by-status mode)
const setUrlState = vi.fn();
const extras = makeExtras({ sort: 'status', status: 'error' }, setUrlState);
extras.setSortBy('name', false);
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'name',
order: 'asc',
page: 0,
status: null,
});
});
it('clicking a sort bar group value sets sort and filter together', () => {
// sort='name' → setGroupFilter('status', 'error')
// → sort=status, status=error
const setUrlState = vi.fn();
const extras = makeExtras({ sort: 'name' }, setUrlState);
extras.setGroupFilter({ group: 'status', groupValue: 'error' });
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'status',
order: 'asc',
page: 0,
status: 'error',
});
});
it('deselecting a sort bar group value keeps sort, clears filter, changes order', () => {
// sort='status', status='error' (set via sort bar group) → setGroupFilter('status', null)
// → sort stays status, status cleared, order toggles
const setUrlState = vi.fn();
const extras = makeExtras(
{ sort: 'status', status: 'error', order: 'asc' },
setUrlState
);
extras.setGroupFilter({ group: 'status', groupValue: null });
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'status',
order: 'desc',
page: 0,
status: null,
});
});
});
describe('buildGroupSortExtras — cross-dimension AND filter scenarios', () => {
it('status set via summary bar persists when switching to a different sort bar group option', () => {
// sort='name', status='error' (set via summary bar) → setGroupFilter('type', 'stack')
// → sort=type, type=stack, status=error preserved (AND filter)
const setUrlState = vi.fn();
const extras = makeExtras({ sort: 'name', status: 'error' }, setUrlState);
extras.setGroupFilter({ group: 'type', groupValue: 'stack' });
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'type',
order: 'asc',
page: 0,
type: 'stack',
});
});
it('status set via summary bar persists through a second sort bar group switch, old group dimension clears', () => {
// sort='type', type='stack', status='error' (set via summary bar, AND filter)
// → setGroupFilter('platform', 'docker')
// → sort=platform, type cleared, status still persists
const setUrlState = vi.fn();
const extras = makeExtrasWithPlatform(
{ sort: 'type', type: 'stack', status: 'error' },
setUrlState
);
extras.setGroupFilter({ group: 'platform', groupValue: 'docker' });
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'platform',
order: 'asc',
page: 0,
platform: 'docker',
type: null,
});
});
it('status set via sort bar group clears when switching to a different sort bar group option', () => {
// sort='status', status='error' (set via sort bar group) → setGroupFilter('type', 'stack')
// → status cleared (was the old sort dimension)
const setUrlState = vi.fn();
const extras = makeExtras({ sort: 'status', status: 'error' }, setUrlState);
extras.setGroupFilter({ group: 'type', groupValue: 'stack' });
expect(setUrlState).toHaveBeenCalledWith({
sort: 'type',
order: 'asc',
page: 0,
type: 'stack',
status: null,
groupBy: null,
groupFilter: null,
});
});
});
describe('buildGroupSortExtras — override scenarios', () => {
it('summary bar overrides status set via sort bar group (last write wins)', () => {
// setStatus is handled by the view, not buildGroupSortExtras.
// Verify that groupFilter reflects the current URL status when sort=status.
const extras = makeExtras({ sort: 'status', status: 'healthy' });
expect(extras.groupFilter).toBe('healthy');
});
it('toggling sort direction preserves the active sort bar group filter', () => {
const setUrlState = vi.fn();
const extras = makeExtras({ sort: 'status', status: 'error' }, setUrlState);
extras.setSortBy('status', true);
expect(setUrlState).toHaveBeenCalledWith({
groupBy: null,
groupFilter: null,
sort: 'status',
order: 'desc',
page: 0,
});
});
});
function makeExtras(
urlState: {
sort: string;
status?: string | null;
type?: string | null;
order?: 'desc' | 'asc';
},
setUrlState = vi.fn()
) {
const order = urlState.order || 'asc';
return buildGroupSortExtras({
urlState: { status: null, type: null, order, ...urlState },
setUrlState,
defaultSort: 'name',
sortKeys: ['name', 'status', 'type'] as const,
dimensions: [{ key: 'status' }, { key: 'type' }],
});
}
function makeExtrasWithPlatform(
urlState: {
sort: string;
status?: string | null;
type?: string | null;
platform?: string | null;
order?: 'asc' | 'desc';
},
setUrlState = vi.fn()
) {
const order = urlState.order || 'asc';
return buildGroupSortExtras({
urlState: { status: null, type: null, platform: null, order, ...urlState },
setUrlState,
defaultSort: 'name',
sortKeys: ['name', 'status', 'type', 'platform'] as const,
dimensions: [{ key: 'status' }, { key: 'type' }, { key: 'platform' }],
});
}
@@ -0,0 +1,98 @@
interface DimensionConfig {
key: string;
}
type UrlState = { sort: string; order: 'asc' | 'desc' } & Record<
string,
unknown
>;
type SetUrlState = (update: Record<string, unknown>) => void;
interface GroupSortConfig {
urlState: UrlState;
setUrlState: SetUrlState;
defaultSort: string;
sortKeys: readonly string[];
dimensions: DimensionConfig[];
}
export function buildGroupSortExtras({
urlState,
setUrlState,
defaultSort,
sortKeys,
dimensions,
}: GroupSortConfig) {
const sortKey = toSortKey(urlState.sort, sortKeys, defaultSort);
return {
groupFilter: dimensions.some((d) => d.key === sortKey)
? (urlState[sortKey] as string | null)
: null,
setGroupFilter: ({
group,
groupValue,
}: {
group: string;
groupValue: string | null;
}) => {
const newKey = toSortKey(group, sortKeys, defaultSort);
const currentKey = toSortKey(urlState.sort, sortKeys, defaultSort);
const isLeavingDimension =
currentKey !== newKey && dimensions.some((d) => d.key === currentKey);
setUrlState({
sort: newKey,
order: newOrder(sortKey, newKey, urlState.order, groupValue),
page: 0,
groupBy: null,
groupFilter: null,
[newKey]: groupValue,
...(isLeavingDimension ? { [currentKey]: null } : {}),
});
},
setSortBy: (id: string, desc: boolean) => {
const newKey = toSortKey(id ?? defaultSort, sortKeys, defaultSort);
const currentKey = toSortKey(urlState.sort, sortKeys, defaultSort);
const dimsToClear =
newKey !== currentKey
? dimensions.filter((d) => d.key === newKey || d.key === currentKey)
: [];
setUrlState({
sort: newKey,
order: desc ? 'desc' : 'asc',
page: 0,
groupBy: null,
groupFilter: null,
...Object.fromEntries(dimsToClear.map((d) => [d.key, null])),
});
},
};
}
function toSortKey(
sort: string,
sortKeys: readonly string[],
defaultSort: string
): string {
return sortKeys.includes(sort) ? sort : defaultSort;
}
function newOrder(
currentSortKey: string,
newSortKey: string,
currentOrder: 'asc' | 'desc',
groupValue: string | null = null
): 'asc' | 'desc' {
if (currentSortKey !== newSortKey) {
return 'asc';
}
// Only toggle sort order when clearing the group filter (groupValue is null)
if (groupValue !== null) {
return currentOrder;
}
return currentOrder === 'asc' ? 'desc' : 'asc';
}
@@ -21,7 +21,7 @@ type Extra = {
groupBy: string | null;
setGroupBy(group: string | null): void;
groupFilter: string | null;
setGroupFilter(group: string | null, filter: string | null): void;
setGroupFilter(value: { group: string; groupValue: string | null }): void;
};
export function useTableStateFromUrl<
@@ -86,10 +86,19 @@ export function useTableStateFromUrl<
}),
groupFilter: urlState.groupFilter,
setGroupFilter: (group, filter) => {
setGroupFilter: ({
group,
groupValue,
}: {
group: string;
groupValue: string | null;
}) => {
const isSameGroup = urlState.groupBy === group;
setCoreState({
sort: group,
order: isSameGroup && urlState.order === 'asc' ? 'desc' : 'asc',
groupBy: group,
groupFilter: filter,
groupFilter: groupValue,
page: 0,
});
},
@@ -17,6 +17,7 @@ export function useHomeViewState() {
return useTableStateFromUrl<Record<never, never>, Extra>({
localStorageKey: STORAGE_KEY,
defaultSort: DEFAULT_SORT,
defaultGroupBy: DEFAULT_SORT,
buildExtra: (urlState, setUrlState) => {
return {
groupKey: urlState.groupBy
@@ -108,6 +108,7 @@ export function WorkflowsView() {
value={tableState.status}
onChange={tableState.setStatus}
radioGroupName="workflows-status"
isLoading={summaryQuery.isLoading}
/>
<SortableList
tableState={tableState}
@@ -1,3 +1,4 @@
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
import {
asEnum,
useTableStateFromUrl,
@@ -21,6 +22,16 @@ const DEPLOYMENT_PLATFORMS = new Set<DeploymentPlatform>([
'kubernetes',
]);
const SORT_KEYS = [
'name',
'status',
'type',
'platform',
'lastSyncDate',
] as const;
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }, { key: 'platform' }];
export function useListState() {
return useTableStateFromUrl({
localStorageKey: 'workflows',
@@ -30,56 +41,19 @@ export function useListState() {
type: asEnum(params.type, WORKFLOW_TYPES),
platform: asEnum(params.platform, DEPLOYMENT_PLATFORMS),
}),
buildExtra: (urlState, setUrlState) => {
return {
status: urlState.status,
type: urlState.type,
platform: urlState.platform,
setStatus: (v: WorkflowStatus | null) =>
setUrlState({ status: v, page: 0 }),
setGroupFilter: (group: string | null, filter: string | null) => {
if (group === 'status') {
setUrlState({
groupBy: group,
groupFilter: filter,
status: filter as WorkflowStatus | null,
type: null,
platform: null,
page: 0,
});
} else if (group === 'type') {
setUrlState({
groupBy: group,
groupFilter: filter,
type: filter as WorkflowType | null,
status: null,
platform: null,
page: 0,
});
} else if (group === 'platform') {
setUrlState({
groupBy: group,
groupFilter: filter,
platform: filter as DeploymentPlatform | null,
type: null,
status: null,
page: 0,
});
}
},
setSortBy: (id: string, desc: boolean) =>
setUrlState({
sort: id ?? DEFAULT_SORT,
order: desc ? 'desc' : 'asc',
groupBy: null,
groupFilter: null,
status: null,
type: null,
platform: null,
page: 0,
}),
};
},
buildExtra: (urlState, setUrlState) => ({
status: urlState.status,
type: urlState.type,
platform: urlState.platform,
setStatus: (v: WorkflowStatus | null) =>
setUrlState({ status: v, page: 0 }),
...buildGroupSortExtras({
urlState,
setUrlState,
defaultSort: DEFAULT_SORT,
sortKeys: SORT_KEYS,
dimensions: DIMENSIONS,
}),
}),
});
}
@@ -0,0 +1,129 @@
import { PageHeader } from '@@/PageHeader';
import {
SortableGroup,
SortableList,
SortOption,
} from '@@/SortableList/SortableList';
import { StatusSummaryBar } from '@@/StatusSummaryBar/StatusSummaryBar';
import { useSources } from '../queries/useSources';
import { useSourcesSummary } from '../queries/useSourcesSummary';
import { Source, SourceStatus, SOURCE_TYPES } from '../types';
import { SourceCard } from './SourceCard';
import { useListState } from './useListState';
const STATUS_CONFIG: Array<{
key: SourceStatus;
label: string;
color: 'error' | 'gray' | 'warning' | 'success';
}> = [
{ key: 'error', label: 'Error', color: 'error' },
{ key: 'paused', label: 'Paused', color: 'gray' },
{ key: 'syncing', label: 'Syncing', color: 'warning' },
{ key: 'healthy', label: 'Healthy', color: 'success' },
{ key: 'unknown', label: 'Unknown', color: 'gray' },
];
const TYPE_CONFIG = Object.entries(SOURCE_TYPES).map(([key, { label }]) => ({
key,
label,
}));
const SORT_OPTIONS: SortOption[] = [
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status', grouped: true },
{ key: 'type', label: 'Type', grouped: true },
];
const GROUP_OPTIONS = {
status: STATUS_CONFIG,
type: TYPE_CONFIG,
};
export function ListView() {
const tableState = useListState();
const sortBy = tableState.sortBy?.id ?? 'name';
const sourcesQuery = useSources({
search: tableState.search || undefined,
sort: sortBy,
order: tableState.sortBy?.desc ? 'desc' : 'asc',
start: tableState.page * tableState.pageSize,
limit: tableState.pageSize,
status: tableState.status ?? undefined,
type: tableState.type ?? undefined,
});
const summaryQuery = useSourcesSummary();
const page = sourcesQuery.data?.data;
const totalCount = sourcesQuery.data?.totalCount ?? 0;
const groups = buildGroups(page, sortBy);
const statusSegments = STATUS_CONFIG.map((s) => ({
...s,
count: summaryQuery.data?.[s.key] ?? 0,
}));
const summaryTotal = summaryQuery.data
? Object.values(summaryQuery.data).reduce((a, b) => a + b, 0)
: 0;
return (
<>
<PageHeader title="GitOps Sources" breadcrumbs="GitOps Sources" reload />
<div className="mx-4 mb-4 space-y-4">
<StatusSummaryBar
total={summaryTotal}
segments={statusSegments}
value={tableState.status}
onChange={tableState.setStatus}
radioGroupName="sources-status"
isLoading={summaryQuery.isLoading}
/>
<SortableList
tableState={tableState}
sortOptions={SORT_OPTIONS}
groupOptions={{ ...GROUP_OPTIONS, status: statusSegments }}
groups={groups}
totalCount={totalCount}
isLoading={sourcesQuery.isLoading}
getItemKey={(item) => item.id}
showGroupHeaders
emptyMessage="No sources found"
searchPlaceholder="Search"
renderItem={(item) => <SourceCard item={item} />}
data-cy="sources-list"
/>
</div>
</>
);
}
function buildGroups(
items: Source[] | null | undefined,
sortBy: string
): SortableGroup<Source>[] {
if (!items?.length) {
return [];
}
if (sortBy === 'status') {
return STATUS_CONFIG.map(({ key, label }) => ({
key,
label,
items: items.filter((item) => item.status === key),
})).filter((g) => g.items.length > 0);
}
if (sortBy === 'type') {
return TYPE_CONFIG.map(({ key, label }) => ({
key,
label,
items: items.filter((item) => item.type === key),
})).filter((g) => g.items.length > 0);
}
return [{ key: 'all', label: 'All', items }];
}
@@ -0,0 +1,59 @@
import { AlertTriangle, GitCommit, Server, WatchIcon } from 'lucide-react';
import moment from 'moment';
import { Icon } from '@@/Icon';
import { SortableListItem } from '@@/SortableList/SortableListItem';
import { StatusBadge } from '../../WorkflowsView/WorkflowBadges';
import { Source, SOURCE_TYPES } from '../types';
import { StatBlock } from './StatBlock';
export function SourceCard({ item }: { item: Source }) {
const { icon: TypeIcon } = SOURCE_TYPES[item.type];
const lastSyncLabel = item.lastSync
? moment.unix(item.lastSync).fromNow()
: '-';
return (
<SortableListItem>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-gray-2 th-highcontrast:bg-gray-8 th-dark:bg-gray-8">
<Icon icon={TypeIcon} size="md" className="text-gray-6" />
</div>
<div className="flex min-w-0 flex-1 items-center justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="truncate font-semibold tracking-wide text-gray-9 th-highcontrast:text-white th-dark:text-white">
{item.name}
</span>
<StatusBadge status={item.status} />
</div>
<span className="truncate text-sm text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
{item.url}
</span>
{item.error && (
<div className="flex items-center gap-1.5 text-xs text-error-8">
<Icon icon={AlertTriangle} size="sm" className="shrink-0" />
{item.error}
</div>
)}
</div>
<div className="flex shrink-0 gap-2">
<StatBlock icon={GitCommit} label="Workflows" value={item.usedBy} />
<StatBlock
icon={Server}
label="Environments"
value={item.environments}
/>
<StatBlock
icon={WatchIcon}
label="Last sync"
value={lastSyncLabel}
/>
</div>
</div>
</div>
</SortableListItem>
);
}
@@ -0,0 +1,23 @@
import { type LucideIcon } from 'lucide-react';
import { Icon } from '@@/Icon';
interface Props {
icon: LucideIcon;
label: string;
value: string | number;
}
export function StatBlock({ icon, label, value }: Props) {
return (
<div className="flex min-w-[100px] flex-col gap-1 rounded-lg border border-solid border-gray-5 bg-gray-2 px-4 py-3 th-highcontrast:border-white th-highcontrast:bg-gray-10 th-dark:border-gray-8 th-dark:bg-gray-iron-10">
<div className="flex items-center gap-1.5 text-xs uppercase tracking-wider text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
<Icon icon={icon} size="sm" />
<span>{label}</span>
</div>
<span className="text-base font-semibold text-gray-9 th-highcontrast:text-white th-dark:text-white">
{value}
</span>
</div>
);
}
@@ -0,0 +1,48 @@
import { buildGroupSortExtras } from '@@/datatables/groupSortState';
import {
asEnum,
useTableStateFromUrl,
} from '@@/datatables/useTableStateFromUrl';
import { SourceStatus, SourceType } from '../types';
const DEFAULT_SORT = 'name' as const;
const SOURCE_STATUSES = new Set<SourceStatus>([
'healthy',
'error',
'syncing',
'paused',
'unknown',
]);
const SOURCE_TYPES = new Set<SourceType>(['git', 'helm', 'oci']);
const SORT_KEYS = ['name', 'status', 'type'] as const;
const DIMENSIONS = [{ key: 'status' }, { key: 'type' }];
export function useListState() {
return useTableStateFromUrl({
localStorageKey: 'sources',
defaultSort: DEFAULT_SORT,
parseExtra: (params) => ({
status: asEnum(params.status, SOURCE_STATUSES),
type: asEnum(params.type, SOURCE_TYPES),
}),
buildExtra: (urlState, setUrlState) => ({
status: urlState.status,
type: urlState.type,
setStatus: (v: SourceStatus | null) =>
setUrlState({ status: v, page: 0 }),
setType: (v: SourceType | null) => setUrlState({ type: v, page: 0 }),
...buildGroupSortExtras({
urlState,
setUrlState,
defaultSort: DEFAULT_SORT,
sortKeys: SORT_KEYS,
dimensions: DIMENSIONS,
}),
}),
});
}
@@ -0,0 +1,5 @@
export const sourceQueryKeys = {
all: ['gitops', 'sources'] as const,
list: (params: object) => [...sourceQueryKeys.all, 'list', params] as const,
summary: () => [...sourceQueryKeys.all, 'summary'] as const,
};
@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import axios from '@/portainer/services/axios/axios';
import { withError } from '@/react-tools/react-query';
import { withPaginationHeaders } from '@/react/common/api/pagination.types';
import { Source, SourceStatus, SourceType } from '../types';
import { sourceQueryKeys } from './query-keys';
export interface SourcesParams {
search?: string;
sort?: string;
order?: 'asc' | 'desc';
start?: number;
limit?: number;
status?: SourceStatus | null;
type?: SourceType | null;
}
async function getSources(params: SourcesParams) {
const response = await axios.get<Source[]>('/gitops/sources', { params });
return withPaginationHeaders(response);
}
export function useSources(params: SourcesParams) {
return useQuery({
queryKey: sourceQueryKeys.list(params),
queryFn: () => getSources(params),
...withError('Failed loading sources'),
});
}
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import axios from '@/portainer/services/axios/axios';
import { withError } from '@/react-tools/react-query';
import { SourceStatus } from '../types';
import { sourceQueryKeys } from './query-keys';
export type SourcesSummary = Record<SourceStatus, number>;
async function getSourcesSummary(): Promise<SourcesSummary> {
const { data } = await axios.get<SourcesSummary>('/gitops/sources/summary');
return data;
}
export function useSourcesSummary() {
return useQuery({
queryKey: sourceQueryKeys.summary(),
queryFn: getSourcesSummary,
...withError('Failed loading sources summary'),
});
}
@@ -0,0 +1,31 @@
import { type LucideIcon, Box, GitBranch, Package } from 'lucide-react';
export type SourceStatus =
| 'healthy'
| 'error'
| 'syncing'
| 'paused'
| 'unknown';
export type SourceType = 'git' | 'helm' | 'oci';
export interface Source {
id: string;
name: string;
type: SourceType;
url: string;
status: SourceStatus;
error?: string;
provider?: string;
usedBy: number;
environments: number;
lastSync: number;
}
export const SOURCE_TYPES: Record<
SourceType,
{ label: string; icon: LucideIcon }
> = {
git: { label: 'Git', icon: GitBranch },
helm: { label: 'Helm', icon: Package },
oci: { label: 'OCI', icon: Box },
};
+25
View File
@@ -0,0 +1,25 @@
import { Database, GitBranch } from 'lucide-react';
import { SidebarItem } from './SidebarItem';
import { SidebarSection } from './SidebarSection';
export function AppDeliverySidebar({ isAdmin }: { isAdmin: boolean }) {
return (
<SidebarSection title="App Delivery">
<SidebarItem
label="Workflows"
to="portainer.gitops.workflows"
icon={GitBranch}
data-cy="portainerSidebar-workflows"
/>
{isAdmin && (
<SidebarItem
label="Sources"
to="portainer.gitops.sources"
icon={Database}
data-cy="portainerSidebar-sources"
/>
)}
</SidebarSection>
);
}
+3 -7
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { GitBranch, Home } from 'lucide-react';
import { Home } from 'lucide-react';
import { useIsEdgeAdmin, useIsPureAdmin } from '@/react/hooks/useUser';
import { useIsCurrentUserTeamLeader } from '@/portainer/users/queries';
@@ -14,6 +14,7 @@ import { Footer } from './Footer';
import { Header } from './Header';
import { SidebarProvider, useSidebarState } from './useSidebarState';
import { UpgradeBEBannerWrapper } from './UpgradeBEBanner';
import { AppDeliverySidebar } from './AppDeliverySidebar';
export function Sidebar() {
return (
@@ -70,12 +71,7 @@ function InnerSidebar() {
<EnvironmentSidebar />
<SidebarItem
to="portainer.workflows"
icon={GitBranch}
label="Workflows"
data-cy="portainerSidebar-workflows"
/>
<AppDeliverySidebar isAdmin={isAdmin} />
{isAdmin && <EdgeComputeSidebar />}
+3 -1
View File
@@ -140,7 +140,8 @@ export default defineConfig([
{
object: 'navigator',
property: 'clipboard',
message: 'navigator.clipboard requires a secure context (HTTPS). Use the `useCopy` hook or `CopyButton` component (@@/buttons/CopyButton) — they include a non-secure fallback.',
message:
'navigator.clipboard requires a secure context (HTTPS). Use the `useCopy` hook or `CopyButton` component (@@/buttons/CopyButton) — they include a non-secure fallback.',
},
{
object: 'navigator',
@@ -263,6 +264,7 @@ export default defineConfig([
'no-empty-function': 'off',
// Tests mock secure-context APIs directly — the restriction is for production code only
'no-restricted-properties': 'off',
'vitest/expect-expect': ['warn', { assertFunctionNames: ['expect*', 'assert*', 'verify*'] }],
},
},