Server-side sorting for execution search results
All checks were successful
CI / build (push) Successful in 1m12s
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 33s

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:
hsiegeln
2026-03-14 19:34:22 +01:00
parent 31b8695420
commit b64edaa16f
7 changed files with 83 additions and 44 deletions

View File

@@ -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));

View File

@@ -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");

View File

@@ -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");
}
}

View File

@@ -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"
}
}
},

View File

@@ -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;

View File

@@ -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

View File

@@ -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,
};
},
}));