diff --git a/components/backend/handlers/projects.go b/components/backend/handlers/projects.go index ce638161f..02352a4be 100644 --- a/components/backend/handlers/projects.go +++ b/components/backend/handlers/projects.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "regexp" + "sort" "strings" "sync" "time" @@ -142,9 +143,22 @@ func GetClusterInfo(c *gin.Context) { }) } +// accessCheckResult holds the result of a parallel access check +type accessCheckResult struct { + namespace *corev1.Namespace + hasAccess bool + err error + cancelled bool // Context was cancelled before check completed +} + +// parallelSSARWorkerCount is the number of concurrent SSAR checks +const parallelSSARWorkerCount = 10 + // ListProjects handles GET /projects // Lists Namespaces (both platforms) using backend SA with label selector, -// then uses SubjectAccessReview to verify user access to each namespace +// then uses SubjectAccessReview to verify user access to each namespace. +// Supports pagination via limit/offset and search filtering. +// SSAR checks are performed in parallel for improved performance. func ListProjects(c *gin.Context) { reqK8s, _ := GetK8sClientsForRequest(c) @@ -153,6 +167,14 @@ func ListProjects(c *gin.Context) { return } + // Parse pagination parameters + var params types.PaginationParams + if err := c.ShouldBindQuery(¶ms); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid pagination parameters"}) + return + } + types.NormalizePaginationParams(¶ms) + // List namespaces using backend SA (both platforms) if K8sClientProjects == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list projects"}) @@ -160,9 +182,8 @@ func ListProjects(c *gin.Context) { } isOpenShift := isOpenShiftCluster() - projects := []types.AmbientProject{} - ctx, cancel := context.WithTimeout(context.Background(), defaultK8sTimeout) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Increased timeout for parallel checks defer cancel() nsList, err := K8sClientProjects.CoreV1().Namespaces().List(ctx, v1.ListOptions{ @@ -174,21 +195,177 @@ func ListProjects(c *gin.Context) { return } - // Filter to only namespaces where user has access - // Use SubjectAccessReview - checks ALL RBAC sources (any RoleBinding, group, etc.) - for _, ns := range nsList.Items { - hasAccess, err := checkUserCanAccessNamespace(reqK8s, ns.Name) - if err != nil { - log.Printf("Failed to check access for namespace %s: %v", ns.Name, err) + // Pre-filter by search term if provided (before SSAR checks to reduce work) + filteredNamespaces := filterNamespacesBySearch(nsList.Items, params.Search, isOpenShift) + + // Perform parallel SSAR checks using worker pool + accessibleProjects := performParallelSSARChecks(ctx, reqK8s, filteredNamespaces, isOpenShift) + + // Sort by creation timestamp (newest first) + sortProjectsByCreationTime(accessibleProjects) + + // Apply pagination + totalCount := len(accessibleProjects) + paginatedProjects, hasMore, nextOffset := paginateProjects(accessibleProjects, params.Offset, params.Limit) + + response := types.PaginatedResponse{ + Items: paginatedProjects, + TotalCount: totalCount, + Limit: params.Limit, + Offset: params.Offset, + HasMore: hasMore, + } + if hasMore { + response.NextOffset = &nextOffset + } + + c.JSON(http.StatusOK, response) +} + +// filterNamespacesBySearch filters namespaces by search term (name or displayName) +func filterNamespacesBySearch(namespaces []corev1.Namespace, search string, isOpenShift bool) []corev1.Namespace { + if search == "" { + return namespaces + } + + searchLower := strings.ToLower(search) + filtered := make([]corev1.Namespace, 0, len(namespaces)) + + for _, ns := range namespaces { + // Match against name + if strings.Contains(strings.ToLower(ns.Name), searchLower) { + filtered = append(filtered, ns) continue } - if hasAccess { - projects = append(projects, projectFromNamespace(&ns, isOpenShift)) + // On OpenShift, also match against displayName + if isOpenShift && ns.Annotations != nil { + displayName := ns.Annotations["openshift.io/display-name"] + if strings.Contains(strings.ToLower(displayName), searchLower) { + filtered = append(filtered, ns) + continue + } } } - c.JSON(http.StatusOK, gin.H{"items": projects}) + return filtered +} + +// performParallelSSARChecks performs SSAR checks in parallel using a worker pool +func performParallelSSARChecks(ctx context.Context, reqK8s *kubernetes.Clientset, namespaces []corev1.Namespace, isOpenShift bool) []types.AmbientProject { + if len(namespaces) == 0 { + return []types.AmbientProject{} + } + + // Determine worker count (don't exceed number of namespaces) + workerCount := parallelSSARWorkerCount + if len(namespaces) < workerCount { + workerCount = len(namespaces) + } + + // Channel for namespace work items + workChan := make(chan *corev1.Namespace, len(namespaces)) + // Channel for results + resultChan := make(chan accessCheckResult, len(namespaces)) + + // Start worker goroutines + var wg sync.WaitGroup + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for ns := range workChan { + // Check context cancellation + select { + case <-ctx.Done(): + // Report cancellation so caller can return partial results + resultChan <- accessCheckResult{ + namespace: ns, + cancelled: true, + } + // Drain remaining work items without processing + for range workChan { + resultChan <- accessCheckResult{cancelled: true} + } + return + default: + } + + hasAccess, err := checkUserCanAccessNamespace(reqK8s, ns.Name) + resultChan <- accessCheckResult{ + namespace: ns, + hasAccess: hasAccess, + err: err, + } + } + }() + } + + // Send work items + for i := range namespaces { + workChan <- &namespaces[i] + } + close(workChan) + + // Wait for all workers to finish and close result channel + go func() { + wg.Wait() + close(resultChan) + }() + + // Collect results and track cancellations + projects := make([]types.AmbientProject, 0, len(namespaces)) + cancelledCount := 0 + for result := range resultChan { + if result.cancelled { + cancelledCount++ + continue + } + if result.err != nil { + log.Printf("Failed to check access for namespace %s: %v", result.namespace.Name, result.err) + continue + } + if result.hasAccess { + projects = append(projects, projectFromNamespace(result.namespace, isOpenShift)) + } + } + + if cancelledCount > 0 { + log.Printf("Warning: %d SSAR checks were cancelled due to context timeout", cancelledCount) + } + + return projects +} + +// sortProjectsByCreationTime sorts projects by creation timestamp (newest first) +func sortProjectsByCreationTime(projects []types.AmbientProject) { + // Use sort.Slice for O(n log n) performance + // RFC3339 timestamps sort lexicographically + sort.Slice(projects, func(i, j int) bool { + return projects[i].CreationTimestamp > projects[j].CreationTimestamp + }) +} + +// paginateProjects applies offset/limit pagination to the project list +func paginateProjects(projects []types.AmbientProject, offset, limit int) ([]types.AmbientProject, bool, int) { + total := len(projects) + + // Handle offset beyond available items + if offset >= total { + return []types.AmbientProject{}, false, 0 + } + + // Calculate end index + end := offset + limit + if end > total { + end = total + } + + // Determine if there are more items + hasMore := end < total + nextOffset := end + + return projects[offset:end], hasMore, nextOffset } // projectFromNamespace converts a Kubernetes Namespace to AmbientProject diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 402a6d5c6..7ba60a94a 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "sort" "strings" "time" @@ -300,7 +301,21 @@ func ListSessions(c *gin.Context) { _ = reqK8s gvr := GetAgenticSessionV1Alpha1Resource() - list, err := reqDyn.Resource(gvr).Namespace(project).List(context.TODO(), v1.ListOptions{}) + // Parse pagination parameters + var params types.PaginationParams + if err := c.ShouldBindQuery(¶ms); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid pagination parameters"}) + return + } + types.NormalizePaginationParams(¶ms) + + // Build list options with pagination + // Note: Kubernetes List with Limit returns a continue token for server-side pagination + // We use offset-based pagination on top of fetching all items for search/sort flexibility + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + list, err := reqDyn.Resource(gvr).Namespace(project).List(ctx, v1.ListOptions{}) if err != nil { log.Printf("Failed to list agentic sessions in project %s: %v", project, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list agentic sessions"}) @@ -326,7 +341,105 @@ func ListSessions(c *gin.Context) { sessions = append(sessions, session) } - c.JSON(http.StatusOK, gin.H{"items": sessions}) + // Apply search filter if provided + if params.Search != "" { + sessions = filterSessionsBySearch(sessions, params.Search) + } + + // Sort by creation timestamp (newest first) + sortSessionsByCreationTime(sessions) + + // Apply pagination + totalCount := len(sessions) + paginatedSessions, hasMore, nextOffset := paginateSessions(sessions, params.Offset, params.Limit) + + response := types.PaginatedResponse{ + Items: paginatedSessions, + TotalCount: totalCount, + Limit: params.Limit, + Offset: params.Offset, + HasMore: hasMore, + } + if hasMore { + response.NextOffset = &nextOffset + } + + c.JSON(http.StatusOK, response) +} + +// filterSessionsBySearch filters sessions by search term (name or displayName) +func filterSessionsBySearch(sessions []types.AgenticSession, search string) []types.AgenticSession { + if search == "" { + return sessions + } + + searchLower := strings.ToLower(search) + filtered := make([]types.AgenticSession, 0, len(sessions)) + + for _, session := range sessions { + // Match against name + if name, ok := session.Metadata["name"].(string); ok { + if strings.Contains(strings.ToLower(name), searchLower) { + filtered = append(filtered, session) + continue + } + } + + // Match against displayName in spec + if strings.Contains(strings.ToLower(session.Spec.DisplayName), searchLower) { + filtered = append(filtered, session) + continue + } + + // Match against initialPrompt + if strings.Contains(strings.ToLower(session.Spec.InitialPrompt), searchLower) { + filtered = append(filtered, session) + continue + } + } + + return filtered +} + +// sortSessionsByCreationTime sorts sessions by creation timestamp (newest first) +func sortSessionsByCreationTime(sessions []types.AgenticSession) { + // Use sort.Slice for O(n log n) performance + sort.Slice(sessions, func(i, j int) bool { + ts1 := getSessionCreationTimestamp(sessions[i]) + ts2 := getSessionCreationTimestamp(sessions[j]) + // Sort descending (newest first) - RFC3339 timestamps sort lexicographically + return ts1 > ts2 + }) +} + +// getSessionCreationTimestamp extracts the creation timestamp from session metadata +func getSessionCreationTimestamp(session types.AgenticSession) string { + if ts, ok := session.Metadata["creationTimestamp"].(string); ok { + return ts + } + return "" +} + +// paginateSessions applies offset/limit pagination to the session list +func paginateSessions(sessions []types.AgenticSession, offset, limit int) ([]types.AgenticSession, bool, int) { + total := len(sessions) + + // Handle offset beyond available items + if offset >= total { + return []types.AgenticSession{}, false, 0 + } + + // Calculate end index + end := offset + limit + if end > total { + end = total + } + + // Determine if there are more items + hasMore := end < total + nextOffset := end + + return sessions[offset:end], hasMore, nextOffset } func CreateSession(c *gin.Context) { diff --git a/components/backend/types/common.go b/components/backend/types/common.go index 930046f18..13745df0b 100644 --- a/components/backend/types/common.go +++ b/components/backend/types/common.go @@ -94,3 +94,41 @@ func StringPtr(s string) *string { func IntPtr(i int) *int { return &i } + +// PaginationParams represents common pagination request parameters +type PaginationParams struct { + Limit int `form:"limit"` // Number of items per page (default: 20, max: 100) + Offset int `form:"offset"` // Offset for offset-based pagination + Continue string `form:"continue"` // Continuation token for k8s-style pagination + Search string `form:"search"` // Search/filter term +} + +// PaginatedResponse is a generic paginated response structure +type PaginatedResponse struct { + Items interface{} `json:"items"` + TotalCount int `json:"totalCount"` + Limit int `json:"limit"` + Offset int `json:"offset"` + HasMore bool `json:"hasMore"` + Continue string `json:"continue,omitempty"` // For k8s-style pagination + NextOffset *int `json:"nextOffset,omitempty"` // For offset-based pagination +} + +// DefaultPaginationLimit is the default number of items per page +const DefaultPaginationLimit = 20 + +// MaxPaginationLimit is the maximum allowed items per page +const MaxPaginationLimit = 100 + +// NormalizePaginationParams ensures pagination params are within valid bounds +func NormalizePaginationParams(params *PaginationParams) { + if params.Limit <= 0 { + params.Limit = DefaultPaginationLimit + } + if params.Limit > MaxPaginationLimit { + params.Limit = MaxPaginationLimit + } + if params.Offset < 0 { + params.Offset = 0 + } +} diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts index 64b24c6f2..7bef7fd5c 100644 --- a/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/route.ts @@ -9,7 +9,10 @@ export async function GET( try { const { name } = await params; const headers = await buildForwardHeadersAsync(request); - const response = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions`, { headers }); + // Forward query parameters to backend + const url = new URL(request.url); + const queryString = url.search; + const response = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions${queryString}`, { headers }); const text = await response.text(); return new Response(text, { status: response.status, headers: { 'Content-Type': 'application/json' } }); } catch (error) { diff --git a/components/frontend/src/app/api/projects/route.ts b/components/frontend/src/app/api/projects/route.ts index 6fcc7d24c..f30eddd4f 100644 --- a/components/frontend/src/app/api/projects/route.ts +++ b/components/frontend/src/app/api/projects/route.ts @@ -6,7 +6,9 @@ export async function GET(request: NextRequest) { try { const headers = await buildForwardHeadersAsync(request); - const response = await fetch(`${BACKEND_URL}/projects`, { + // Forward query parameters to backend + const queryString = request.nextUrl.search; + const response = await fetch(`${BACKEND_URL}/projects${queryString}`, { method: 'GET', headers, }); diff --git a/components/frontend/src/app/projects/page.tsx b/components/frontend/src/app/projects/page.tsx index 8cec3a3a5..56687e094 100644 --- a/components/frontend/src/app/projects/page.tsx +++ b/components/frontend/src/app/projects/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import { formatDistanceToNow } from 'date-fns'; -import { Plus, RefreshCw, Trash2, FolderOpen, Loader2 } from 'lucide-react'; +import { Plus, RefreshCw, Trash2, FolderOpen, Loader2, Search, ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -22,7 +22,8 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { useProjects, useDeleteProject } from '@/services/queries'; +import { Input } from '@/components/ui/input'; +import { useProjectsPaginated, useDeleteProject } from '@/services/queries'; import { PageHeader } from '@/components/page-header'; import { EmptyState } from '@/components/empty-state'; import { ErrorMessage } from '@/components/error-message'; @@ -30,20 +31,68 @@ import { DestructiveConfirmationDialog } from '@/components/confirmation-dialog' import { CreateWorkspaceDialog } from '@/components/create-workspace-dialog'; import { successToast, errorToast } from '@/hooks/use-toast'; import type { Project } from '@/types/api'; +import { DEFAULT_PAGE_SIZE } from '@/types/api'; +import { useDebounce } from '@/hooks/use-debounce'; export default function ProjectsPage() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [projectToDelete, setProjectToDelete] = useState(null); const [showCreateDialog, setShowCreateDialog] = useState(false); - // React Query hooks - const { data: projects = [], isLoading, error, refetch } = useProjects(); + // Pagination and search state + const [searchInput, setSearchInput] = useState(''); + const [offset, setOffset] = useState(0); + const limit = DEFAULT_PAGE_SIZE; + + // Debounce search to avoid too many API calls + const debouncedSearch = useDebounce(searchInput, 300); + + // Reset offset when search changes + useEffect(() => { + setOffset(0); + }, [debouncedSearch]); + + // React Query hooks with pagination + const { + data: paginatedData, + isLoading, + isFetching, + error, + refetch, + } = useProjectsPaginated({ + limit, + offset, + search: debouncedSearch || undefined, + }); + + const projects = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const hasMore = paginatedData?.hasMore ?? false; + const currentPage = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(totalCount / limit); + const deleteProjectMutation = useDeleteProject(); const handleRefreshClick = () => { refetch(); }; + const handleNextPage = () => { + if (hasMore) { + setOffset(offset + limit); + } + }; + + const handlePrevPage = () => { + if (offset > 0) { + setOffset(Math.max(0, offset - limit)); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchInput(e.target.value); + }; + const openDeleteDialog = (project: Project) => { setProjectToDelete(project); setShowDeleteDialog(true); @@ -68,8 +117,8 @@ export default function ProjectsPage() { }); }; - // Loading state - if (isLoading) { + // Initial loading state (no data yet) + if (isLoading && !paginatedData) { return (
@@ -123,10 +172,10 @@ export default function ProjectsPage() { @@ -136,9 +185,19 @@ export default function ProjectsPage() {
+ {/* Search input */} +
+ + +
- {projects.length === 0 ? ( + {projects.length === 0 && !debouncedSearch ? ( setShowCreateDialog(true), }} /> + ) : projects.length === 0 && debouncedSearch ? ( + ) : ( -
- - - - Name - - Description - - - Created - - Actions - - - - {projects.map((project) => ( - - - -
-
- {project.displayName || project.name} -
-
- {project.name} + <> +
+
+ + + Name + + Description + + + Created + + Actions + + + + {projects.map((project) => ( + + + +
+
+ {project.displayName || project.name} +
+
+ {project.name} +
- - -
- - - {project.description || '—'} - - - - {project.creationTimestamp ? ( - - {formatDistanceToNow( - new Date(project.creationTimestamp), - { addSuffix: true } - )} + + + + + {project.description || '—'} - ) : ( - - )} - - - - -
- ))} -
-
-
+ + + {project.creationTimestamp ? ( + + {formatDistanceToNow( + new Date(project.creationTimestamp), + { addSuffix: true } + )} + + ) : ( + + )} + + + + + + ))} + + + + + {/* Pagination controls */} + {totalPages > 1 && ( +
+
+ Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount} workspaces +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} + )}
diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx index 1e924968b..034bfc81a 100644 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -1,26 +1,59 @@ 'use client'; +import { useState, useEffect } from 'react'; import { formatDistanceToNow } from 'date-fns'; -import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain } from 'lucide-react'; +import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, ChevronLeft, ChevronRight } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; import { EmptyState } from '@/components/empty-state'; import { SessionPhaseBadge } from '@/components/status-badge'; import { CreateSessionDialog } from '@/components/create-session-dialog'; -import { useSessions, useStopSession, useDeleteSession, useContinueSession } from '@/services/queries'; +import { useSessionsPaginated, useStopSession, useDeleteSession, useContinueSession } from '@/services/queries'; import { successToast, errorToast } from '@/hooks/use-toast'; +import { useDebounce } from '@/hooks/use-debounce'; +import { DEFAULT_PAGE_SIZE } from '@/types/api'; type SessionsSectionProps = { projectName: string; }; export function SessionsSection({ projectName }: SessionsSectionProps) { - const { data: sessions = [], isLoading, refetch } = useSessions(projectName); + // Pagination and search state + const [searchInput, setSearchInput] = useState(''); + const [offset, setOffset] = useState(0); + const limit = DEFAULT_PAGE_SIZE; + + // Debounce search to avoid too many API calls + const debouncedSearch = useDebounce(searchInput, 300); + + // Reset offset when search changes + useEffect(() => { + setOffset(0); + }, [debouncedSearch]); + + // React Query hooks with pagination + const { + data: paginatedData, + isFetching, + refetch, + } = useSessionsPaginated(projectName, { + limit, + offset, + search: debouncedSearch || undefined, + }); + + const sessions = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const hasMore = paginatedData?.hasMore ?? false; + const currentPage = Math.floor(offset / limit) + 1; + const totalPages = Math.ceil(totalCount / limit); + const stopSessionMutation = useStopSession(); const deleteSessionMutation = useDeleteSession(); const continueSessionMutation = useContinueSession(); @@ -68,11 +101,21 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { ); }; - const sortedSessions = [...sessions].sort((a, b) => { - const aTime = a?.metadata?.creationTimestamp ? new Date(a.metadata.creationTimestamp).getTime() : 0; - const bTime = b?.metadata?.creationTimestamp ? new Date(b.metadata.creationTimestamp).getTime() : 0; - return bTime - aTime; - }); + const handleNextPage = () => { + if (hasMore) { + setOffset(offset + limit); + } + }; + + const handlePrevPage = () => { + if (offset > 0) { + setOffset(Math.max(0, offset - limit)); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchInput(e.target.value); + }; return ( @@ -85,8 +128,8 @@ export function SessionsSection({ projectName }: SessionsSectionProps) {
-
+ {/* Search input */} +
+ + +
- {sessions.length === 0 ? ( + {sessions.length === 0 && !debouncedSearch ? ( + ) : sessions.length === 0 && debouncedSearch ? ( + ) : ( -
- - - - Name - Status - Mode - Model - Created - Cost - Actions - - - - {sortedSessions.map((session) => { - const sessionName = session.metadata.name; - const phase = session.status?.phase || 'Pending'; - const isActionPending = - (stopSessionMutation.isPending && stopSessionMutation.variables?.sessionName === sessionName) || - (deleteSessionMutation.isPending && deleteSessionMutation.variables?.sessionName === sessionName); + <> +
+
+ + + Name + Status + Mode + Model + Created + Cost + Actions + + + + {sessions.map((session) => { + const sessionName = session.metadata.name; + const phase = session.status?.phase || 'Pending'; + const isActionPending = + (stopSessionMutation.isPending && stopSessionMutation.variables?.sessionName === sessionName) || + (deleteSessionMutation.isPending && deleteSessionMutation.variables?.sessionName === sessionName); - return ( - - - -
-
{session.spec.displayName || session.metadata.name}
- {session.spec.displayName && ( -
{session.metadata.name}
- )} -
- -
- - - - - - {session.spec?.interactive ? 'Interactive' : 'Headless'} - - - - - {session.spec.llmSettings.model} - - - - {session.metadata?.creationTimestamp && - formatDistanceToNow(new Date(session.metadata.creationTimestamp), { addSuffix: true })} - - - {/* total_cost_usd removed from simplified status */} - - - - {isActionPending ? ( - - ) : ( - - )} - -
- ); - })} -
-
-
+ return ( + + + +
+
{session.spec.displayName || session.metadata.name}
+ {session.spec.displayName && ( +
{session.metadata.name}
+ )} +
+ +
+ + + + + + {session.spec?.interactive ? 'Interactive' : 'Headless'} + + + + + {session.spec.llmSettings.model} + + + + {session.metadata?.creationTimestamp && + formatDistanceToNow(new Date(session.metadata.creationTimestamp), { addSuffix: true })} + + + {/* total_cost_usd removed from simplified status */} + + + + {isActionPending ? ( + + ) : ( + + )} + +
+ ); + })} + + + + + {/* Pagination controls */} + {totalPages > 1 && ( +
+
+ Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount} sessions +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} + )}
diff --git a/components/frontend/src/services/api/projects.ts b/components/frontend/src/services/api/projects.ts index ca1ddf2df..33231c616 100644 --- a/components/frontend/src/services/api/projects.ts +++ b/components/frontend/src/services/api/projects.ts @@ -8,16 +8,36 @@ import type { Project, CreateProjectRequest, UpdateProjectRequest, - ListProjectsResponse, + ListProjectsPaginatedResponse, DeleteProjectResponse, PermissionAssignment, + PaginationParams, } from '@/types/api'; /** - * List all projects + * List projects with pagination support + */ +export async function listProjectsPaginated( + params: PaginationParams = {} +): Promise { + const searchParams = new URLSearchParams(); + if (params.limit) searchParams.set('limit', params.limit.toString()); + if (params.offset) searchParams.set('offset', params.offset.toString()); + if (params.search) searchParams.set('search', params.search); + + const queryString = searchParams.toString(); + const url = queryString ? `/projects?${queryString}` : '/projects'; + + return apiClient.get(url); +} + +/** + * List all projects (legacy - fetches all without pagination) + * @deprecated Use listProjectsPaginated for better performance */ export async function listProjects(): Promise { - const response = await apiClient.get('/projects'); + // For backward compatibility, fetch with a high limit + const response = await listProjectsPaginated({ limit: 100 }); return response.items; } diff --git a/components/frontend/src/services/api/sessions.ts b/components/frontend/src/services/api/sessions.ts index a7c5f52df..5ec04ab17 100644 --- a/components/frontend/src/services/api/sessions.ts +++ b/components/frontend/src/services/api/sessions.ts @@ -9,27 +9,44 @@ import type { CreateAgenticSessionRequest, CreateAgenticSessionResponse, GetAgenticSessionResponse, - ListAgenticSessionsResponse, + ListAgenticSessionsPaginatedResponse, StopAgenticSessionRequest, StopAgenticSessionResponse, CloneAgenticSessionRequest, CloneAgenticSessionResponse, Message, GetSessionMessagesResponse, + PaginationParams, } from '@/types/api'; /** - * List sessions for a project + * List sessions for a project with pagination support + */ +export async function listSessionsPaginated( + projectName: string, + params: PaginationParams = {} +): Promise { + const searchParams = new URLSearchParams(); + if (params.limit) searchParams.set('limit', params.limit.toString()); + if (params.offset) searchParams.set('offset', params.offset.toString()); + if (params.search) searchParams.set('search', params.search); + + const queryString = searchParams.toString(); + const url = queryString + ? `/projects/${projectName}/agentic-sessions?${queryString}` + : `/projects/${projectName}/agentic-sessions`; + + return apiClient.get(url); +} + +/** + * List sessions for a project (legacy - fetches all without pagination) + * @deprecated Use listSessionsPaginated for better performance */ export async function listSessions(projectName: string): Promise { - const response = await apiClient.get( - `/projects/${projectName}/agentic-sessions` - ); - // Handle both wrapped and unwrapped responses - if (Array.isArray(response)) { - return response; - } - return response.items || []; + // For backward compatibility, fetch with a high limit + const response = await listSessionsPaginated(projectName, { limit: 100 }); + return response.items; } /** diff --git a/components/frontend/src/services/queries/use-projects.ts b/components/frontend/src/services/queries/use-projects.ts index 21a6e24ec..fea22a0b6 100644 --- a/components/frontend/src/services/queries/use-projects.ts +++ b/components/frontend/src/services/queries/use-projects.ts @@ -2,13 +2,14 @@ * React Query hooks for projects */ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import * as projectsApi from '../api/projects'; import type { Project, CreateProjectRequest, UpdateProjectRequest, PermissionAssignment, + PaginationParams, } from '@/types/api'; /** @@ -17,14 +18,26 @@ import type { export const projectKeys = { all: ['projects'] as const, lists: () => [...projectKeys.all, 'list'] as const, - list: () => [...projectKeys.lists()] as const, + list: (params?: PaginationParams) => [...projectKeys.lists(), params ?? {}] as const, details: () => [...projectKeys.all, 'detail'] as const, detail: (name: string) => [...projectKeys.details(), name] as const, permissions: (name: string) => [...projectKeys.detail(name), 'permissions'] as const, }; /** - * Hook to fetch all projects + * Hook to fetch projects with pagination support + */ +export function useProjectsPaginated(params: PaginationParams = {}) { + return useQuery({ + queryKey: projectKeys.list(params), + queryFn: () => projectsApi.listProjectsPaginated(params), + placeholderData: keepPreviousData, // Keep previous data while fetching new page + }); +} + +/** + * Hook to fetch all projects (legacy - no pagination) + * @deprecated Use useProjectsPaginated for better performance */ export function useProjects() { return useQuery({ diff --git a/components/frontend/src/services/queries/use-sessions.ts b/components/frontend/src/services/queries/use-sessions.ts index 83f8dd2d8..02096c51b 100644 --- a/components/frontend/src/services/queries/use-sessions.ts +++ b/components/frontend/src/services/queries/use-sessions.ts @@ -2,13 +2,14 @@ * React Query hooks for agentic sessions */ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import * as sessionsApi from '../api/sessions'; import type { AgenticSession, CreateAgenticSessionRequest, StopAgenticSessionRequest, CloneAgenticSessionRequest, + PaginationParams, } from '@/types/api'; /** @@ -17,7 +18,8 @@ import type { export const sessionKeys = { all: ['sessions'] as const, lists: () => [...sessionKeys.all, 'list'] as const, - list: (projectName: string) => [...sessionKeys.lists(), projectName] as const, + list: (projectName: string, params?: PaginationParams) => + [...sessionKeys.lists(), projectName, params ?? {}] as const, details: () => [...sessionKeys.all, 'detail'] as const, detail: (projectName: string, sessionName: string) => [...sessionKeys.details(), projectName, sessionName] as const, @@ -26,7 +28,20 @@ export const sessionKeys = { }; /** - * Hook to fetch sessions for a project + * Hook to fetch sessions for a project with pagination support + */ +export function useSessionsPaginated(projectName: string, params: PaginationParams = {}) { + return useQuery({ + queryKey: sessionKeys.list(projectName, params), + queryFn: () => sessionsApi.listSessionsPaginated(projectName, params), + enabled: !!projectName, + placeholderData: keepPreviousData, // Keep previous data while fetching new page + }); +} + +/** + * Hook to fetch sessions for a project (legacy - no pagination) + * @deprecated Use useSessionsPaginated for better performance */ export function useSessions(projectName: string) { return useQuery({ diff --git a/components/frontend/src/types/api/common.ts b/components/frontend/src/types/api/common.ts index c1b27307e..6244ea1ad 100644 --- a/components/frontend/src/types/api/common.ts +++ b/components/frontend/src/types/api/common.ts @@ -15,6 +15,35 @@ export type ApiError = { export type ApiResult = ApiResponse | ApiError; +/** + * Pagination request parameters + */ +export type PaginationParams = { + limit?: number; + offset?: number; + search?: string; + continue?: string; +}; + +/** + * Paginated response structure from the backend + */ +export type PaginatedResponse = { + items: T[]; + totalCount: number; + limit: number; + offset: number; + hasMore: boolean; + continue?: string; + nextOffset?: number; +}; + +/** + * Default pagination values + */ +export const DEFAULT_PAGE_SIZE = 20; +export const MAX_PAGE_SIZE = 100; + export function isApiError(result: ApiResult): result is ApiError { return 'error' in result && result.error !== undefined; } diff --git a/components/frontend/src/types/api/projects.ts b/components/frontend/src/types/api/projects.ts index dcf6ad29a..c9a0e67e1 100644 --- a/components/frontend/src/types/api/projects.ts +++ b/components/frontend/src/types/api/projects.ts @@ -41,10 +41,25 @@ export type UpdateProjectResponse = { project: Project; }; +/** + * Legacy response type (deprecated - use PaginatedResponse) + */ export type ListProjectsResponse = { items: Project[]; }; +/** + * Paginated projects response from the backend + */ +export type ListProjectsPaginatedResponse = { + items: Project[]; + totalCount: number; + limit: number; + offset: number; + hasMore: boolean; + nextOffset?: number; +}; + export type GetProjectResponse = { project: Project; }; diff --git a/components/frontend/src/types/api/sessions.ts b/components/frontend/src/types/api/sessions.ts index bacf0a08c..2140165d5 100644 --- a/components/frontend/src/types/api/sessions.ts +++ b/components/frontend/src/types/api/sessions.ts @@ -133,10 +133,25 @@ export type GetAgenticSessionResponse = { session: AgenticSession; }; +/** + * Legacy response type (deprecated - use PaginatedResponse) + */ export type ListAgenticSessionsResponse = { items: AgenticSession[]; }; +/** + * Paginated sessions response from the backend + */ +export type ListAgenticSessionsPaginatedResponse = { + items: AgenticSession[]; + totalCount: number; + limit: number; + offset: number; + hasMore: boolean; + nextOffset?: number; +}; + export type StopAgenticSessionRequest = { reason?: string; };