Server-side sorting for execution search results
Sorting now applies to the entire result set via ClickHouse ORDER BY instead of only sorting the current page client-side. Default sort order is timestamp descending. Supported sort columns: startTime, status, agentId, routeId, correlationId, durationMs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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<ExecutionSummary> data = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
||||
Timestamp endTs = rs.getTimestamp("end_time");
|
||||
|
||||
@@ -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<String> ALLOWED_SORT_FIELDS = java.util.Set.of(
|
||||
"startTime", "status", "agentId", "routeId", "correlationId", "durationMs"
|
||||
);
|
||||
|
||||
private static final java.util.Map<String, String> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
4
ui/src/api/schema.d.ts
vendored
4
ui/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>('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) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedResults.map((exec) => {
|
||||
{results.map((exec) => {
|
||||
const isExpanded = expandedId === exec.executionId;
|
||||
return (
|
||||
<ResultRow
|
||||
|
||||
@@ -9,6 +9,9 @@ function todayMidnight(): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
|
||||
}
|
||||
|
||||
type SortColumn = 'startTime' | 'status' | 'agentId' | 'routeId' | 'correlationId' | 'durationMs';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface ExecutionSearchState {
|
||||
status: string[];
|
||||
timeFrom: string;
|
||||
@@ -22,6 +25,8 @@ interface ExecutionSearchState {
|
||||
live: boolean;
|
||||
offset: number;
|
||||
limit: number;
|
||||
sortField: SortColumn;
|
||||
sortDir: SortDir;
|
||||
|
||||
toggleLive: () => 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<ExecutionSearchState>((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<ExecutionSearchState>((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<ExecutionSearchState>((set, get) => ({
|
||||
agentId: '',
|
||||
processorType: '',
|
||||
offset: 0,
|
||||
sortField: 'startTime',
|
||||
sortDir: 'desc',
|
||||
}),
|
||||
|
||||
toSearchRequest: (): SearchRequest => {
|
||||
@@ -102,6 +118,8 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
||||
processorType: s.processorType || undefined,
|
||||
offset: s.offset,
|
||||
limit: s.limit,
|
||||
sortField: s.sortField,
|
||||
sortDir: s.sortDir,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user