diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java index d80fd006..424a05ad 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java @@ -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 ); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java index f8f6e5c5..655793b6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java @@ -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); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java index 7aa217c6..a6396b23 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java @@ -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 ) { diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 6147c8b5..77b9a061 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -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; } diff --git a/ui/src/components/command-palette/CommandPalette.module.css b/ui/src/components/command-palette/CommandPalette.module.css new file mode 100644 index 00000000..4a20e0d8 --- /dev/null +++ b/ui/src/components/command-palette/CommandPalette.module.css @@ -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; + } +} diff --git a/ui/src/components/command-palette/CommandPalette.tsx b/ui/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 00000000..8817321b --- /dev/null +++ b/ui/src/components/command-palette/CommandPalette.tsx @@ -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( +
{ + if (e.target === e.currentTarget) { + close(); + reset(); + } + }}> +
+ + + + +
+
, + document.body, + ); +} diff --git a/ui/src/components/command-palette/PaletteFooter.tsx b/ui/src/components/command-palette/PaletteFooter.tsx new file mode 100644 index 00000000..28c6f508 --- /dev/null +++ b/ui/src/components/command-palette/PaletteFooter.tsx @@ -0,0 +1,24 @@ +import styles from './CommandPalette.module.css'; + +export function PaletteFooter() { + return ( +
+
+ + + navigate + + + open + + + tab scope + + + esc close + +
+ cameleer3 +
+ ); +} diff --git a/ui/src/components/command-palette/PaletteInput.tsx b/ui/src/components/command-palette/PaletteInput.tsx new file mode 100644 index 00000000..c188ead5 --- /dev/null +++ b/ui/src/components/command-palette/PaletteInput.tsx @@ -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(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 ( +
+ + + + + {filters.length > 0 && ( +
+ {filters.map((f, i) => ( + + {f.key}: + {f.value} + + + ))} +
+ )} + handleChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={filters.length > 0 ? 'Refine search...' : 'Search executions, agents...'} + /> +
+ esc close +
+
+ ); +} diff --git a/ui/src/components/command-palette/ResultItem.tsx b/ui/src/components/command-palette/ResultItem.tsx new file mode 100644 index 00000000..f985ea07 --- /dev/null +++ b/ui/src/components/command-palette/ResultItem.tsx @@ -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' ? ( + {p} + ) : ( + {p.highlight} + ), + )} + + ); +} + +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 ( + <> +
+ + + +
+
+
+ + {data.status} + {data.durationMs}ms +
+
+ {data.agentId} + + + {data.errorMessage && ( + <> + + + {data.errorMessage.slice(0, 60)} + {data.errorMessage.length > 60 ? '...' : ''} + + + )} +
+
+
+ {formatRelativeTime(data.startTime)} +
+ + ); +} + +function AgentResult({ data, query }: { data: AgentInstance; query: string }) { + return ( + <> +
+ + + + +
+
+
+ + {data.state} +
+
+ group: {data.group} + + last heartbeat: {formatRelativeTime(data.lastHeartbeat)} +
+
+
+ Agent +
+ + ); +} + +export function ResultItem({ result, selected, query, onClick }: ResultItemProps) { + return ( +
+ {result.type === 'execution' && ( + + )} + {result.type === 'agent' && ( + + )} +
+ ); +} diff --git a/ui/src/components/command-palette/ResultsList.tsx b/ui/src/components/command-palette/ResultsList.tsx new file mode 100644 index 00000000..b721eccb --- /dev/null +++ b/ui/src/components/command-palette/ResultsList.tsx @@ -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(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 ( +
+
+
+
+
+
+
+ ); + } + + if (results.length === 0) { + return ( +
+
+ + + + + No results found + + Try a different search or use filters like status:failed + +
+
+ ); + } + + // Group results by type + const executions = results.filter((r) => r.type === 'execution'); + const agents = results.filter((r) => r.type === 'agent'); + + let globalIndex = 0; + + return ( +
+ {executions.length > 0 && ( + <> +
Executions
+ {executions.map((r) => { + const idx = globalIndex++; + return ( + onSelect(r)} + /> + ); + })} + + )} + {agents.length > 0 && ( + <> +
Agents
+ {agents.map((r) => { + const idx = globalIndex++; + return ( + onSelect(r)} + /> + ); + })} + + )} +
+ ); +} diff --git a/ui/src/components/command-palette/ScopeTabs.tsx b/ui/src/components/command-palette/ScopeTabs.tsx new file mode 100644 index 00000000..39629f31 --- /dev/null +++ b/ui/src/components/command-palette/ScopeTabs.tsx @@ -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 ( +
+ {SCOPES.map((s) => ( + + ))} +
+ ); +} diff --git a/ui/src/components/command-palette/use-command-palette.ts b/ui/src/components/command-palette/use-command-palette.ts new file mode 100644 index 00000000..71b396bb --- /dev/null +++ b/ui/src/components/command-palette/use-command-palette.ts @@ -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((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 }), +})); diff --git a/ui/src/components/command-palette/use-palette-search.ts b/ui/src/components/command-palette/use-palette-search.ts new file mode 100644 index 00000000..286f228c --- /dev/null +++ b/ui/src/components/command-palette/use-palette-search.ts @@ -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, + }; +} diff --git a/ui/src/components/command-palette/utils.ts b/ui/src/components/command-palette/utils.ts new file mode 100644 index 00000000..1b9eb3cf --- /dev/null +++ b/ui/src/components/command-palette/utils.ts @@ -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 = { + '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(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`; +} diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx index 7fdd9514..2847c9d8 100644 --- a/ui/src/components/layout/AppShell.tsx +++ b/ui/src/components/layout/AppShell.tsx @@ -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() {
+ ); } diff --git a/ui/src/components/layout/TopNav.module.css b/ui/src/components/layout/TopNav.module.css index cac7dd1f..2503b35b 100644 --- a/ui/src/components/layout/TopNav.module.css +++ b/ui/src/components/layout/TopNav.module.css @@ -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; diff --git a/ui/src/components/layout/TopNav.tsx b/ui/src/components/layout/TopNav.tsx index 89e9c84b..c2659799 100644 --- a/ui/src/components/layout/TopNav.tsx +++ b/ui/src/components/layout/TopNav.tsx @@ -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 (