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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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}>⌘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}>⌘K</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user