Add sortable table columns, pre-populate date filter, inline command palette
All checks were successful
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 46s
CI / deploy (push) Successful in 32s

- 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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-13 18:03:37 +01:00
parent d78b283567
commit c3cfb39f81
6 changed files with 261 additions and 135 deletions

View File

@@ -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(
<div className={styles.overlay} onClick={(e) => {
if (e.target === e.currentTarget) {
close();
reset();
}
}}>
<div className={styles.modal}>
<PaletteInput />
<ScopeTabs executionCount={executionCount} agentCount={agentCount} />
<ResultsList results={results} isLoading={isLoading} onSelect={handleSelect} />
<PaletteFooter />
</div>
</div>,
document.body,
);
return null;
}

View File

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

View File

@@ -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 (
<th
className={`${styles.th} ${styles.thSortable} ${isActive ? styles.thActive : ''}`}
style={style}
onClick={() => onSort(column)}
>
{label}
<span className={styles.sortArrow}>
{isActive ? (direction === 'asc' ? '\u25B2' : '\u25BC') : '\u25B4'}
</span>
</th>
);
}
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]);
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) {
<thead className={styles.thead}>
<tr>
<th className={styles.th} style={{ width: 32 }} />
<th className={styles.th}>Timestamp</th>
<th className={styles.th}>Status</th>
<th className={styles.th}>Application</th>
<th className={styles.th}>Route</th>
<th className={styles.th}>Correlation ID</th>
<th className={styles.th}>Duration</th>
<SortableTh label="Timestamp" column="startTime" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Status" column="status" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Application" column="agentId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Route" column="routeId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Correlation ID" column="correlationId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Duration" column="durationMs" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
</tr>
</thead>
<tbody>
{results.map((exec) => {
{sortedResults.map((exec) => {
const isExpanded = expandedId === exec.executionId;
return (
<ResultRow

View File

@@ -182,7 +182,32 @@
.clearAll:hover { color: var(--rose); }
/* ── Inline Palette ── */
.searchAnchor {
flex: 1;
min-width: 300px;
position: relative;
}
.paletteInline {
background: var(--bg-base);
border: 1px solid var(--amber-dim);
border-radius: var(--radius-sm);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), 0 0 0 3px var(--amber-glow);
display: flex;
flex-direction: column;
max-height: 480px;
overflow: hidden;
animation: paletteExpand 0.15s ease-out;
}
@keyframes paletteExpand {
from { opacity: 0; max-height: 44px; }
to { opacity: 1; max-height: 480px; }
}
@media (max-width: 768px) {
.filterRow { flex-direction: column; align-items: stretch; }
.searchInputWrap { min-width: unset; }
.searchAnchor { min-width: unset; }
}

View File

@@ -1,6 +1,13 @@
import { useRef, useEffect, useCallback } from 'react';
import { useExecutionSearch } from './use-execution-search';
import { useCommandPalette } from '../../components/command-palette/use-command-palette';
import { usePaletteSearch, type PaletteResult } from '../../components/command-palette/use-palette-search';
import { PaletteInput } from '../../components/command-palette/PaletteInput';
import { ScopeTabs } from '../../components/command-palette/ScopeTabs';
import { ResultsList } from '../../components/command-palette/ResultsList';
import { PaletteFooter } from '../../components/command-palette/PaletteFooter';
import { FilterChip } from '../../components/shared/FilterChip';
import type { ExecutionSummary, AgentInstance } from '../../api/schema';
import styles from './SearchFilters.module.css';
export function SearchFilters() {
@@ -16,7 +23,90 @@ export function SearchFilters() {
clearAll,
} = useExecutionSearch();
const execSearch = useExecutionSearch();
const { isOpen, close, scope, setScope, selectedIndex, setSelectedIndex, reset, filters } =
useCommandPalette();
const openPalette = useCommandPalette((s) => s.open);
const { results, executionCount, agentCount, isLoading } = usePaletteSearch();
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className={`${styles.filterBar} animate-in delay-3`}>
{/* Row 1: Search trigger (opens command palette) */}
{/* Row 1: Search bar with inline palette */}
<div className={styles.filterRow}>
<div className={styles.searchInputWrap} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<span className={styles.searchPlaceholder}>
{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...'}
</span>
<span className={styles.searchHint}>&#8984;K</span>
<div className={styles.searchAnchor} ref={dropdownRef}>
{isOpen ? (
<div className={styles.paletteInline}>
<PaletteInput />
<ScopeTabs executionCount={executionCount} agentCount={agentCount} />
<ResultsList results={results} isLoading={isLoading} onSelect={handleSelect} />
<PaletteFooter />
</div>
) : (
<div className={styles.searchInputWrap} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<span className={styles.searchPlaceholder}>
{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...'}
</span>
<span className={styles.searchHint}>&#8984;K</span>
</div>
)}
</div>
</div>

View File

@@ -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<ExecutionSearchState>((set, get) => ({
status: ['COMPLETED', 'FAILED'],
timeFrom: '',
timeFrom: todayMidnight(),
timeTo: '',
durationMin: null,
durationMax: null,
@@ -62,7 +70,7 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
clearAll: () =>
set({
status: ['COMPLETED', 'FAILED', 'RUNNING'],
timeFrom: '',
timeFrom: todayMidnight(),
timeTo: '',
durationMin: null,
durationMax: null,