Add Cmd+K command palette for searching executions and agents
All checks were successful
CI / build (push) Successful in 59s
CI / docker (push) Successful in 56s
CI / deploy (push) Successful in 26s

Backend: add routeId, agentId, processorType filter fields to SearchRequest
and ClickHouseSearchEngine. Expand global text search to match route_id and
agent_id columns.

Frontend: new command palette component (portal overlay, Zustand store,
TanStack Query search hook with 300ms debounce, filter chip parsing,
keyboard navigation, scope tabs). Search bar in SearchFilters and TopNav
now open the palette. Selecting a result writes filters to the execution
search store to drive the results table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-13 16:28:16 +01:00
parent 6f415cb017
commit 64b03a4e2f
20 changed files with 1348 additions and 67 deletions

View File

@@ -41,6 +41,9 @@ public class SearchController {
@RequestParam(required = false) Instant timeTo,
@RequestParam(required = false) String correlationId,
@RequestParam(required = false) String text,
@RequestParam(required = false) String routeId,
@RequestParam(required = false) String agentId,
@RequestParam(required = false) String processorType,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit) {
@@ -49,6 +52,7 @@ public class SearchController {
null, null,
correlationId,
text, null, null, null,
routeId, agentId, processorType,
offset, limit
);

View File

@@ -109,9 +109,23 @@ public class ClickHouseSearchEngine implements SearchEngine {
conditions.add("correlation_id = ?");
params.add(req.correlationId());
}
if (req.routeId() != null && !req.routeId().isBlank()) {
conditions.add("route_id = ?");
params.add(req.routeId());
}
if (req.agentId() != null && !req.agentId().isBlank()) {
conditions.add("agent_id = ?");
params.add(req.agentId());
}
if (req.processorType() != null && !req.processorType().isBlank()) {
conditions.add("has(processor_types, ?)");
params.add(req.processorType());
}
if (req.text() != null && !req.text().isBlank()) {
String pattern = "%" + escapeLike(req.text()) + "%";
conditions.add("(error_message LIKE ? OR error_stacktrace LIKE ? OR exchange_bodies LIKE ? OR exchange_headers LIKE ?)");
conditions.add("(route_id LIKE ? OR agent_id LIKE ? OR error_message LIKE ? OR error_stacktrace LIKE ? OR exchange_bodies LIKE ? OR exchange_headers LIKE ?)");
params.add(pattern);
params.add(pattern);
params.add(pattern);
params.add(pattern);
params.add(pattern);

View File

@@ -18,6 +18,9 @@ import java.time.Instant;
* @param textInBody full-text search scoped to exchange bodies
* @param textInHeaders full-text search scoped to exchange headers
* @param textInErrors full-text search scoped to error messages and stack traces
* @param routeId exact match on route_id
* @param agentId exact match on agent_id
* @param processorType matches processor_types array via has()
* @param offset pagination offset (0-based)
* @param limit page size (default 50, max 500)
*/
@@ -32,6 +35,9 @@ public record SearchRequest(
String textInBody,
String textInHeaders,
String textInErrors,
String routeId,
String agentId,
String processorType,
int offset,
int limit
) {

View File

@@ -122,6 +122,9 @@ export interface SearchRequest {
textInBody?: string | null;
textInHeaders?: string | null;
textInErrors?: string | null;
routeId?: string | null;
agentId?: string | null;
processorType?: string | null;
offset?: number;
limit?: number;
}

View File

@@ -0,0 +1,489 @@
/* ── Overlay ── */
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(6, 10, 19, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
justify-content: center;
padding-top: 12vh;
animation: fadeIn 0.12s ease-out;
}
[data-theme="light"] .overlay {
background: rgba(247, 245, 242, 0.75);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes slideInResult {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Modal ── */
.modal {
width: 680px;
max-height: 520px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 16px 72px rgba(0, 0, 0, 0.5), 0 0 40px rgba(240, 180, 41, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
align-self: flex-start;
}
/* ── Input Area ── */
.inputWrap {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-subtle);
gap: 10px;
}
.searchIcon {
width: 20px;
height: 20px;
color: var(--amber);
flex-shrink: 0;
filter: drop-shadow(0 0 6px var(--amber-glow));
}
.chipList {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
background: var(--amber-glow);
color: var(--amber);
font-size: 12px;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
font-family: var(--font-mono);
}
.chipKey {
color: var(--text-muted);
font-size: 11px;
}
.chipRemove {
background: none;
border: none;
color: var(--amber);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 0 0 2px;
opacity: 0.5;
}
.chipRemove:hover {
opacity: 1;
}
.input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 16px;
font-family: var(--font-body);
color: var(--text-primary);
caret-color: var(--amber);
min-width: 100px;
}
.input::placeholder {
color: var(--text-muted);
}
.inputHint {
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 5px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 4px;
line-height: 1.5;
color: var(--text-muted);
}
/* ── Scope Tabs ── */
.scopeTabs {
display: flex;
padding: 8px 18px 0;
gap: 2px;
border-bottom: 1px solid var(--border-subtle);
}
.scopeTab {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
border: none;
background: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.scopeTab:hover {
color: var(--text-secondary);
}
.scopeTabActive {
composes: scopeTab;
color: var(--amber);
border-bottom-color: var(--amber);
}
.scopeCount {
font-size: 10px;
padding: 1px 6px;
background: var(--bg-raised);
border-radius: 10px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.scopeTabActive .scopeCount {
background: var(--amber-glow);
color: var(--amber);
}
.scopeTabDisabled {
composes: scopeTab;
opacity: 0.4;
cursor: default;
}
/* ── Results ── */
.results {
flex: 1;
overflow-y: auto;
padding: 6px 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.results::-webkit-scrollbar { width: 6px; }
.results::-webkit-scrollbar-track { background: transparent; }
.results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.groupLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-muted);
padding: 10px 12px 4px;
}
.resultItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.1s;
animation: slideInResult 0.2s ease-out both;
}
.resultItem:nth-child(2) { animation-delay: 0.03s; }
.resultItem:nth-child(3) { animation-delay: 0.06s; }
.resultItem:nth-child(4) { animation-delay: 0.09s; }
.resultItem:nth-child(5) { animation-delay: 0.12s; }
.resultItem:hover {
background: var(--bg-hover);
}
.resultItemSelected {
composes: resultItem;
background: var(--amber-glow);
outline: 1px solid rgba(240, 180, 41, 0.2);
}
.resultItemSelected:hover {
background: var(--amber-glow);
}
/* ── Result Icon ── */
.resultIcon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resultIcon svg {
width: 18px;
height: 18px;
}
.iconExecution {
composes: resultIcon;
background: rgba(59, 130, 246, 0.12);
color: var(--blue);
}
.iconAgent {
composes: resultIcon;
background: var(--green-glow);
color: var(--green);
}
.iconError {
composes: resultIcon;
background: var(--rose-glow);
color: var(--rose);
}
/* ── Result Body ── */
.resultBody {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.resultTitle {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
line-height: 1.3;
}
.highlight {
color: var(--amber);
font-weight: 600;
}
.resultMeta {
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.5;
flex-shrink: 0;
}
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 12px;
line-height: 1.4;
white-space: nowrap;
}
.badgeCompleted {
composes: badge;
background: var(--green-glow);
color: var(--green);
}
.badgeFailed {
composes: badge;
background: var(--rose-glow);
color: var(--rose);
}
.badgeRunning {
composes: badge;
background: rgba(240, 180, 41, 0.12);
color: var(--amber);
}
.badgeDuration {
composes: badge;
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10.5px;
}
.badgeRoute {
composes: badge;
background: rgba(168, 85, 247, 0.1);
color: var(--purple);
font-family: var(--font-mono);
font-size: 10.5px;
}
.badgeLive {
composes: badge;
background: var(--green-glow);
color: var(--green);
}
.badgeStale {
composes: badge;
background: rgba(240, 180, 41, 0.12);
color: var(--amber);
}
.badgeDead {
composes: badge;
background: var(--rose-glow);
color: var(--rose);
}
.resultRight {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
padding-top: 2px;
}
.resultTime {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
}
/* ── Empty / Loading ── */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--text-muted);
gap: 8px;
}
.emptyIcon {
width: 40px;
height: 40px;
opacity: 0.4;
}
.emptyText {
font-size: 14px;
}
.emptyHint {
font-size: 12px;
opacity: 0.6;
}
.loadingDots {
display: flex;
gap: 4px;
padding: 24px;
justify-content: center;
}
.loadingDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: pulse 1.2s ease-in-out infinite;
}
.loadingDot:nth-child(2) { animation-delay: 0.2s; }
.loadingDot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* ── Footer ── */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 18px;
border-top: 1px solid var(--border-subtle);
background: var(--bg-raised);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.footerHints {
display: flex;
gap: 16px;
font-size: 11px;
color: var(--text-muted);
}
.footerHint {
display: flex;
align-items: center;
gap: 5px;
}
.footerBrand {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* ── Responsive ── */
@media (max-width: 768px) {
.modal {
width: calc(100vw - 32px);
max-height: 70vh;
}
}

View File

@@ -0,0 +1,131 @@
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'];
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.setText(exec.executionId);
execSearch.setRouteId('');
execSearch.setAgentId('');
execSearch.setProcessorType('');
} else if (result.type === 'agent') {
const agent = result.data as AgentInstance;
execSearch.setAgentId(agent.agentId);
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') {
e.preventDefault();
const store = useCommandPalette.getState();
if (store.isOpen) {
store.close();
store.reset();
} else {
store.open();
}
}
}
document.addEventListener('keydown', onKeyDown);
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,
);
}

View File

@@ -0,0 +1,24 @@
import styles from './CommandPalette.module.css';
export function PaletteFooter() {
return (
<div className={styles.footer}>
<div className={styles.footerHints}>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>&uarr;</kbd>
<kbd className={styles.kbd}>&darr;</kbd> navigate
</span>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>&crarr;</kbd> open
</span>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>tab</kbd> scope
</span>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>esc</kbd> close
</span>
</div>
<span className={styles.footerBrand}>cameleer3</span>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useRef, useEffect } from 'react';
import { useCommandPalette } from './use-command-palette';
import { parseFilterPrefix, checkTrailingFilter } from './utils';
import styles from './CommandPalette.module.css';
export function PaletteInput() {
const { query, filters, setQuery, addFilter, removeLastFilter, removeFilter } =
useCommandPalette();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
function handleChange(value: string) {
// Check if user typed a filter prefix like "status:failed "
const parsed = parseFilterPrefix(value);
if (parsed) {
addFilter(parsed.filter);
setQuery(parsed.remaining);
return;
}
const trailing = checkTrailingFilter(value);
if (trailing) {
addFilter(trailing);
setQuery('');
return;
}
setQuery(value);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Backspace' && query === '' && filters.length > 0) {
e.preventDefault();
removeLastFilter();
}
}
return (
<div className={styles.inputWrap}>
<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 21-4.35-4.35" />
</svg>
{filters.length > 0 && (
<div className={styles.chipList}>
{filters.map((f, i) => (
<span key={f.key} className={styles.chip}>
<span className={styles.chipKey}>{f.key}:</span>
{f.value}
<button className={styles.chipRemove} onClick={() => removeFilter(i)}>
&times;
</button>
</span>
))}
</div>
)}
<input
ref={inputRef}
className={styles.input}
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={filters.length > 0 ? 'Refine search...' : 'Search executions, agents...'}
/>
<div className={styles.inputHint}>
<kbd className={styles.kbd}>esc</kbd> close
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import type { ExecutionSummary, AgentInstance } from '../../api/schema';
import type { PaletteResult } from './use-palette-search';
import { highlightMatch, formatRelativeTime } from './utils';
import styles from './CommandPalette.module.css';
interface ResultItemProps {
result: PaletteResult;
selected: boolean;
query: string;
onClick: () => void;
}
function HighlightedText({ text, query }: { text: string; query: string }) {
const parts = highlightMatch(text, query);
return (
<>
{parts.map((p, i) =>
typeof p === 'string' ? (
<span key={i}>{p}</span>
) : (
<span key={i} className={styles.highlight}>{p.highlight}</span>
),
)}
</>
);
}
function statusBadgeClass(status: string): string {
switch (status.toUpperCase()) {
case 'COMPLETED': return styles.badgeCompleted;
case 'FAILED': return styles.badgeFailed;
case 'RUNNING': return styles.badgeRunning;
default: return styles.badge;
}
}
function stateBadgeClass(state: string): string {
switch (state) {
case 'LIVE': return styles.badgeLive;
case 'STALE': return styles.badgeStale;
case 'DEAD': return styles.badgeDead;
default: return styles.badge;
}
}
function ExecutionResult({ data, query }: { data: ExecutionSummary; query: string }) {
const isFailed = data.status === 'FAILED';
return (
<>
<div className={isFailed ? styles.iconError : styles.iconExecution}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</div>
<div className={styles.resultBody}>
<div className={styles.resultTitle}>
<HighlightedText text={data.routeId} query={query} />
<span className={statusBadgeClass(data.status)}>{data.status}</span>
<span className={styles.badgeDuration}>{data.durationMs}ms</span>
</div>
<div className={styles.resultMeta}>
<span className={styles.badgeRoute}>{data.agentId}</span>
<span className={styles.sep} />
<HighlightedText text={data.executionId.slice(0, 16)} query={query} />
{data.errorMessage && (
<>
<span className={styles.sep} />
<span style={{ color: 'var(--rose)' }}>
{data.errorMessage.slice(0, 60)}
{data.errorMessage.length > 60 ? '...' : ''}
</span>
</>
)}
</div>
</div>
<div className={styles.resultRight}>
<span className={styles.resultTime}>{formatRelativeTime(data.startTime)}</span>
</div>
</>
);
}
function AgentResult({ data, query }: { data: AgentInstance; query: string }) {
return (
<>
<div className={styles.iconAgent}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
</div>
<div className={styles.resultBody}>
<div className={styles.resultTitle}>
<HighlightedText text={data.agentId} query={query} />
<span className={stateBadgeClass(data.state)}>{data.state}</span>
</div>
<div className={styles.resultMeta}>
<span>group: {data.group}</span>
<span className={styles.sep} />
<span>last heartbeat: {formatRelativeTime(data.lastHeartbeat)}</span>
</div>
</div>
<div className={styles.resultRight}>
<span className={styles.resultTime}>Agent</span>
</div>
</>
);
}
export function ResultItem({ result, selected, query, onClick }: ResultItemProps) {
return (
<div
className={selected ? styles.resultItemSelected : styles.resultItem}
onClick={onClick}
data-palette-item
>
{result.type === 'execution' && (
<ExecutionResult data={result.data as ExecutionSummary} query={query} />
)}
{result.type === 'agent' && (
<AgentResult data={result.data as AgentInstance} query={query} />
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useRef, useEffect } from 'react';
import { useCommandPalette } from './use-command-palette';
import type { PaletteResult } from './use-palette-search';
import { ResultItem } from './ResultItem';
import styles from './CommandPalette.module.css';
interface ResultsListProps {
results: PaletteResult[];
isLoading: boolean;
onSelect: (result: PaletteResult) => void;
}
export function ResultsList({ results, isLoading, onSelect }: ResultsListProps) {
const { selectedIndex, query } = useCommandPalette();
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = listRef.current?.querySelector('[data-palette-item].selected, [data-palette-item]:nth-child(' + (selectedIndex + 1) + ')');
if (!el) return;
const items = listRef.current?.querySelectorAll('[data-palette-item]');
items?.[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (isLoading && results.length === 0) {
return (
<div className={styles.results}>
<div className={styles.loadingDots}>
<div className={styles.loadingDot} />
<div className={styles.loadingDot} />
<div className={styles.loadingDot} />
</div>
</div>
);
}
if (results.length === 0) {
return (
<div className={styles.results}>
<div className={styles.emptyState}>
<svg className={styles.emptyIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<span className={styles.emptyText}>No results found</span>
<span className={styles.emptyHint}>
Try a different search or use filters like status:failed
</span>
</div>
</div>
);
}
// Group results by type
const executions = results.filter((r) => r.type === 'execution');
const agents = results.filter((r) => r.type === 'agent');
let globalIndex = 0;
return (
<div className={styles.results} ref={listRef}>
{executions.length > 0 && (
<>
<div className={styles.groupLabel}>Executions</div>
{executions.map((r) => {
const idx = globalIndex++;
return (
<ResultItem
key={r.id}
result={r}
selected={idx === selectedIndex}
query={query}
onClick={() => onSelect(r)}
/>
);
})}
</>
)}
{agents.length > 0 && (
<>
<div className={styles.groupLabel}>Agents</div>
{agents.map((r) => {
const idx = globalIndex++;
return (
<ResultItem
key={r.id}
result={r}
selected={idx === selectedIndex}
query={query}
onClick={() => onSelect(r)}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useCommandPalette, type PaletteScope } from './use-command-palette';
import styles from './CommandPalette.module.css';
interface ScopeTabsProps {
executionCount: number;
agentCount: number;
}
const SCOPES: { key: PaletteScope; label: string; disabled?: boolean }[] = [
{ key: 'all', label: 'All' },
{ key: 'executions', label: 'Executions' },
{ key: 'agents', label: 'Agents' },
];
export function ScopeTabs({ executionCount, agentCount }: ScopeTabsProps) {
const { scope, setScope } = useCommandPalette();
function getCount(key: PaletteScope): number {
if (key === 'all') return executionCount + agentCount;
if (key === 'executions') return executionCount;
if (key === 'agents') return agentCount;
return 0;
}
return (
<div className={styles.scopeTabs}>
{SCOPES.map((s) => (
<button
key={s.key}
className={scope === s.key ? styles.scopeTabActive : styles.scopeTab}
onClick={() => !s.disabled && setScope(s.key)}
>
{s.label}
<span className={styles.scopeCount}>{getCount(s.key)}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { create } from 'zustand';
export type PaletteScope = 'all' | 'executions' | 'agents';
export interface PaletteFilter {
key: 'status' | 'route' | 'agent' | 'processor';
value: string;
}
interface CommandPaletteState {
isOpen: boolean;
query: string;
scope: PaletteScope;
filters: PaletteFilter[];
selectedIndex: number;
open: () => void;
close: () => void;
setQuery: (q: string) => void;
setScope: (s: PaletteScope) => void;
addFilter: (f: PaletteFilter) => void;
removeLastFilter: () => void;
removeFilter: (index: number) => void;
setSelectedIndex: (i: number) => void;
reset: () => void;
}
export const useCommandPalette = create<CommandPaletteState>((set) => ({
isOpen: false,
query: '',
scope: 'all',
filters: [],
selectedIndex: 0,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false, selectedIndex: 0 }),
setQuery: (q) => set({ query: q, selectedIndex: 0 }),
setScope: (s) => set({ scope: s, selectedIndex: 0 }),
addFilter: (f) =>
set((state) => ({
filters: [...state.filters.filter((x) => x.key !== f.key), f],
query: '',
selectedIndex: 0,
})),
removeLastFilter: () =>
set((state) => ({
filters: state.filters.slice(0, -1),
selectedIndex: 0,
})),
removeFilter: (index) =>
set((state) => ({
filters: state.filters.filter((_, i) => i !== index),
selectedIndex: 0,
})),
setSelectedIndex: (i) => set({ selectedIndex: i }),
reset: () => set({ query: '', scope: 'all', filters: [], selectedIndex: 0 }),
}));

View File

@@ -0,0 +1,93 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { ExecutionSummary, AgentInstance } from '../../api/schema';
import { useCommandPalette, type PaletteScope } from './use-command-palette';
import { useDebouncedValue } from './utils';
export interface PaletteResult {
type: 'execution' | 'agent';
id: string;
data: ExecutionSummary | AgentInstance;
}
function isExecutionScope(scope: PaletteScope) {
return scope === 'all' || scope === 'executions';
}
function isAgentScope(scope: PaletteScope) {
return scope === 'all' || scope === 'agents';
}
export function usePaletteSearch() {
const { query, scope, filters, isOpen } = useCommandPalette();
const debouncedQuery = useDebouncedValue(query, 300);
const statusFilter = filters.find((f) => f.key === 'status')?.value;
const routeFilter = filters.find((f) => f.key === 'route')?.value;
const agentFilter = filters.find((f) => f.key === 'agent')?.value;
const processorFilter = filters.find((f) => f.key === 'processor')?.value;
const executionsQuery = useQuery({
queryKey: ['palette', 'executions', debouncedQuery, statusFilter, routeFilter, agentFilter, processorFilter],
queryFn: async () => {
const { data, error } = await api.POST('/search/executions', {
body: {
text: debouncedQuery || undefined,
status: statusFilter || undefined,
routeId: routeFilter || undefined,
agentId: agentFilter || undefined,
processorType: processorFilter || undefined,
limit: 10,
offset: 0,
},
});
if (error) throw new Error('Search failed');
return data!;
},
enabled: isOpen && isExecutionScope(scope),
placeholderData: (prev) => prev,
});
const agentsQuery = useQuery({
queryKey: ['agents'],
queryFn: async () => {
const { data, error } = await api.GET('/agents', {
params: { query: {} },
});
if (error) throw new Error('Failed to load agents');
return data!;
},
enabled: isOpen && isAgentScope(scope),
staleTime: 30_000,
});
const executionResults: PaletteResult[] = (executionsQuery.data?.data ?? []).map((e) => ({
type: 'execution' as const,
id: e.executionId,
data: e,
}));
const filteredAgents = (agentsQuery.data ?? []).filter((a) => {
if (!debouncedQuery) return true;
const q = debouncedQuery.toLowerCase();
return a.agentId.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
});
const agentResults: PaletteResult[] = filteredAgents.slice(0, 10).map((a) => ({
type: 'agent' as const,
id: a.agentId,
data: a,
}));
let results: PaletteResult[] = [];
if (scope === 'all') results = [...executionResults, ...agentResults];
else if (scope === 'executions') results = executionResults;
else if (scope === 'agents') results = agentResults;
return {
results,
executionCount: executionsQuery.data?.total ?? 0,
agentCount: filteredAgents.length,
isLoading: executionsQuery.isFetching || agentsQuery.isFetching,
};
}

View File

@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import type { PaletteFilter } from './use-command-palette';
const FILTER_PREFIXES = ['status:', 'route:', 'agent:', 'processor:'] as const;
type FilterKey = PaletteFilter['key'];
const PREFIX_TO_KEY: Record<string, FilterKey> = {
'status:': 'status',
'route:': 'route',
'agent:': 'agent',
'processor:': 'processor',
};
export function parseFilterPrefix(
input: string,
): { filter: PaletteFilter; remaining: string } | null {
for (const prefix of FILTER_PREFIXES) {
if (input.startsWith(prefix)) {
const value = input.slice(prefix.length).trim();
if (value && value.includes(' ')) {
const spaceIdx = value.indexOf(' ');
return {
filter: { key: PREFIX_TO_KEY[prefix], value: value.slice(0, spaceIdx) },
remaining: value.slice(spaceIdx + 1).trim(),
};
}
}
}
return null;
}
export function checkTrailingFilter(input: string): PaletteFilter | null {
for (const prefix of FILTER_PREFIXES) {
if (input.endsWith(' ') && input.trimEnd().length > prefix.length) {
const trimmed = input.trimEnd();
for (const p of FILTER_PREFIXES) {
const idx = trimmed.lastIndexOf(p);
if (idx !== -1 && idx === trimmed.length - p.length - (trimmed.length - trimmed.lastIndexOf(p) - p.length)) {
// This is getting complex, let's use a simpler approach
}
}
}
}
// Simple approach: check if last word matches prefix:value pattern
const words = input.trimEnd().split(/\s+/);
const lastWord = words[words.length - 1];
for (const prefix of FILTER_PREFIXES) {
if (lastWord.startsWith(prefix) && lastWord.length > prefix.length && input.endsWith(' ')) {
return {
key: PREFIX_TO_KEY[prefix],
value: lastWord.slice(prefix.length),
};
}
}
return null;
}
export function highlightMatch(text: string, query: string): (string | { highlight: string })[] {
if (!query) return [text];
const lower = text.toLowerCase();
const qLower = query.toLowerCase();
const idx = lower.indexOf(qLower);
if (idx === -1) return [text];
return [
text.slice(0, idx),
{ highlight: text.slice(idx, idx + query.length) },
text.slice(idx + query.length),
].filter((s) => (typeof s === 'string' ? s.length > 0 : true));
}
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
export function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

View File

@@ -1,5 +1,6 @@
import { Outlet } from 'react-router';
import { TopNav } from './TopNav';
import { CommandPalette } from '../command-palette/CommandPalette';
import styles from './AppShell.module.css';
export function AppShell() {
@@ -9,6 +10,7 @@ export function AppShell() {
<main className={styles.main}>
<Outlet />
</main>
<CommandPalette />
</>
);
}

View File

@@ -61,6 +61,43 @@
gap: 16px;
}
.searchTrigger {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px 5px 10px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 13px;
font-family: var(--font-body);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.searchTrigger:hover {
border-color: var(--text-muted);
color: var(--text-secondary);
}
.searchTrigger svg {
width: 14px;
height: 14px;
opacity: 0.5;
}
.kbdKey {
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 5px;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
line-height: 1.4;
}
.envBadge {
font-family: var(--font-mono);
font-size: 11px;

View File

@@ -1,11 +1,13 @@
import { NavLink } from 'react-router';
import { useThemeStore } from '../../theme/theme-store';
import { useAuthStore } from '../../auth/auth-store';
import { useCommandPalette } from '../command-palette/use-command-palette';
import styles from './TopNav.module.css';
export function TopNav() {
const { theme, toggle } = useThemeStore();
const { username, logout } = useAuthStore();
const openPalette = useCommandPalette((s) => s.open);
return (
<nav className={styles.topnav}>
@@ -26,6 +28,14 @@ export function TopNav() {
</ul>
<div className={styles.navRight}>
<button className={styles.searchTrigger} onClick={openPalette}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
Search...
<kbd className={styles.kbdKey}>&#8984;K</kbd>
</button>
<span className={styles.envBadge}>PRODUCTION</span>
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}

View File

@@ -20,6 +20,18 @@
flex: 1;
min-width: 300px;
position: relative;
display: flex;
align-items: center;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.searchInputWrap:hover {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.searchIcon {
@@ -32,27 +44,18 @@
color: var(--text-muted);
}
.searchInput {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
.searchPlaceholder {
flex: 1;
padding: 10px 14px 10px 40px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.searchInput::placeholder {
color: var(--text-muted);
font-family: var(--font-body);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.searchInput:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
.searchInputWrap:hover .searchPlaceholder {
color: var(--text-secondary);
}
.searchHint {
@@ -136,35 +139,6 @@
min-width: 50px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--text-muted); }
.btnPrimary {
composes: btn;
background: var(--amber);
color: #0a0e17;
border-color: var(--amber);
font-weight: 600;
}
.btnPrimary:hover { background: var(--amber-hover); border-color: var(--amber-hover); color: #0a0e17; }
.filterTags {
display: flex;
gap: 6px;

View File

@@ -1,5 +1,5 @@
import { useRef, useCallback } from 'react';
import { useExecutionSearch } from './use-execution-search';
import { useCommandPalette } from '../../components/command-palette/use-command-palette';
import { FilterChip } from '../../components/shared/FilterChip';
import styles from './SearchFilters.module.css';
@@ -10,21 +10,19 @@ export function SearchFilters() {
timeTo, setTimeTo,
durationMax, setDurationMax,
text, setText,
routeId, setRouteId,
agentId, setAgentId,
processorType, setProcessorType,
clearAll,
} = useExecutionSearch();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleTextChange = useCallback(
(value: string) => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setText(value), 300);
},
[setText],
);
const openPalette = useCommandPalette((s) => s.open);
const activeTags: { label: string; onRemove: () => void }[] = [];
if (text) activeTags.push({ label: `text:"${text}"`, onRemove: () => setText('') });
if (routeId) activeTags.push({ label: `route:${routeId}`, onRemove: () => setRouteId('') });
if (agentId) activeTags.push({ label: `agent:${agentId}`, onRemove: () => setAgentId('') });
if (processorType) activeTags.push({ label: `processor:${processorType}`, onRemove: () => setProcessorType('') });
if (timeFrom) activeTags.push({ label: `from:${timeFrom}`, onRemove: () => setTimeFrom('') });
if (timeTo) activeTags.push({ label: `to:${timeTo}`, onRemove: () => setTimeTo('') });
if (durationMax && durationMax < 5000) {
@@ -33,23 +31,20 @@ export function SearchFilters() {
return (
<div className={`${styles.filterBar} animate-in delay-3`}>
{/* Row 1: Search */}
{/* Row 1: Search trigger (opens command palette) */}
<div className={styles.filterRow}>
<div className={styles.searchInputWrap}>
<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>
<input
className={styles.searchInput}
type="text"
placeholder="Search by correlation ID, error message, route ID..."
defaultValue={text}
onChange={(e) => handleTextChange(e.target.value)}
/>
<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>
<button className={styles.btnPrimary}>Search</button>
</div>
{/* Row 2: Status chips + date + duration */}

View File

@@ -8,6 +8,9 @@ interface ExecutionSearchState {
durationMin: number | null;
durationMax: number | null;
text: string;
routeId: string;
agentId: string;
processorType: string;
offset: number;
limit: number;
@@ -18,6 +21,9 @@ interface ExecutionSearchState {
setDurationMin: (v: number | null) => void;
setDurationMax: (v: number | null) => void;
setText: (v: string) => void;
setRouteId: (v: string) => void;
setAgentId: (v: string) => void;
setProcessorType: (v: string) => void;
setOffset: (v: number) => void;
clearAll: () => void;
toSearchRequest: () => SearchRequest;
@@ -30,6 +36,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
durationMin: null,
durationMax: null,
text: '',
routeId: '',
agentId: '',
processorType: '',
offset: 0,
limit: 25,
@@ -46,6 +55,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
setDurationMin: (v) => set({ durationMin: v, offset: 0 }),
setDurationMax: (v) => set({ durationMax: v, offset: 0 }),
setText: (v) => set({ text: v, offset: 0 }),
setRouteId: (v) => set({ routeId: v, offset: 0 }),
setAgentId: (v) => set({ agentId: v, offset: 0 }),
setProcessorType: (v) => set({ processorType: v, offset: 0 }),
setOffset: (v) => set({ offset: v }),
clearAll: () =>
set({
@@ -55,6 +67,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
durationMin: null,
durationMax: null,
text: '',
routeId: '',
agentId: '',
processorType: '',
offset: 0,
}),
@@ -70,6 +85,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
durationMin: s.durationMin,
durationMax: s.durationMax,
text: s.text || undefined,
routeId: s.routeId || undefined,
agentId: s.agentId || undefined,
processorType: s.processorType || undefined,
offset: s.offset,
limit: s.limit,
};