diff --git a/agent-manager-service/controllers/agent_controller.go b/agent-manager-service/controllers/agent_controller.go index 642fe17ef..406983d63 100644 --- a/agent-manager-service/controllers/agent_controller.go +++ b/agent-manager-service/controllers/agent_controller.go @@ -26,6 +26,7 @@ import ( "github.com/wso2/ai-agent-management-platform/agent-manager-service/middleware/jwtassertion" "github.com/wso2/ai-agent-management-platform/agent-manager-service/middleware/logger" + "github.com/wso2/ai-agent-management-platform/agent-manager-service/models" "github.com/wso2/ai-agent-management-platform/agent-manager-service/services" "github.com/wso2/ai-agent-management-platform/agent-manager-service/spec" "github.com/wso2/ai-agent-management-platform/agent-manager-service/utils" @@ -125,11 +126,44 @@ func (c *agentController) ListAgents(w http.ResponseWriter, r *http.Request) { return } + // Parse filter parameters + search := r.URL.Query().Get("search") + provisioningType := r.URL.Query().Get("provisioningType") + sortBy := r.URL.Query().Get("sortBy") + sortOrder := r.URL.Query().Get("sortOrder") + + // Validate filter parameters + if !models.IsValidSortBy(sortBy) { + log.Error("ListAgents: invalid sortBy parameter", "sortBy", sortBy) + utils.WriteErrorResponse(w, http.StatusBadRequest, "Invalid sortBy parameter: must be 'name', 'createdAt', or 'updatedAt'") + return + } + if !models.IsValidSortOrder(sortOrder) { + log.Error("ListAgents: invalid sortOrder parameter", "sortOrder", sortOrder) + utils.WriteErrorResponse(w, http.StatusBadRequest, "Invalid sortOrder parameter: must be 'asc' or 'desc'") + return + } + if !models.IsValidProvisioningType(provisioningType) { + log.Error("ListAgents: invalid provisioningType parameter", "provisioningType", provisioningType) + utils.WriteErrorResponse(w, http.StatusBadRequest, "Invalid provisioningType parameter: must be 'internal' or 'external'") + return + } + + // Build filter + filter := models.AgentFilter{ + Search: search, + ProvisioningType: provisioningType, + SortBy: sortBy, + SortOrder: sortOrder, + Limit: limit, + Offset: offset, + } + // Extract user info from JWT token tokenClaims := jwtassertion.GetTokenClaims(ctx) userIdpId := tokenClaims.Sub - agents, total, err := c.agentService.ListAgents(ctx, userIdpId, orgName, projName, int32(limit), int32(offset)) + agents, total, err := c.agentService.ListAgents(ctx, userIdpId, orgName, projName, filter) if err != nil { log.Error("ListAgents: failed to list agents", "error", err) if errors.Is(err, utils.ErrOrganizationNotFound) { diff --git a/agent-manager-service/models/agent_filter.go b/agent-manager-service/models/agent_filter.go new file mode 100644 index 000000000..08b9c18a5 --- /dev/null +++ b/agent-manager-service/models/agent_filter.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package models + +// Valid filter values as constants +const ( + // Sort field options + SortByName = "name" + SortByCreatedAt = "createdAt" + SortByUpdatedAt = "updatedAt" + + // Sort order options + SortOrderAsc = "asc" + SortOrderDesc = "desc" + + // Provisioning type options + ProvisioningInternal = "internal" + ProvisioningExternal = "external" +) + +// AgentFilter holds filter options for listing agents +type AgentFilter struct { + Search string // search in name, displayName, description + ProvisioningType string // "internal", "external" + SortBy string // "name", "createdAt", "updatedAt" + SortOrder string // "asc", "desc" + Limit int + Offset int +} + +// DefaultAgentFilter returns filter with sensible defaults +func DefaultAgentFilter() AgentFilter { + return AgentFilter{ + SortBy: SortByCreatedAt, + SortOrder: SortOrderDesc, + Limit: 20, + Offset: 0, + } +} + +// IsValidSortBy checks if sortBy value is valid +func IsValidSortBy(sortBy string) bool { + return sortBy == "" || sortBy == SortByName || sortBy == SortByCreatedAt || sortBy == SortByUpdatedAt +} + +// IsValidSortOrder checks if sortOrder value is valid +func IsValidSortOrder(sortOrder string) bool { + return sortOrder == "" || sortOrder == SortOrderAsc || sortOrder == SortOrderDesc +} + +// IsValidProvisioningType checks if provisioningType value is valid +func IsValidProvisioningType(provisioningType string) bool { + return provisioningType == "" || provisioningType == ProvisioningInternal || provisioningType == ProvisioningExternal +} diff --git a/agent-manager-service/repositories/agent.go b/agent-manager-service/repositories/agent.go index cfbbad2ac..a5a791657 100644 --- a/agent-manager-service/repositories/agent.go +++ b/agent-manager-service/repositories/agent.go @@ -29,6 +29,7 @@ import ( type AgentRepository interface { ListAgents(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID) ([]*models.Agent, error) + ListAgentsWithFilter(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, filter models.AgentFilter) ([]*models.Agent, int64, error) GetAgentByName(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, agentName string) (*models.Agent, error) CreateAgent(ctx context.Context, agent *models.Agent) error SoftDeleteAgentByName(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, agentName string) error @@ -56,6 +57,63 @@ func (r *agentRepository) ListAgents(ctx context.Context, orgId uuid.UUID, proje return agents, nil } +func (r *agentRepository) ListAgentsWithFilter(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, filter models.AgentFilter) ([]*models.Agent, int64, error) { + var agents []*models.Agent + var total int64 + + query := db.DB(ctx).Model(&models.Agent{}).Where("org_id = ? AND project_id = ?", orgId, projectId) + + // Apply search filter (case-insensitive search on name, display_name, description) + if filter.Search != "" { + searchPattern := "%" + filter.Search + "%" + query = query.Where("name ILIKE ? OR display_name ILIKE ? OR description ILIKE ?", searchPattern, searchPattern, searchPattern) + } + + // Apply provisioning type filter + if filter.ProvisioningType != "" { + query = query.Where("provisioning_type = ?", filter.ProvisioningType) + } + + // Get total count before pagination + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("agentRepository.ListAgentsWithFilter count: %w", err) + } + + // Apply sorting - map camelCase API values to snake_case DB columns + sortColumn := "created_at" + switch filter.SortBy { + case models.SortByName: + sortColumn = "name" + case models.SortByUpdatedAt: + sortColumn = "updated_at" + case models.SortByCreatedAt: + sortColumn = "created_at" + } + + // Validate sortOrder to prevent SQL injection - only allow known values + sortOrder := "DESC" + if filter.SortOrder == models.SortOrderAsc { + sortOrder = "ASC" + } + + query = query.Order(fmt.Sprintf("%s %s", sortColumn, sortOrder)) + + // Apply pagination + if filter.Limit > 0 { + query = query.Limit(filter.Limit) + } + if filter.Offset > 0 { + query = query.Offset(filter.Offset) + } + + // Execute query with preload + if err := query.Preload("AgentDetails").Find(&agents).Error; err != nil { + return nil, 0, fmt.Errorf("agentRepository.ListAgentsWithFilter: %w", err) + } + + return agents, total, nil +} + func (r *agentRepository) GetAgentByName(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, agentName string) (*models.Agent, error) { var agent models.Agent if err := db.DB(ctx). diff --git a/agent-manager-service/services/agent_manager.go b/agent-manager-service/services/agent_manager.go index e1d44f973..df23a3540 100644 --- a/agent-manager-service/services/agent_manager.go +++ b/agent-manager-service/services/agent_manager.go @@ -37,7 +37,7 @@ import ( ) type AgentManagerService interface { - ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, limit int32, offset int32) ([]*models.AgentResponse, int32, error) + ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, filter models.AgentFilter) ([]*models.AgentResponse, int32, error) CreateAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, req *spec.CreateAgentRequest) error BuildAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, agentName string, commitId string) (*models.BuildResponse, error) DeleteAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, agentName string) error @@ -126,8 +126,8 @@ func (s *agentManagerService) GetAgent(ctx context.Context, userIdpId uuid.UUID, return s.convertManagedAgentToAgentResponse(ocAgentComponent, agent), nil } -func (s *agentManagerService) ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, limit int32, offset int32) ([]*models.AgentResponse, int32, error) { - s.logger.Info("Listing agents", "orgName", orgName, "projectName", projName, "limit", limit, "offset", offset, "userIdpId", userIdpId) +func (s *agentManagerService) ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, filter models.AgentFilter) ([]*models.AgentResponse, int32, error) { + s.logger.Info("Listing agents", "orgName", orgName, "projectName", projName, "filter", filter, "userIdpId", userIdpId) // Validate organization exists org, err := s.OrganizationRepository.GetOrganizationByOrgName(ctx, userIdpId, orgName) if err != nil { @@ -146,34 +146,22 @@ func (s *agentManagerService) ListAgents(ctx context.Context, userIdpId uuid.UUI s.logger.Error("Failed to find project", "projectName", projName, "orgId", org.ID, "error", err) return nil, 0, fmt.Errorf("failed to find project %s: %w", projName, err) } - // Fetch all agents from the database - agents, err := s.AgentRepository.ListAgents(ctx, org.ID, project.ID) + + // Fetch agents with filter from the database + agents, total, err := s.AgentRepository.ListAgentsWithFilter(ctx, org.ID, project.ID, filter) if err != nil { s.logger.Error("Failed to list agents from repository", "orgId", org.ID, "projectId", project.ID, "error", err) - return nil, 0, fmt.Errorf("failed to list external agents: %w", err) + return nil, 0, fmt.Errorf("failed to list agents: %w", err) } - var allAgents []*models.AgentResponse + + // Convert to response format + var agentResponses []*models.AgentResponse for _, agent := range agents { - allAgents = append(allAgents, s.convertToAgentListItem(agent, project.Name)) + agentResponses = append(agentResponses, s.convertToAgentListItem(agent, project.Name)) } - // Calculate total count - total := int32(len(allAgents)) - - // Apply pagination - var paginatedAgents []*models.AgentResponse - if offset >= total { - // If offset is beyond available data, return empty slice - paginatedAgents = []*models.AgentResponse{} - } else { - endIndex := offset + limit - if endIndex > total { - endIndex = total - } - paginatedAgents = allAgents[offset:endIndex] - } - s.logger.Info("Listed agents successfully", "orgName", orgName, "projName", projName, "totalAgents", total, "returnedAgents", len(paginatedAgents)) - return paginatedAgents, total, nil + s.logger.Info("Listed agents successfully", "orgName", orgName, "projName", projName, "totalAgents", total, "returnedAgents", len(agentResponses)) + return agentResponses, int32(total), nil } func (s *agentManagerService) CreateAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, req *spec.CreateAgentRequest) error { diff --git a/console/workspaces/libs/types/src/api/agents.ts b/console/workspaces/libs/types/src/api/agents.ts index baeb55162..615127384 100644 --- a/console/workspaces/libs/types/src/api/agents.ts +++ b/console/workspaces/libs/types/src/api/agents.ts @@ -61,6 +61,15 @@ export type ListAgentsPathParams = OrgProjPathParams; export type CreateAgentPathParams = OrgProjPathParams; export type GetAgentPathParams = AgentPathParams; export type DeleteAgentPathParams = AgentPathParams; -export type ListAgentsQuery = ListQuery; + +// Sort field options for agent list +export type AgentSortBy = 'name' | 'createdAt' | 'updatedAt'; + +export interface ListAgentsQuery extends ListQuery { + search?: string; + provisioningType?: ProvisioningType; + sortBy?: AgentSortBy; + sortOrder?: 'asc' | 'desc'; +} diff --git a/console/workspaces/pages/overview/src/AgentsList/AgentsList.tsx b/console/workspaces/pages/overview/src/AgentsList/AgentsList.tsx index fdb151bea..9309a6cd1 100644 --- a/console/workspaces/pages/overview/src/AgentsList/AgentsList.tsx +++ b/console/workspaces/pages/overview/src/AgentsList/AgentsList.tsx @@ -100,8 +100,17 @@ export interface AgentWithHref extends AgentResponse { export const AgentsList: React.FC = () => { const theme = useTheme(); const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); const [hoveredAgentId, setHoveredAgentId] = useState(null); + // Debounce search input to reduce API calls + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + }, 300); + return () => clearTimeout(timer); + }, [search]); + // Detect touch device for alternative interaction pattern const isTouchDevice = typeof window !== "undefined" && @@ -118,10 +127,10 @@ export const AgentsList: React.FC = () => { error, isRefetching, refetch: refetchAgents, - } = useListAgents({ - orgName: orgId, - projName: projectId, - }); + } = useListAgents( + { orgName: orgId, projName: projectId }, + { search: debouncedSearch || undefined } + ); const { mutate: deleteAgent, isPending: isDeletingAgent } = useDeleteAgent(); const { data: project, isLoading: isProjectLoading } = useGetProject({ orgName: orgId, @@ -166,7 +175,8 @@ export const AgentsList: React.FC = () => { projectId && !data?.agents?.length && !isLoading && - !isRefetching + !isRefetching && + !debouncedSearch // Don't redirect when searching with no results ) { navigate( generatePath( @@ -176,34 +186,28 @@ export const AgentsList: React.FC = () => { ) ); } - }, [orgId, projectId, data?.agents, isLoading, isRefetching, navigate]); + }, [orgId, projectId, data?.agents, isLoading, isRefetching, navigate, debouncedSearch]); const agentsWithHref: AgentWithHref[] = useMemo( () => - data?.agents - ?.filter( - (agent: AgentResponse) => - agent.displayName.toLowerCase().includes(search.toLowerCase()) || - agent.name.toLowerCase().includes(search.toLowerCase()) - ) - .map((agent) => ({ - ...agent, - href: generatePath( - getAgentPath(agent.provisioning.type === "internal"), - { - orgId: orgId ?? "", - projectId: agent.projectName, - agentId: agent.name, - } - ), - id: agent.name, - agentInfo: { - name: agent.name, - displayName: agent.displayName, - description: agent.description, - }, - })) ?? [], - [data?.agents, search, orgId] + data?.agents?.map((agent) => ({ + ...agent, + href: generatePath( + getAgentPath(agent.provisioning.type === "internal"), + { + orgId: orgId ?? "", + projectId: agent.projectName, + agentId: agent.name, + } + ), + id: agent.name, + agentInfo: { + name: agent.name, + displayName: agent.displayName, + description: agent.description, + }, + })) ?? [], + [data?.agents, orgId] ); const columns = useMemo( @@ -416,7 +420,6 @@ export const AgentsList: React.FC = () => { size="small" variant="outlined" placeholder="Search agents" - disabled={!data?.agents?.length} />