diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java index fd984d9c..515c352f 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java @@ -47,7 +47,9 @@ public class SearchController { @RequestParam(required = false) String agentId, @RequestParam(required = false) String processorType, @RequestParam(defaultValue = "0") int offset, - @RequestParam(defaultValue = "50") int limit) { + @RequestParam(defaultValue = "50") int limit, + @RequestParam(required = false) String sortField, + @RequestParam(required = false) String sortDir) { SearchRequest request = new SearchRequest( status, timeFrom, timeTo, @@ -55,7 +57,8 @@ public class SearchController { correlationId, text, null, null, null, routeId, agentId, processorType, - offset, limit + offset, limit, + sortField, sortDir ); return ResponseEntity.ok(searchService.search(request)); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java index 4f28db1a..c5cb3f90 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java @@ -51,10 +51,11 @@ public class ClickHouseSearchEngine implements SearchEngine { // Data query params.add(request.limit()); params.add(request.offset()); + String orderDir = "asc".equalsIgnoreCase(request.sortDir()) ? "ASC" : "DESC"; String dataSql = "SELECT execution_id, route_id, agent_id, status, start_time, end_time, " + "duration_ms, correlation_id, error_message, diagram_content_hash " + "FROM route_executions" + where + - " ORDER BY start_time DESC LIMIT ? OFFSET ?"; + " ORDER BY " + request.sortColumn() + " " + orderDir + " LIMIT ? OFFSET ?"; List data = jdbcTemplate.query(dataSql, (rs, rowNum) -> { Timestamp endTs = rs.getTimestamp("end_time"); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java index a6396b23..8ce101ca 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java @@ -23,6 +23,8 @@ import java.time.Instant; * @param processorType matches processor_types array via has() * @param offset pagination offset (0-based) * @param limit page size (default 50, max 500) + * @param sortField column to sort by (default: startTime) + * @param sortDir sort direction: asc or desc (default: desc) */ public record SearchRequest( String status, @@ -39,15 +41,37 @@ public record SearchRequest( String agentId, String processorType, int offset, - int limit + int limit, + String sortField, + String sortDir ) { private static final int DEFAULT_LIMIT = 50; private static final int MAX_LIMIT = 500; + private static final java.util.Set ALLOWED_SORT_FIELDS = java.util.Set.of( + "startTime", "status", "agentId", "routeId", "correlationId", "durationMs" + ); + + private static final java.util.Map SORT_FIELD_TO_COLUMN = java.util.Map.of( + "startTime", "start_time", + "status", "status", + "agentId", "agent_id", + "routeId", "route_id", + "correlationId", "correlation_id", + "durationMs", "duration_ms" + ); + public SearchRequest { if (limit <= 0) limit = DEFAULT_LIMIT; if (limit > MAX_LIMIT) limit = MAX_LIMIT; if (offset < 0) offset = 0; + if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) sortField = "startTime"; + if (!"asc".equalsIgnoreCase(sortDir)) sortDir = "desc"; + } + + /** Returns the validated ClickHouse column name for ORDER BY. */ + public String sortColumn() { + return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time"); } } diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index efea3672..76c83b40 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -255,6 +255,22 @@ "format": "int32", "default": 50 } + }, + { + "name": "sortField", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sortDir", + "in": "query", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -1500,6 +1516,12 @@ "limit": { "type": "integer", "format": "int32" + }, + "sortField": { + "type": "string" + }, + "sortDir": { + "type": "string" } } }, diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 89bcbb89..97f34192 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -562,6 +562,8 @@ export interface components { offset?: number; /** Format: int32 */ limit?: number; + sortField?: string; + sortDir?: string; }; ExecutionSummary: { executionId: string; @@ -915,6 +917,8 @@ export interface operations { processorType?: string; offset?: number; limit?: number; + sortField?: string; + sortDir?: string; }; header?: never; path?: never; diff --git a/ui/src/pages/executions/ResultsTable.tsx b/ui/src/pages/executions/ResultsTable.tsx index edfde65e..42e07bba 100644 --- a/ui/src/pages/executions/ResultsTable.tsx +++ b/ui/src/pages/executions/ResultsTable.tsx @@ -1,10 +1,11 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import type { ExecutionSummary } from '../../api/types'; import { StatusPill } from '../../components/shared/StatusPill'; import { DurationBar } from '../../components/shared/DurationBar'; import { AppBadge } from '../../components/shared/AppBadge'; import { ProcessorTree } from './ProcessorTree'; import { ExchangeDetail } from './ExchangeDetail'; +import { useExecutionSearch } from './use-execution-search'; import styles from './ResultsTable.module.css'; interface ResultsTableProps { @@ -24,31 +25,6 @@ function formatTime(iso: string) { }); } -function compareFn(a: ExecutionSummary, b: ExecutionSummary, col: SortColumn, dir: SortDir): number { - let cmp = 0; - switch (col) { - case 'startTime': - cmp = a.startTime.localeCompare(b.startTime); - break; - case 'status': - cmp = a.status.localeCompare(b.status); - break; - case 'agentId': - cmp = a.agentId.localeCompare(b.agentId); - break; - case 'routeId': - cmp = a.routeId.localeCompare(b.routeId); - break; - case 'correlationId': - cmp = (a.correlationId ?? '').localeCompare(b.correlationId ?? ''); - break; - case 'durationMs': - cmp = a.durationMs - b.durationMs; - break; - } - return dir === 'asc' ? cmp : -cmp; -} - interface SortableThProps { label: string; column: SortColumn; @@ -76,21 +52,12 @@ function SortableTh({ label, column, activeColumn, direction, onSort, style }: S export function ResultsTable({ results, loading }: ResultsTableProps) { const [expandedId, setExpandedId] = useState(null); - const [sortColumn, setSortColumn] = useState(null); - const [sortDir, setSortDir] = useState('desc'); - - const sortedResults = useMemo(() => { - if (!sortColumn) return results; - return [...results].sort((a, b) => compareFn(a, b, sortColumn, sortDir)); - }, [results, sortColumn, sortDir]); + const sortColumn = useExecutionSearch((s) => s.sortField); + const sortDir = useExecutionSearch((s) => s.sortDir); + const setSort = useExecutionSearch((s) => s.setSort); function handleSort(col: SortColumn) { - if (sortColumn === col) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortColumn(col); - setSortDir('desc'); - } + setSort(col); } if (loading && results.length === 0) { @@ -124,7 +91,7 @@ export function ResultsTable({ results, loading }: ResultsTableProps) { - {sortedResults.map((exec) => { + {results.map((exec) => { const isExpanded = expandedId === exec.executionId; return ( void; setStatus: (statuses: string[]) => void; @@ -35,6 +40,7 @@ interface ExecutionSearchState { setAgentId: (v: string) => void; setProcessorType: (v: string) => void; setOffset: (v: number) => void; + setSort: (col: SortColumn) => void; clearAll: () => void; toSearchRequest: () => SearchRequest; } @@ -52,6 +58,8 @@ export const useExecutionSearch = create((set, get) => ({ live: true, offset: 0, limit: 25, + sortField: 'startTime', + sortDir: 'desc', toggleLive: () => set((state) => ({ live: !state.live })), setStatus: (statuses) => set({ status: statuses, offset: 0 }), @@ -71,6 +79,12 @@ export const useExecutionSearch = create((set, get) => ({ setAgentId: (v) => set({ agentId: v, offset: 0 }), setProcessorType: (v) => set({ processorType: v, offset: 0 }), setOffset: (v) => set({ offset: v }), + setSort: (col) => + set((state) => ({ + sortField: col, + sortDir: state.sortField === col && state.sortDir === 'desc' ? 'asc' : 'desc', + offset: 0, + })), clearAll: () => set({ status: ['COMPLETED', 'FAILED', 'RUNNING'], @@ -83,6 +97,8 @@ export const useExecutionSearch = create((set, get) => ({ agentId: '', processorType: '', offset: 0, + sortField: 'startTime', + sortDir: 'desc', }), toSearchRequest: (): SearchRequest => { @@ -102,6 +118,8 @@ export const useExecutionSearch = create((set, get) => ({ processorType: s.processorType || undefined, offset: s.offset, limit: s.limit, + sortField: s.sortField, + sortDir: s.sortDir, }; }, }));