diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index f49613ee5..2e3b4ca1c 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -687,8 +687,18 @@ func ListSessions(c *gin.Context) { sessions = filterSessionsBySearch(sessions, params.Search) } - // Sort by creation timestamp (newest first) - sortSessionsByCreationTime(sessions) + // Apply phase filter if provided + if params.Phase != "" { + sessions = filterSessionsByPhase(sessions, params.Phase) + } + + // Apply userId filter if provided + if params.UserID != "" { + sessions = filterSessionsByUserID(sessions, params.UserID) + } + + // Sort sessions + sortSessions(sessions, params.SortBy, params.SortDirection) // Apply pagination totalCount := len(sessions) @@ -747,14 +757,75 @@ func filterSessionsBySearch(sessions []types.AgenticSession, search string) []ty return filtered } -// sortSessionsByCreationTime sorts sessions by creation timestamp (newest first) -func sortSessionsByCreationTime(sessions []types.AgenticSession) { - // Use sort.Slice for O(n log n) performance +// filterSessionsByPhase filters sessions by one or more comma-separated phase values +func filterSessionsByPhase(sessions []types.AgenticSession, phaseParam string) []types.AgenticSession { + if phaseParam == "" { + return sessions + } + phases := strings.Split(phaseParam, ",") + phaseSet := make(map[string]bool, len(phases)) + for _, p := range phases { + phaseSet[strings.TrimSpace(p)] = true + } + filtered := make([]types.AgenticSession, 0, len(sessions)) + for _, session := range sessions { + if session.Status != nil && phaseSet[session.Status.Phase] { + filtered = append(filtered, session) + } + } + return filtered +} + +// filterSessionsByUserID filters sessions by creator userId +func filterSessionsByUserID(sessions []types.AgenticSession, userID string) []types.AgenticSession { + if userID == "" { + return sessions + } + filtered := make([]types.AgenticSession, 0, len(sessions)) + for _, session := range sessions { + if session.Spec.UserContext != nil && session.Spec.UserContext.UserID == userID { + filtered = append(filtered, session) + } + } + return filtered +} + +// sortSessions sorts sessions by the given column and direction +func sortSessions(sessions []types.AgenticSession, sortBy, sortDirection string) { + if sortBy == "" { + sortBy = "created" + } + if sortDirection == "" { + sortDirection = "desc" + } + ascending := sortDirection == "asc" + 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 + var vi, vj string + switch sortBy { + case "name": + ni := sessions[i].Spec.DisplayName + if strings.TrimSpace(ni) == "" { + if name, ok := sessions[i].Metadata["name"].(string); ok { + ni = name + } + } + nj := sessions[j].Spec.DisplayName + if strings.TrimSpace(nj) == "" { + if name, ok := sessions[j].Metadata["name"].(string); ok { + nj = name + } + } + vi = strings.ToLower(ni) + vj = strings.ToLower(nj) + default: // "created" or any unrecognized value + vi = getSessionCreationTimestamp(sessions[i]) + vj = getSessionCreationTimestamp(sessions[j]) + } + if ascending { + return vi < vj + } + return vi > vj }) } diff --git a/components/backend/handlers/sessions_test.go b/components/backend/handlers/sessions_test.go old mode 100644 new mode 100755 index 529748d7a..a99890690 --- a/components/backend/handlers/sessions_test.go +++ b/components/backend/handlers/sessions_test.go @@ -252,6 +252,204 @@ var _ = Describe("Sessions Handler", Label(test_constants.LabelUnit, test_consta logger.Log("Unauthorized project returned empty list") }) }) + + Context("With phase filter", func() { + BeforeEach(func() { + createTestSessionWithOptions("running-"+randomName, testNamespace, "Running", "", k8sUtils) + createTestSessionWithOptions("completed-"+randomName, testNamespace, "Completed", "", k8sUtils) + createTestSessionWithOptions("failed-"+randomName, testNamespace, "Failed", "", k8sUtils) + }) + + It("Should filter by single phase", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?phase=Running", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(1)) + }) + + It("Should filter by multiple phases", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?phase=Running,Failed", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(2)) + }) + + It("Should return all sessions when no phase filter", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(3)) + }) + }) + + Context("With userId filter", func() { + BeforeEach(func() { + createTestSessionWithOptions("user1-session-"+randomName, testNamespace, "Running", "user-1", k8sUtils) + createTestSessionWithOptions("user2-session-"+randomName, testNamespace, "Running", "user-2", k8sUtils) + createTestSessionWithOptions("no-user-session-"+randomName, testNamespace, "Running", "", k8sUtils) + }) + + It("Should filter by userId", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?userId=user-1", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(1)) + }) + + It("Should return all sessions when no userId filter", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(3)) + }) + }) + + Context("With sort direction", func() { + BeforeEach(func() { + createTestSessionWithOptions("alpha-"+randomName, testNamespace, "Running", "", k8sUtils) + time.Sleep(100 * time.Millisecond) + createTestSessionWithOptions("beta-"+randomName, testNamespace, "Running", "", k8sUtils) + }) + + It("Should sort ascending when sortDirection=asc", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?sortDirection=asc", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(2)) + // First item should be the oldest (alpha was created first) + firstItem := items[0].(map[string]interface{}) + metadata := firstItem["metadata"].(map[string]interface{}) + name := metadata["name"].(string) + Expect(name).To(HavePrefix("alpha-")) + }) + + It("Should sort descending by default", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(2)) + // First item should be the newest (beta was created second) + firstItem := items[0].(map[string]interface{}) + metadata := firstItem["metadata"].(map[string]interface{}) + name := metadata["name"].(string) + Expect(name).To(HavePrefix("beta-")) + }) + }) + + Context("With sortBy=name", func() { + BeforeEach(func() { + createTestSessionWithOptions("alpha-"+randomName, testNamespace, "Running", "", k8sUtils) + time.Sleep(100 * time.Millisecond) + createTestSessionWithOptions("beta-"+randomName, testNamespace, "Running", "", k8sUtils) + }) + + It("Should sort by name ascending", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?sortBy=name&sortDirection=asc", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(2)) + firstName := items[0].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string) + secondName := items[1].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string) + Expect(firstName).To(HavePrefix("alpha-")) + Expect(secondName).To(HavePrefix("beta-")) + }) + + It("Should sort by name descending", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?sortBy=name&sortDirection=desc", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(2)) + firstName := items[0].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string) + secondName := items[1].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string) + Expect(firstName).To(HavePrefix("beta-")) + Expect(secondName).To(HavePrefix("alpha-")) + }) + }) + + Context("With combined filters", func() { + BeforeEach(func() { + createTestSessionWithOptions("running-match-"+randomName, testNamespace, "Running", "user-1", k8sUtils) + createTestSessionWithOptions("completed-match-"+randomName, testNamespace, "Completed", "user-1", k8sUtils) + createTestSessionWithOptions("running-other-"+randomName, testNamespace, "Running", "user-2", k8sUtils) + }) + + It("Should apply both phase and userId filters", func() { + context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?phase=Running&userId=user-1", nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + + ListSessions(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + items := response["items"].([]interface{}) + Expect(items).To(HaveLen(1)) + }) + }) }) Describe("CreateSession", func() { @@ -981,3 +1179,43 @@ func createTestSession(name, namespace string, k8sUtils *test_utils.K8sTestUtils } return created } + +func createTestSessionWithOptions(name, namespace, phase, userId string, k8sUtils *test_utils.K8sTestUtils) *unstructured.Unstructured { + session := &unstructured.Unstructured{} + session.SetAPIVersion("vteam.ambient-code/v1alpha1") + session.SetKind("AgenticSession") + session.SetName(name) + session.SetNamespace(namespace) + session.SetLabels(map[string]string{"test-framework": "ambient-code-backend"}) + + unstructured.SetNestedField(session.Object, "Test prompt for "+name, "spec", "initialPrompt") + repos := []interface{}{ + map[string]interface{}{"url": "https://github.com/test/repo.git", "branch": "main"}, + } + unstructured.SetNestedSlice(session.Object, repos, "spec", "repos") + + if phase != "" { + unstructured.SetNestedField(session.Object, phase, "status", "phase") + } else { + unstructured.SetNestedField(session.Object, "Pending", "status", "phase") + } + + if userId != "" { + unstructured.SetNestedField(session.Object, userId, "spec", "userContext", "userId") + } + + sessionGVR := schema.GroupVersionResource{ + Group: "vteam.ambient-code", + Version: "v1alpha1", + Resource: "agenticsessions", + } + + created, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(namespace).Create( + context.Background(), session, v1.CreateOptions{}, + ) + if err != nil { + Fail(fmt.Sprintf("Failed to create test session %s: %v", name, err)) + return nil + } + return created +} diff --git a/components/backend/types/common.go b/components/backend/types/common.go old mode 100644 new mode 100755 index 13745df0b..51e5a817b --- a/components/backend/types/common.go +++ b/components/backend/types/common.go @@ -97,10 +97,14 @@ func IntPtr(i int) *int { // 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 + 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 + Phase string `form:"phase"` // Comma-separated phases to filter by + UserID string `form:"userId"` // Filter by creator userId + SortBy string `form:"sortBy"` // Sort column (default: "created") + SortDirection string `form:"sortDirection"` // "asc" or "desc" (default: "desc") } // PaginatedResponse is a generic paginated response structure diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx old mode 100644 new mode 100755 index cbc02854e..75eb3a235 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { formatDistanceToNow } from 'date-fns'; -import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, Pencil, Clock, Cpu, MessageSquare, NotepadText, User } from 'lucide-react'; +import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, Pencil, Clock, Cpu, MessageSquare, NotepadText, User, ArrowUp, ArrowDown } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; @@ -11,6 +11,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; import { Pagination, PaginationContent, @@ -28,6 +30,7 @@ import { deriveAgentStatusFromPhase } from '@/hooks/use-agent-status'; import { EditSessionNameDialog } from '@/components/edit-session-name-dialog'; import { useSessionsPaginated, useStopSession, useDeleteSession, useContinueSession, useUpdateSessionDisplayName, useRunnerTypes } from '@/services/queries'; +import { useCurrentUser } from '@/services/queries/use-auth'; import { toast } from 'sonner'; import { useWorkspaceList } from '@/services/queries/use-workspace'; import { useProjectAccess } from '@/services/queries/use-project-access'; @@ -66,18 +69,25 @@ type SessionsSectionProps = { }; export function SessionsSection({ projectName }: SessionsSectionProps) { - // Pagination and search state + // Pagination, search, and filter state const [searchInput, setSearchInput] = useState(''); const [offset, setOffset] = useState(0); const limit = DEFAULT_PAGE_SIZE; + const [phaseFilter, setPhaseFilter] = useState(''); + const [mySessionsOnly, setMySessionsOnly] = useState(false); + const [sortBy, setSortBy] = useState<'created' | 'name'>('created'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); // Debounce search to avoid too many API calls const debouncedSearch = useDebounce(searchInput, 300); - // Reset offset when search changes + // Current user for "My sessions" filter + const { data: currentUser, isLoading: isCurrentUserLoading } = useCurrentUser(); + + // Reset offset when search or filters change useEffect(() => { setOffset(0); - }, [debouncedSearch]); + }, [debouncedSearch, phaseFilter, mySessionsOnly, sortBy, sortDirection]); // Access control (default-deny until role is resolved) const { data: access } = useProjectAccess(projectName); @@ -104,6 +114,10 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { limit, offset, search: debouncedSearch || undefined, + phase: phaseFilter || undefined, + userId: mySessionsOnly && currentUser?.userId ? currentUser.userId : undefined, + sortBy, + sortDirection, }); const sessions = paginatedData?.items ?? []; @@ -228,41 +242,153 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { )} - {/* Search input */} -
- - + {/* Search and filters */} +
+
+ + +
+ + + +
+ + {/* Active filter chips */} + {(phaseFilter || mySessionsOnly) && ( +
+ Filters: + {phaseFilter && ( + setPhaseFilter('')}> + {phaseFilter === 'Running,Pending,Creating' ? 'Active' : phaseFilter === 'Completed,Stopped' ? 'Completed' : phaseFilter} + × + + )} + {mySessionsOnly && ( + setMySessionsOnly(false)}> + My sessions + × + + )} +
+ )} - {sessions.length === 0 && !debouncedSearch ? ( - - ) : sessions.length === 0 && debouncedSearch ? ( - - ) : ( + {(() => { + const hasActiveFilters = !!debouncedSearch || !!phaseFilter || mySessionsOnly; + if (sessions.length === 0 && !hasActiveFilters) { + return ( + + ); + } + if (sessions.length === 0 && hasActiveFilters) { + return ( + + ); + } + return null; + })()} + {sessions.length > 0 && ( <>
- Name + { + if (sortBy === 'name') { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy('name'); + setSortDirection('asc'); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (sortBy === 'name') { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy('name'); + setSortDirection('asc'); + } + } + }} + > +
+ Name + {sortBy === 'name' && (sortDirection === 'asc' ? : )} +
+
Status Model - Created + { + if (sortBy === 'created') { + setSortDirection(prev => prev === 'desc' ? 'asc' : 'desc'); + } else { + setSortBy('created'); + setSortDirection('desc'); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (sortBy === 'created') { + setSortDirection(prev => prev === 'desc' ? 'asc' : 'desc'); + } else { + setSortBy('created'); + setSortDirection('desc'); + } + } + }} + > +
+ Created + {sortBy === 'created' && (sortDirection === 'desc' ? : )} +
+
Creator Artifacts Actions diff --git a/components/frontend/src/services/api/sessions.ts b/components/frontend/src/services/api/sessions.ts index 24ff0cc62..a453053e0 100755 --- a/components/frontend/src/services/api/sessions.ts +++ b/components/frontend/src/services/api/sessions.ts @@ -55,6 +55,10 @@ export async function listSessionsPaginated( if (params.offset) searchParams.set('offset', params.offset.toString()); if (params.search) searchParams.set('search', params.search); if (params.continue) searchParams.set('continue', params.continue); + if (params.phase) searchParams.set('phase', params.phase); + if (params.userId) searchParams.set('userId', params.userId); + if (params.sortBy) searchParams.set('sortBy', params.sortBy); + if (params.sortDirection) searchParams.set('sortDirection', params.sortDirection); const queryString = searchParams.toString(); const url = queryString diff --git a/components/frontend/src/services/queries/__tests__/use-sessions.test.ts b/components/frontend/src/services/queries/__tests__/use-sessions.test.ts index 627913243..a61b2c93b 100755 --- a/components/frontend/src/services/queries/__tests__/use-sessions.test.ts +++ b/components/frontend/src/services/queries/__tests__/use-sessions.test.ts @@ -68,6 +68,11 @@ describe('sessionKeys', () => { expect(sessionKeys.export('proj', 'sess')).toEqual(['v1', 'sessions', 'detail', 'proj', 'sess', 'export']); expect(sessionKeys.reposStatus('proj', 'sess')).toEqual(['v1', 'sessions', 'detail', 'proj', 'sess', 'repos-status']); }); + + it('includes filter params in query key', () => { + const key = sessionKeys.list('proj', { phase: 'Running', userId: 'user-1' }); + expect(key).toEqual(['v1', 'sessions', 'list', 'proj', { phase: 'Running', userId: 'user-1' }]); + }); }); describe('useSessions', () => { @@ -102,6 +107,36 @@ describe('useSessionsPaginated', () => { expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { limit: 10 }); expect(result.current.data?.totalCount).toBe(1); }); + + it('passes phase filter to port', async () => { + const fakePort = createFakeSessionsPort(); + const { result } = renderHook( + () => useSessionsPaginated('proj', { limit: 10, phase: 'Running,Pending' }, fakePort), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { limit: 10, phase: 'Running,Pending' }); + }); + + it('passes userId filter to port', async () => { + const fakePort = createFakeSessionsPort(); + const { result } = renderHook( + () => useSessionsPaginated('proj', { limit: 10, userId: 'user-1' }, fakePort), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { limit: 10, userId: 'user-1' }); + }); + + it('passes sort params to port', async () => { + const fakePort = createFakeSessionsPort(); + const { result } = renderHook( + () => useSessionsPaginated('proj', { sortBy: 'created', sortDirection: 'asc' }, fakePort), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { sortBy: 'created', sortDirection: 'asc' }); + }); }); describe('useSession', () => { diff --git a/components/frontend/src/types/api/common.ts b/components/frontend/src/types/api/common.ts old mode 100644 new mode 100755 index 6244ea1ad..9e197b8d3 --- a/components/frontend/src/types/api/common.ts +++ b/components/frontend/src/types/api/common.ts @@ -23,6 +23,10 @@ export type PaginationParams = { offset?: number; search?: string; continue?: string; + phase?: string; + userId?: string; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; }; /**