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 agentId,
|
||||||
@RequestParam(required = false) String processorType,
|
@RequestParam(required = false) String processorType,
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
@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(
|
SearchRequest request = new SearchRequest(
|
||||||
status, timeFrom, timeTo,
|
status, timeFrom, timeTo,
|
||||||
@@ -55,7 +57,8 @@ public class SearchController {
|
|||||||
correlationId,
|
correlationId,
|
||||||
text, null, null, null,
|
text, null, null, null,
|
||||||
routeId, agentId, processorType,
|
routeId, agentId, processorType,
|
||||||
offset, limit
|
offset, limit,
|
||||||
|
sortField, sortDir
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(searchService.search(request));
|
return ResponseEntity.ok(searchService.search(request));
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
|||||||
// Data query
|
// Data query
|
||||||
params.add(request.limit());
|
params.add(request.limit());
|
||||||
params.add(request.offset());
|
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, " +
|
String dataSql = "SELECT execution_id, route_id, agent_id, status, start_time, end_time, " +
|
||||||
"duration_ms, correlation_id, error_message, diagram_content_hash " +
|
"duration_ms, correlation_id, error_message, diagram_content_hash " +
|
||||||
"FROM route_executions" + where +
|
"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) -> {
|
List<ExecutionSummary> data = jdbcTemplate.query(dataSql, (rs, rowNum) -> {
|
||||||
Timestamp endTs = rs.getTimestamp("end_time");
|
Timestamp endTs = rs.getTimestamp("end_time");
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import java.time.Instant;
|
|||||||
* @param processorType matches processor_types array via has()
|
* @param processorType matches processor_types array via has()
|
||||||
* @param offset pagination offset (0-based)
|
* @param offset pagination offset (0-based)
|
||||||
* @param limit page size (default 50, max 500)
|
* @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(
|
public record SearchRequest(
|
||||||
String status,
|
String status,
|
||||||
@@ -39,15 +41,37 @@ public record SearchRequest(
|
|||||||
String agentId,
|
String agentId,
|
||||||
String processorType,
|
String processorType,
|
||||||
int offset,
|
int offset,
|
||||||
int limit
|
int limit,
|
||||||
|
String sortField,
|
||||||
|
String sortDir
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private static final int DEFAULT_LIMIT = 50;
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
private static final int MAX_LIMIT = 500;
|
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 {
|
public SearchRequest {
|
||||||
if (limit <= 0) limit = DEFAULT_LIMIT;
|
if (limit <= 0) limit = DEFAULT_LIMIT;
|
||||||
if (limit > MAX_LIMIT) limit = MAX_LIMIT;
|
if (limit > MAX_LIMIT) limit = MAX_LIMIT;
|
||||||
if (offset < 0) offset = 0;
|
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",
|
"format": "int32",
|
||||||
"default": 50
|
"default": 50
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sortField",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sortDir",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1500,6 +1516,12 @@
|
|||||||
"limit": {
|
"limit": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"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;
|
offset?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
sortField?: string;
|
||||||
|
sortDir?: string;
|
||||||
};
|
};
|
||||||
ExecutionSummary: {
|
ExecutionSummary: {
|
||||||
executionId: string;
|
executionId: string;
|
||||||
@@ -915,6 +917,8 @@ export interface operations {
|
|||||||
processorType?: string;
|
processorType?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
sortField?: string;
|
||||||
|
sortDir?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ExecutionSummary } from '../../api/types';
|
import type { ExecutionSummary } from '../../api/types';
|
||||||
import { StatusPill } from '../../components/shared/StatusPill';
|
import { StatusPill } from '../../components/shared/StatusPill';
|
||||||
import { DurationBar } from '../../components/shared/DurationBar';
|
import { DurationBar } from '../../components/shared/DurationBar';
|
||||||
import { AppBadge } from '../../components/shared/AppBadge';
|
import { AppBadge } from '../../components/shared/AppBadge';
|
||||||
import { ProcessorTree } from './ProcessorTree';
|
import { ProcessorTree } from './ProcessorTree';
|
||||||
import { ExchangeDetail } from './ExchangeDetail';
|
import { ExchangeDetail } from './ExchangeDetail';
|
||||||
|
import { useExecutionSearch } from './use-execution-search';
|
||||||
import styles from './ResultsTable.module.css';
|
import styles from './ResultsTable.module.css';
|
||||||
|
|
||||||
interface ResultsTableProps {
|
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 {
|
interface SortableThProps {
|
||||||
label: string;
|
label: string;
|
||||||
column: SortColumn;
|
column: SortColumn;
|
||||||
@@ -76,21 +52,12 @@ function SortableTh({ label, column, activeColumn, direction, onSort, style }: S
|
|||||||
|
|
||||||
export function ResultsTable({ results, loading }: ResultsTableProps) {
|
export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null);
|
const sortColumn = useExecutionSearch((s) => s.sortField);
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
const sortDir = useExecutionSearch((s) => s.sortDir);
|
||||||
|
const setSort = useExecutionSearch((s) => s.setSort);
|
||||||
const sortedResults = useMemo(() => {
|
|
||||||
if (!sortColumn) return results;
|
|
||||||
return [...results].sort((a, b) => compareFn(a, b, sortColumn, sortDir));
|
|
||||||
}, [results, sortColumn, sortDir]);
|
|
||||||
|
|
||||||
function handleSort(col: SortColumn) {
|
function handleSort(col: SortColumn) {
|
||||||
if (sortColumn === col) {
|
setSort(col);
|
||||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
||||||
} else {
|
|
||||||
setSortColumn(col);
|
|
||||||
setSortDir('desc');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && results.length === 0) {
|
if (loading && results.length === 0) {
|
||||||
@@ -124,7 +91,7 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedResults.map((exec) => {
|
{results.map((exec) => {
|
||||||
const isExpanded = expandedId === exec.executionId;
|
const isExpanded = expandedId === exec.executionId;
|
||||||
return (
|
return (
|
||||||
<ResultRow
|
<ResultRow
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ function todayMidnight(): string {
|
|||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
|
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 {
|
interface ExecutionSearchState {
|
||||||
status: string[];
|
status: string[];
|
||||||
timeFrom: string;
|
timeFrom: string;
|
||||||
@@ -22,6 +25,8 @@ interface ExecutionSearchState {
|
|||||||
live: boolean;
|
live: boolean;
|
||||||
offset: number;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
sortField: SortColumn;
|
||||||
|
sortDir: SortDir;
|
||||||
|
|
||||||
toggleLive: () => void;
|
toggleLive: () => void;
|
||||||
setStatus: (statuses: string[]) => void;
|
setStatus: (statuses: string[]) => void;
|
||||||
@@ -35,6 +40,7 @@ interface ExecutionSearchState {
|
|||||||
setAgentId: (v: string) => void;
|
setAgentId: (v: string) => void;
|
||||||
setProcessorType: (v: string) => void;
|
setProcessorType: (v: string) => void;
|
||||||
setOffset: (v: number) => void;
|
setOffset: (v: number) => void;
|
||||||
|
setSort: (col: SortColumn) => void;
|
||||||
clearAll: () => void;
|
clearAll: () => void;
|
||||||
toSearchRequest: () => SearchRequest;
|
toSearchRequest: () => SearchRequest;
|
||||||
}
|
}
|
||||||
@@ -52,6 +58,8 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
|||||||
live: true,
|
live: true,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 25,
|
limit: 25,
|
||||||
|
sortField: 'startTime',
|
||||||
|
sortDir: 'desc',
|
||||||
|
|
||||||
toggleLive: () => set((state) => ({ live: !state.live })),
|
toggleLive: () => set((state) => ({ live: !state.live })),
|
||||||
setStatus: (statuses) => set({ status: statuses, offset: 0 }),
|
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 }),
|
setAgentId: (v) => set({ agentId: v, offset: 0 }),
|
||||||
setProcessorType: (v) => set({ processorType: v, offset: 0 }),
|
setProcessorType: (v) => set({ processorType: v, offset: 0 }),
|
||||||
setOffset: (v) => set({ offset: v }),
|
setOffset: (v) => set({ offset: v }),
|
||||||
|
setSort: (col) =>
|
||||||
|
set((state) => ({
|
||||||
|
sortField: col,
|
||||||
|
sortDir: state.sortField === col && state.sortDir === 'desc' ? 'asc' : 'desc',
|
||||||
|
offset: 0,
|
||||||
|
})),
|
||||||
clearAll: () =>
|
clearAll: () =>
|
||||||
set({
|
set({
|
||||||
status: ['COMPLETED', 'FAILED', 'RUNNING'],
|
status: ['COMPLETED', 'FAILED', 'RUNNING'],
|
||||||
@@ -83,6 +97,8 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
|||||||
agentId: '',
|
agentId: '',
|
||||||
processorType: '',
|
processorType: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
sortField: 'startTime',
|
||||||
|
sortDir: 'desc',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
toSearchRequest: (): SearchRequest => {
|
toSearchRequest: (): SearchRequest => {
|
||||||
@@ -102,6 +118,8 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
|||||||
processorType: s.processorType || undefined,
|
processorType: s.processorType || undefined,
|
||||||
offset: s.offset,
|
offset: s.offset,
|
||||||
limit: s.limit,
|
limit: s.limit,
|
||||||
|
sortField: s.sortField,
|
||||||
|
sortDir: s.sortDir,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user