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:
@@ -41,6 +41,9 @@ public class SearchController {
|
|||||||
@RequestParam(required = false) Instant timeTo,
|
@RequestParam(required = false) Instant timeTo,
|
||||||
@RequestParam(required = false) String correlationId,
|
@RequestParam(required = false) String correlationId,
|
||||||
@RequestParam(required = false) String text,
|
@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 = "0") int offset,
|
||||||
@RequestParam(defaultValue = "50") int limit) {
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ public class SearchController {
|
|||||||
null, null,
|
null, null,
|
||||||
correlationId,
|
correlationId,
|
||||||
text, null, null, null,
|
text, null, null, null,
|
||||||
|
routeId, agentId, processorType,
|
||||||
offset, limit
|
offset, limit
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -109,9 +109,23 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
|||||||
conditions.add("correlation_id = ?");
|
conditions.add("correlation_id = ?");
|
||||||
params.add(req.correlationId());
|
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()) {
|
if (req.text() != null && !req.text().isBlank()) {
|
||||||
String pattern = "%" + escapeLike(req.text()) + "%";
|
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);
|
params.add(pattern);
|
||||||
params.add(pattern);
|
params.add(pattern);
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import java.time.Instant;
|
|||||||
* @param textInBody full-text search scoped to exchange bodies
|
* @param textInBody full-text search scoped to exchange bodies
|
||||||
* @param textInHeaders full-text search scoped to exchange headers
|
* @param textInHeaders full-text search scoped to exchange headers
|
||||||
* @param textInErrors full-text search scoped to error messages and stack traces
|
* @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 offset pagination offset (0-based)
|
||||||
* @param limit page size (default 50, max 500)
|
* @param limit page size (default 50, max 500)
|
||||||
*/
|
*/
|
||||||
@@ -32,6 +35,9 @@ public record SearchRequest(
|
|||||||
String textInBody,
|
String textInBody,
|
||||||
String textInHeaders,
|
String textInHeaders,
|
||||||
String textInErrors,
|
String textInErrors,
|
||||||
|
String routeId,
|
||||||
|
String agentId,
|
||||||
|
String processorType,
|
||||||
int offset,
|
int offset,
|
||||||
int limit
|
int limit
|
||||||
) {
|
) {
|
||||||
|
|||||||
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;
|
textInBody?: string | null;
|
||||||
textInHeaders?: string | null;
|
textInHeaders?: string | null;
|
||||||
textInErrors?: string | null;
|
textInErrors?: string | null;
|
||||||
|
routeId?: string | null;
|
||||||
|
agentId?: string | null;
|
||||||
|
processorType?: string | null;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: 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 { Outlet } from 'react-router';
|
||||||
import { TopNav } from './TopNav';
|
import { TopNav } from './TopNav';
|
||||||
|
import { CommandPalette } from '../command-palette/CommandPalette';
|
||||||
import styles from './AppShell.module.css';
|
import styles from './AppShell.module.css';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -9,6 +10,7 @@ export function AppShell() {
|
|||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<CommandPalette />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,43 @@
|
|||||||
gap: 16px;
|
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 {
|
.envBadge {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { useThemeStore } from '../../theme/theme-store';
|
import { useThemeStore } from '../../theme/theme-store';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
import { useCommandPalette } from '../command-palette/use-command-palette';
|
||||||
import styles from './TopNav.module.css';
|
import styles from './TopNav.module.css';
|
||||||
|
|
||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
const { theme, toggle } = useThemeStore();
|
const { theme, toggle } = useThemeStore();
|
||||||
const { username, logout } = useAuthStore();
|
const { username, logout } = useAuthStore();
|
||||||
|
const openPalette = useCommandPalette((s) => s.open);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.topnav}>
|
<nav className={styles.topnav}>
|
||||||
@@ -26,6 +28,14 @@ export function TopNav() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className={styles.navRight}>
|
<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>
|
<span className={styles.envBadge}>PRODUCTION</span>
|
||||||
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
|
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
|
||||||
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
||||||
|
|||||||
@@ -20,6 +20,18 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
position: relative;
|
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 {
|
.searchIcon {
|
||||||
@@ -32,27 +44,18 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput {
|
.searchPlaceholder {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
background: var(--bg-base);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 10px 14px 10px 40px;
|
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);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-body);
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput:focus {
|
.searchInputWrap:hover .searchPlaceholder {
|
||||||
border-color: var(--amber-dim);
|
color: var(--text-secondary);
|
||||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchHint {
|
.searchHint {
|
||||||
@@ -136,35 +139,6 @@
|
|||||||
min-width: 50px;
|
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 {
|
.filterTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useCallback } from 'react';
|
|
||||||
import { useExecutionSearch } from './use-execution-search';
|
import { useExecutionSearch } from './use-execution-search';
|
||||||
|
import { useCommandPalette } from '../../components/command-palette/use-command-palette';
|
||||||
import { FilterChip } from '../../components/shared/FilterChip';
|
import { FilterChip } from '../../components/shared/FilterChip';
|
||||||
import styles from './SearchFilters.module.css';
|
import styles from './SearchFilters.module.css';
|
||||||
|
|
||||||
@@ -10,21 +10,19 @@ export function SearchFilters() {
|
|||||||
timeTo, setTimeTo,
|
timeTo, setTimeTo,
|
||||||
durationMax, setDurationMax,
|
durationMax, setDurationMax,
|
||||||
text, setText,
|
text, setText,
|
||||||
|
routeId, setRouteId,
|
||||||
|
agentId, setAgentId,
|
||||||
|
processorType, setProcessorType,
|
||||||
clearAll,
|
clearAll,
|
||||||
} = useExecutionSearch();
|
} = useExecutionSearch();
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const openPalette = useCommandPalette((s) => s.open);
|
||||||
|
|
||||||
const handleTextChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => setText(value), 300);
|
|
||||||
},
|
|
||||||
[setText],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeTags: { label: string; onRemove: () => void }[] = [];
|
const activeTags: { label: string; onRemove: () => void }[] = [];
|
||||||
if (text) activeTags.push({ label: `text:"${text}"`, onRemove: () => setText('') });
|
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 (timeFrom) activeTags.push({ label: `from:${timeFrom}`, onRemove: () => setTimeFrom('') });
|
||||||
if (timeTo) activeTags.push({ label: `to:${timeTo}`, onRemove: () => setTimeTo('') });
|
if (timeTo) activeTags.push({ label: `to:${timeTo}`, onRemove: () => setTimeTo('') });
|
||||||
if (durationMax && durationMax < 5000) {
|
if (durationMax && durationMax < 5000) {
|
||||||
@@ -33,23 +31,20 @@ export function SearchFilters() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.filterBar} animate-in delay-3`}>
|
<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.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">
|
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="M21 21l-4.35-4.35" />
|
<path d="M21 21l-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<span className={styles.searchPlaceholder}>
|
||||||
className={styles.searchInput}
|
{text || routeId || agentId || processorType
|
||||||
type="text"
|
? [text, routeId && `route:${routeId}`, agentId && `agent:${agentId}`, processorType && `processor:${processorType}`].filter(Boolean).join(' ')
|
||||||
placeholder="Search by correlation ID, error message, route ID..."
|
: 'Search by correlation ID, error message, route ID...'}
|
||||||
defaultValue={text}
|
</span>
|
||||||
onChange={(e) => handleTextChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className={styles.searchHint}>⌘K</span>
|
<span className={styles.searchHint}>⌘K</span>
|
||||||
</div>
|
</div>
|
||||||
<button className={styles.btnPrimary}>Search</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Status chips + date + duration */}
|
{/* Row 2: Status chips + date + duration */}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ interface ExecutionSearchState {
|
|||||||
durationMin: number | null;
|
durationMin: number | null;
|
||||||
durationMax: number | null;
|
durationMax: number | null;
|
||||||
text: string;
|
text: string;
|
||||||
|
routeId: string;
|
||||||
|
agentId: string;
|
||||||
|
processorType: string;
|
||||||
offset: number;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
||||||
@@ -18,6 +21,9 @@ interface ExecutionSearchState {
|
|||||||
setDurationMin: (v: number | null) => void;
|
setDurationMin: (v: number | null) => void;
|
||||||
setDurationMax: (v: number | null) => void;
|
setDurationMax: (v: number | null) => void;
|
||||||
setText: (v: string) => void;
|
setText: (v: string) => void;
|
||||||
|
setRouteId: (v: string) => void;
|
||||||
|
setAgentId: (v: string) => void;
|
||||||
|
setProcessorType: (v: string) => void;
|
||||||
setOffset: (v: number) => void;
|
setOffset: (v: number) => void;
|
||||||
clearAll: () => void;
|
clearAll: () => void;
|
||||||
toSearchRequest: () => SearchRequest;
|
toSearchRequest: () => SearchRequest;
|
||||||
@@ -30,6 +36,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
|||||||
durationMin: null,
|
durationMin: null,
|
||||||
durationMax: null,
|
durationMax: null,
|
||||||
text: '',
|
text: '',
|
||||||
|
routeId: '',
|
||||||
|
agentId: '',
|
||||||
|
processorType: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 25,
|
limit: 25,
|
||||||
|
|
||||||
@@ -46,6 +55,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
|||||||
setDurationMin: (v) => set({ durationMin: v, offset: 0 }),
|
setDurationMin: (v) => set({ durationMin: v, offset: 0 }),
|
||||||
setDurationMax: (v) => set({ durationMax: v, offset: 0 }),
|
setDurationMax: (v) => set({ durationMax: v, offset: 0 }),
|
||||||
setText: (v) => set({ text: 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 }),
|
setOffset: (v) => set({ offset: v }),
|
||||||
clearAll: () =>
|
clearAll: () =>
|
||||||
set({
|
set({
|
||||||
@@ -55,6 +67,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
|||||||
durationMin: null,
|
durationMin: null,
|
||||||
durationMax: null,
|
durationMax: null,
|
||||||
text: '',
|
text: '',
|
||||||
|
routeId: '',
|
||||||
|
agentId: '',
|
||||||
|
processorType: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -70,6 +85,9 @@ export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
|||||||
durationMin: s.durationMin,
|
durationMin: s.durationMin,
|
||||||
durationMax: s.durationMax,
|
durationMax: s.durationMax,
|
||||||
text: s.text || undefined,
|
text: s.text || undefined,
|
||||||
|
routeId: s.routeId || undefined,
|
||||||
|
agentId: s.agentId || undefined,
|
||||||
|
processorType: s.processorType || undefined,
|
||||||
offset: s.offset,
|
offset: s.offset,
|
||||||
limit: s.limit,
|
limit: s.limit,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user