From 62709ce80bf0f1fcbf7b38c97514b802afd4fa6d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:13:58 +0100 Subject: [PATCH] feat: include tap attributes in cmd-K full-text search Add attributes_text flattened field to OpenSearch indexing for both execution and processor levels. Include in full-text search queries, wildcard matching, and highlighting. Merge processor-level attributes into ExecutionSummary. Add 'attribute' category to CommandPalette (design-system 0.1.17) with per-key-value results in the search UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/search/OpenSearchIndex.java | 46 +++++++++++++++---- ui/package-lock.json | 8 ++-- ui/package.json | 2 +- ui/src/components/LayoutShell.tsx | 26 ++++++++++- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java index ea913e58..d059fa6d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchIndex.java @@ -130,8 +130,10 @@ public class OpenSearchIndex implements SearchIndex { } private static final List HIGHLIGHT_FIELDS = List.of( - "error_message", "processors.input_body", "processors.output_body", - "processors.input_headers", "processors.output_headers"); + "error_message", "attributes_text", + "processors.input_body", "processors.output_body", + "processors.input_headers", "processors.output_headers", + "processors.attributes_text"); private org.opensearch.client.opensearch.core.SearchRequest buildSearchRequest( SearchRequest request, int size) { @@ -197,11 +199,13 @@ public class OpenSearchIndex implements SearchIndex { // Search top-level text fields (analyzed match + wildcard for substring) textQueries.add(Query.of(q -> q.multiMatch(m -> m .query(text) - .fields("error_message", "error_stacktrace")))); + .fields("error_message", "error_stacktrace", "attributes_text")))); textQueries.add(Query.of(q -> q.wildcard(w -> w .field("error_message").value(wildcard).caseInsensitive(true)))); textQueries.add(Query.of(q -> q.wildcard(w -> w .field("error_stacktrace").value(wildcard).caseInsensitive(true)))); + textQueries.add(Query.of(q -> q.wildcard(w -> w + .field("attributes_text").value(wildcard).caseInsensitive(true)))); // Search nested processor fields (analyzed match + wildcard) textQueries.add(Query.of(q -> q.nested(n -> n @@ -210,14 +214,16 @@ public class OpenSearchIndex implements SearchIndex { .query(text) .fields("processors.input_body", "processors.output_body", "processors.input_headers", "processors.output_headers", - "processors.error_message", "processors.error_stacktrace")))))); + "processors.error_message", "processors.error_stacktrace", + "processors.attributes_text")))))); textQueries.add(Query.of(q -> q.nested(n -> n .path("processors") .query(nq -> nq.bool(nb -> nb.should( wildcardQuery("processors.input_body", wildcard), wildcardQuery("processors.output_body", wildcard), wildcardQuery("processors.input_headers", wildcard), - wildcardQuery("processors.output_headers", wildcard) + wildcardQuery("processors.output_headers", wildcard), + wildcardQuery("processors.attributes_text", wildcard) ).minimumShouldMatch("1")))))); // Also try keyword fields for exact matches @@ -319,7 +325,9 @@ public class OpenSearchIndex implements SearchIndex { map.put("error_message", doc.errorMessage()); map.put("error_stacktrace", doc.errorStacktrace()); if (doc.attributes() != null) { - map.put("attributes", parseAttributesJson(doc.attributes())); + Map attrs = parseAttributesJson(doc.attributes()); + map.put("attributes", attrs); + map.put("attributes_text", flattenAttributes(attrs)); } if (doc.processors() != null) { map.put("processors", doc.processors().stream().map(p -> { @@ -334,7 +342,9 @@ public class OpenSearchIndex implements SearchIndex { pm.put("input_headers", p.inputHeaders()); pm.put("output_headers", p.outputHeaders()); if (p.attributes() != null) { - pm.put("attributes", parseAttributesJson(p.attributes())); + Map pAttrs = parseAttributesJson(p.attributes()); + pm.put("attributes", pAttrs); + pm.put("attributes_text", flattenAttributes(pAttrs)); } return pm; }).toList()); @@ -348,7 +358,20 @@ public class OpenSearchIndex implements SearchIndex { if (src == null) return null; @SuppressWarnings("unchecked") Map attributes = src.get("attributes") instanceof Map - ? (Map) src.get("attributes") : null; + ? new LinkedHashMap<>((Map) src.get("attributes")) : null; + // Merge processor-level attributes (execution-level takes precedence) + if (src.get("processors") instanceof List procs) { + for (Object pObj : procs) { + if (pObj instanceof Map pm && pm.get("attributes") instanceof Map pa) { + if (attributes == null) attributes = new LinkedHashMap<>(); + for (var entry : pa.entrySet()) { + attributes.putIfAbsent( + String.valueOf(entry.getKey()), + String.valueOf(entry.getValue())); + } + } + } + } return new ExecutionSummary( (String) src.get("execution_id"), (String) src.get("route_id"), @@ -384,4 +407,11 @@ public class OpenSearchIndex implements SearchIndex { return null; } } + + private static String flattenAttributes(Map attrs) { + if (attrs == null || attrs.isEmpty()) return ""; + return attrs.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(" ")); + } } diff --git a/ui/package-lock.json b/ui/package-lock.json index 4d5a3f54..a4818979 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,7 +8,7 @@ "name": "ui", "version": "0.0.0", "dependencies": { - "@cameleer/design-system": "^0.1.16", + "@cameleer/design-system": "^0.1.17", "@tanstack/react-query": "^5.90.21", "openapi-fetch": "^0.17.0", "react": "^19.2.4", @@ -276,9 +276,9 @@ } }, "node_modules/@cameleer/design-system": { - "version": "0.1.16", - "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.16/design-system-0.1.16.tgz", - "integrity": "sha512-8ahFv2cirfV2ZZUoELl4ac2iSCOCpKQf4MznwJwoe9WJDYgmQf81m4Qi5uY+rQDVGpECI2MnLjPQ00HQLi5ybQ==", + "version": "0.1.17", + "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.17/design-system-0.1.17.tgz", + "integrity": "sha512-THK6yN+xSrxEJadEQ4AZiVhPvoI2rq6gvmMonpxVhUw93dOPO5p06pRS5csJc1miFD1thOrazsoDzSTAbNaELw==", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/ui/package.json b/ui/package.json index f8cd800c..2fdab049 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,7 @@ "generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts" }, "dependencies": { - "@cameleer/design-system": "^0.1.16", + "@cameleer/design-system": "^0.1.17", "@tanstack/react-query": "^5.90.21", "openapi-fetch": "^0.17.0", "react": "^19.2.4", diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 475941c9..4f875a40 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -140,8 +140,30 @@ function LayoutContent() { serverFiltered: true, matchContext: e.highlight ?? undefined, })); - return [...catalogData, ...exchangeItems]; - }, [catalogData, exchangeResults]); + + const attributeItems: SearchResult[] = []; + if (debouncedQuery) { + const q = debouncedQuery.toLowerCase(); + for (const e of exchangeResults?.data || []) { + if (!e.attributes) continue; + for (const [key, value] of Object.entries(e.attributes as Record)) { + if (key.toLowerCase().includes(q) || String(value).toLowerCase().includes(q)) { + attributeItems.push({ + id: `${e.executionId}-attr-${key}`, + category: 'attribute' as const, + title: `${key} = "${value}"`, + badges: [{ label: e.status, color: statusToColor(e.status) }], + meta: `${e.executionId} · ${e.routeId} · ${e.applicationName ?? ''}`, + path: `/exchanges/${e.executionId}`, + serverFiltered: true, + }); + } + } + } + } + + return [...catalogData, ...exchangeItems, ...attributeItems]; + }, [catalogData, exchangeResults, debouncedQuery]); const breadcrumb = useMemo(() => { const LABELS: Record = {