Add Cmd+K command palette for searching executions and agents
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:
3
ui/src/api/schema.d.ts
vendored
3
ui/src/api/schema.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
489
ui/src/components/command-palette/CommandPalette.module.css
Normal file
489
ui/src/components/command-palette/CommandPalette.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
131
ui/src/components/command-palette/CommandPalette.tsx
Normal file
131
ui/src/components/command-palette/CommandPalette.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
24
ui/src/components/command-palette/PaletteFooter.tsx
Normal file
24
ui/src/components/command-palette/PaletteFooter.tsx
Normal 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}>↑</kbd>
|
||||
<kbd className={styles.kbd}>↓</kbd> navigate
|
||||
</span>
|
||||
<span className={styles.footerHint}>
|
||||
<kbd className={styles.kbd}>↵</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>
|
||||
);
|
||||
}
|
||||
72
ui/src/components/command-palette/PaletteInput.tsx
Normal file
72
ui/src/components/command-palette/PaletteInput.tsx
Normal 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)}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
125
ui/src/components/command-palette/ResultItem.tsx
Normal file
125
ui/src/components/command-palette/ResultItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
ui/src/components/command-palette/ResultsList.tsx
Normal file
97
ui/src/components/command-palette/ResultsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
ui/src/components/command-palette/ScopeTabs.tsx
Normal file
39
ui/src/components/command-palette/ScopeTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
ui/src/components/command-palette/use-command-palette.ts
Normal file
57
ui/src/components/command-palette/use-command-palette.ts
Normal 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 }),
|
||||
}));
|
||||
93
ui/src/components/command-palette/use-palette-search.ts
Normal file
93
ui/src/components/command-palette/use-palette-search.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
91
ui/src/components/command-palette/utils.ts
Normal file
91
ui/src/components/command-palette/utils.ts
Normal 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`;
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>⌘K</kbd>
|
||||
</button>
|
||||
<span className={styles.envBadge}>PRODUCTION</span>
|
||||
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
|
||||
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>⌘K</span>
|
||||
</div>
|
||||
<button className={styles.btnPrimary}>Search</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Status chips + date + duration */}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user