From c3cfb39f813768919a572ce43e074a572e129f59 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:03:37 +0100 Subject: [PATCH] Add sortable table columns, pre-populate date filter, inline command palette - Table headers are now clickable to sort by column (client-side) - From date picker defaults to today 00:00 instead of empty - Command palette expands inline from search bar instead of modal dialog Co-Authored-By: Claude Opus 4.6 --- .../command-palette/CommandPalette.tsx | 120 +---------------- .../pages/executions/ResultsTable.module.css | 29 ++++ ui/src/pages/executions/ResultsTable.tsx | 85 ++++++++++-- .../pages/executions/SearchFilters.module.css | 25 ++++ ui/src/pages/executions/SearchFilters.tsx | 125 ++++++++++++++++-- .../pages/executions/use-execution-search.ts | 12 +- 6 files changed, 261 insertions(+), 135 deletions(-) diff --git a/ui/src/components/command-palette/CommandPalette.tsx b/ui/src/components/command-palette/CommandPalette.tsx index 5f4b6281..5680ba16 100644 --- a/ui/src/components/command-palette/CommandPalette.tsx +++ b/ui/src/components/command-palette/CommandPalette.tsx @@ -1,94 +1,11 @@ -import { useEffect, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import { useCommandPalette, type PaletteScope } from './use-command-palette'; -import { usePaletteSearch, type PaletteResult } from './use-palette-search'; -import { useExecutionSearch } from '../../pages/executions/use-execution-search'; -import { PaletteInput } from './PaletteInput'; -import { ScopeTabs } from './ScopeTabs'; -import { ResultsList } from './ResultsList'; -import { PaletteFooter } from './PaletteFooter'; -import type { ExecutionSummary, AgentInstance } from '../../api/schema'; -import styles from './CommandPalette.module.css'; - -const SCOPES: PaletteScope[] = ['all', 'executions', 'agents']; +import { useEffect } from 'react'; +import { useCommandPalette } from './use-command-palette'; +/** + * Headless component: only registers the global Cmd+K / Ctrl+K keyboard shortcut. + * The palette UI itself is rendered inline within SearchFilters. + */ export function CommandPalette() { - const { isOpen, close, scope, setScope, selectedIndex, setSelectedIndex, reset, filters } = - useCommandPalette(); - const { results, executionCount, agentCount, isLoading } = usePaletteSearch(); - const execSearch = useExecutionSearch(); - - const handleSelect = useCallback( - (result: PaletteResult) => { - if (result.type === 'execution') { - const exec = result.data as ExecutionSummary; - execSearch.setStatus(['COMPLETED', 'FAILED', 'RUNNING']); - execSearch.setText(exec.executionId); - execSearch.setRouteId(''); - execSearch.setAgentId(''); - execSearch.setProcessorType(''); - } else if (result.type === 'agent') { - const agent = result.data as AgentInstance; - execSearch.setStatus(['COMPLETED', 'FAILED', 'RUNNING']); - execSearch.setAgentId(agent.id); - execSearch.setText(''); - execSearch.setRouteId(''); - execSearch.setProcessorType(''); - } - // Apply any active palette filters to the execution search - for (const f of filters) { - if (f.key === 'status') execSearch.setStatus([f.value.toUpperCase()]); - if (f.key === 'route') execSearch.setRouteId(f.value); - if (f.key === 'agent') execSearch.setAgentId(f.value); - if (f.key === 'processor') execSearch.setProcessorType(f.value); - } - close(); - reset(); - }, - [close, reset, execSearch, filters], - ); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!isOpen) return; - - switch (e.key) { - case 'Escape': - e.preventDefault(); - close(); - reset(); - break; - case 'ArrowDown': - e.preventDefault(); - setSelectedIndex( - results.length > 0 ? (selectedIndex + 1) % results.length : 0, - ); - break; - case 'ArrowUp': - e.preventDefault(); - setSelectedIndex( - results.length > 0 - ? (selectedIndex - 1 + results.length) % results.length - : 0, - ); - break; - case 'Enter': - e.preventDefault(); - if (results[selectedIndex]) { - handleSelect(results[selectedIndex]); - } - break; - case 'Tab': - e.preventDefault(); - const idx = SCOPES.indexOf(scope); - setScope(SCOPES[(idx + 1) % SCOPES.length]); - break; - } - }, - [isOpen, close, reset, selectedIndex, setSelectedIndex, results, handleSelect, scope, setScope], - ); - - // Global Cmd+K / Ctrl+K listener useEffect(() => { function onKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { @@ -106,28 +23,5 @@ export function CommandPalette() { return () => document.removeEventListener('keydown', onKeyDown); }, []); - // Keyboard handling when open - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); - - if (!isOpen) return null; - - return createPortal( -
{ - if (e.target === e.currentTarget) { - close(); - reset(); - } - }}> -
- - - - -
-
, - document.body, - ); + return null; } diff --git a/ui/src/pages/executions/ResultsTable.module.css b/ui/src/pages/executions/ResultsTable.module.css index d15bdede..10ce542e 100644 --- a/ui/src/pages/executions/ResultsTable.module.css +++ b/ui/src/pages/executions/ResultsTable.module.css @@ -28,6 +28,35 @@ white-space: nowrap; } +.thSortable { + cursor: pointer; + transition: color 0.15s; +} + +.thSortable:hover { + color: var(--text-secondary); +} + +.thActive { + color: var(--amber); +} + +.sortArrow { + display: inline-block; + margin-left: 4px; + font-size: 9px; + opacity: 0.3; + transition: opacity 0.15s; +} + +.thSortable:hover .sortArrow { + opacity: 0.6; +} + +.thActive .sortArrow { + opacity: 1; +} + .row { border-bottom: 1px solid var(--border-subtle); transition: background 0.1s; diff --git a/ui/src/pages/executions/ResultsTable.tsx b/ui/src/pages/executions/ResultsTable.tsx index f764f939..acaa3ec3 100644 --- a/ui/src/pages/executions/ResultsTable.tsx +++ b/ui/src/pages/executions/ResultsTable.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import type { ExecutionSummary } from '../../api/schema'; import { StatusPill } from '../../components/shared/StatusPill'; import { DurationBar } from '../../components/shared/DurationBar'; @@ -12,6 +12,9 @@ interface ResultsTableProps { loading: boolean; } +type SortColumn = 'startTime' | 'status' | 'agentId' | 'routeId' | 'correlationId' | 'durationMs'; +type SortDir = 'asc' | 'desc'; + function formatTime(iso: string) { return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', @@ -21,8 +24,74 @@ 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; + activeColumn: SortColumn | null; + direction: SortDir; + onSort: (col: SortColumn) => void; + style?: React.CSSProperties; +} + +function SortableTh({ label, column, activeColumn, direction, onSort, style }: SortableThProps) { + const isActive = activeColumn === column; + return ( + onSort(column)} + > + {label} + + {isActive ? (direction === 'asc' ? '\u25B2' : '\u25BC') : '\u25B4'} + + + ); +} + 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]); + + function handleSort(col: SortColumn) { + if (sortColumn === col) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortColumn(col); + setSortDir('desc'); + } + } if (loading && results.length === 0) { return ( @@ -46,16 +115,16 @@ export function ResultsTable({ results, loading }: ResultsTableProps) { - Timestamp - Status - Application - Route - Correlation ID - Duration + + + + + + - {results.map((exec) => { + {sortedResults.map((exec) => { const isExpanded = expandedId === exec.executionId; return ( s.open); + const { results, executionCount, agentCount, isLoading } = usePaletteSearch(); + const dropdownRef = useRef(null); + + const handleSelect = useCallback( + (result: PaletteResult) => { + if (result.type === 'execution') { + const exec = result.data as ExecutionSummary; + execSearch.setStatus(['COMPLETED', 'FAILED', 'RUNNING']); + execSearch.setText(exec.executionId); + execSearch.setRouteId(''); + execSearch.setAgentId(''); + execSearch.setProcessorType(''); + } else if (result.type === 'agent') { + const agent = result.data as AgentInstance; + execSearch.setStatus(['COMPLETED', 'FAILED', 'RUNNING']); + execSearch.setAgentId(agent.id); + execSearch.setText(''); + execSearch.setRouteId(''); + execSearch.setProcessorType(''); + } + for (const f of filters) { + if (f.key === 'status') execSearch.setStatus([f.value.toUpperCase()]); + if (f.key === 'route') execSearch.setRouteId(f.value); + if (f.key === 'agent') execSearch.setAgentId(f.value); + if (f.key === 'processor') execSearch.setProcessorType(f.value); + } + close(); + reset(); + }, + [close, reset, execSearch, filters], + ); + + // Close on click outside + useEffect(() => { + if (!isOpen) return; + function onClickOutside(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + close(); + reset(); + } + } + document.addEventListener('mousedown', onClickOutside); + return () => document.removeEventListener('mousedown', onClickOutside); + }, [isOpen, close, reset]); + + // Keyboard handling when open + useEffect(() => { + if (!isOpen) return; + const SCOPES = ['all', 'executions', 'agents'] as const; + function handleKeyDown(e: KeyboardEvent) { + switch (e.key) { + case 'Escape': + e.preventDefault(); + close(); + reset(); + break; + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(results.length > 0 ? (selectedIndex + 1) % results.length : 0); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(results.length > 0 ? (selectedIndex - 1 + results.length) % results.length : 0); + break; + case 'Enter': + e.preventDefault(); + if (results[selectedIndex]) { + handleSelect(results[selectedIndex]); + } + break; + case 'Tab': + e.preventDefault(); + const idx = SCOPES.indexOf(scope as typeof SCOPES[number]); + setScope(SCOPES[(idx + 1) % SCOPES.length]); + break; + } + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, close, reset, selectedIndex, setSelectedIndex, results, handleSelect, scope, setScope]); const activeTags: { label: string; onRemove: () => void }[] = []; if (text) activeTags.push({ label: `text:"${text}"`, onRemove: () => setText('') }); @@ -31,19 +121,30 @@ export function SearchFilters() { return (
- {/* Row 1: Search trigger (opens command palette) */} + {/* Row 1: Search bar with inline palette */}
-
{ if (e.key === 'Enter' || e.key === ' ') openPalette(); }}> - - - - - - {text || routeId || agentId || processorType - ? [text, routeId && `route:${routeId}`, agentId && `agent:${agentId}`, processorType && `processor:${processorType}`].filter(Boolean).join(' ') - : 'Search by correlation ID, error message, route ID...'} - - ⌘K +
+ {isOpen ? ( +
+ + + + +
+ ) : ( +
{ if (e.key === 'Enter' || e.key === ' ') openPalette(); }}> + + + + + + {text || routeId || agentId || processorType + ? [text, routeId && `route:${routeId}`, agentId && `agent:${agentId}`, processorType && `processor:${processorType}`].filter(Boolean).join(' ') + : 'Search by correlation ID, error message, route ID...'} + + ⌘K +
+ )}
diff --git a/ui/src/pages/executions/use-execution-search.ts b/ui/src/pages/executions/use-execution-search.ts index 61567ac2..67c8fdf6 100644 --- a/ui/src/pages/executions/use-execution-search.ts +++ b/ui/src/pages/executions/use-execution-search.ts @@ -1,6 +1,14 @@ import { create } from 'zustand'; import type { SearchRequest } from '../../api/schema'; +function todayMidnight(): string { + const d = new Date(); + d.setHours(0, 0, 0, 0); + // Format as datetime-local value: YYYY-MM-DDTHH:mm + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`; +} + interface ExecutionSearchState { status: string[]; timeFrom: string; @@ -31,7 +39,7 @@ interface ExecutionSearchState { export const useExecutionSearch = create((set, get) => ({ status: ['COMPLETED', 'FAILED'], - timeFrom: '', + timeFrom: todayMidnight(), timeTo: '', durationMin: null, durationMax: null, @@ -62,7 +70,7 @@ export const useExecutionSearch = create((set, get) => ({ clearAll: () => set({ status: ['COMPLETED', 'FAILED', 'RUNNING'], - timeFrom: '', + timeFrom: todayMidnight(), timeTo: '', durationMin: null, durationMax: null,