From f3feaddbfe528fa343393888fa77dc78d805da86 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:39:27 +0200 Subject: [PATCH] feat: show distinct attribute keys in cmd-k Attributes tab Add GET /search/attributes/keys endpoint that queries distinct attribute key names from ClickHouse using JSONExtractKeys. Attribute keys appear in the cmd-k Attributes tab alongside attribute value matches from exchange results. - SearchIndex.distinctAttributeKeys() interface method - ClickHouseSearchIndex implementation using arrayJoin(JSONExtractKeys) - SearchController /attributes/keys endpoint - useAttributeKeys() React Query hook - buildSearchData includes attribute keys as 'attribute' category items Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/controller/SearchController.java | 6 ++++++ .../app/search/ClickHouseSearchIndex.java | 15 +++++++++++++++ .../server/core/search/SearchService.java | 4 ++++ .../server/core/storage/SearchIndex.java | 5 +++++ ui/src/api/queries/executions.ts | 16 ++++++++++++++++ ui/src/components/LayoutShell.tsx | 19 ++++++++++++++++--- 6 files changed, 62 insertions(+), 3 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java index 11025128..e84067e5 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java @@ -172,6 +172,12 @@ public class SearchController { return ResponseEntity.ok(searchService.punchcard(from, to, application)); } + @GetMapping("/attributes/keys") + @Operation(summary = "Distinct attribute key names across all executions") + public ResponseEntity> attributeKeys() { + return ResponseEntity.ok(searchService.distinctAttributeKeys()); + } + @GetMapping("/errors/top") @Operation(summary = "Top N errors with velocity trend") public ResponseEntity> topErrors( diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java index a5a1f516..a64dc3f5 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchIndex.java @@ -305,6 +305,21 @@ public class ClickHouseSearchIndex implements SearchIndex { .replace("_", "\\_"); } + @Override + public List distinctAttributeKeys() { + try { + return jdbc.queryForList(""" + SELECT DISTINCT arrayJoin(JSONExtractKeys(attributes)) AS attr_key + FROM executions FINAL + WHERE tenant_id = 'default' AND attributes != '' AND attributes != '{}' + ORDER BY attr_key + """, String.class); + } catch (Exception e) { + log.error("Failed to query distinct attribute keys", e); + return List.of(); + } + } + private static Map parseAttributesJson(String json) { if (json == null || json.isBlank()) return null; try { diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java index 0474882e..719285af 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java @@ -25,6 +25,10 @@ public class SearchService { return searchIndex.count(request); } + public List distinctAttributeKeys() { + return searchIndex.distinctAttributeKeys(); + } + public ExecutionStats stats(Instant from, Instant to) { return statsStore.stats(from, to); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/SearchIndex.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/SearchIndex.java index e06379ac..a57a2eb7 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/SearchIndex.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/SearchIndex.java @@ -5,6 +5,8 @@ import com.cameleer3.server.core.search.SearchRequest; import com.cameleer3.server.core.search.SearchResult; import com.cameleer3.server.core.storage.model.ExecutionDocument; +import java.util.List; + public interface SearchIndex { SearchResult search(SearchRequest request); @@ -14,4 +16,7 @@ public interface SearchIndex { void index(ExecutionDocument document); void delete(String executionId); + + /** Returns distinct attribute key names across all executions. */ + List distinctAttributeKeys(); } diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index 681ea312..efd1c3b6 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -32,6 +32,22 @@ export function useExecutionStats( }); } +export function useAttributeKeys() { + return useQuery({ + queryKey: ['search', 'attribute-keys'], + queryFn: async () => { + const token = (await import('../../auth/auth-store')).useAuthStore.getState().accessToken; + const { config } = await import('../../config'); + const res = await fetch(`${config.apiBaseUrl}/search/attributes/keys`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error('Failed to load attribute keys'); + return res.json() as Promise; + }, + staleTime: 60_000, + }); +} + export function useSearchExecutions(filters: SearchRequest, live = false) { const liveQuery = useLiveQuery(5_000); return useQuery({ diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index d13259f7..8739d0e9 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -3,7 +3,7 @@ import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, Glob import type { SidebarApp, SearchResult } from '@cameleer/design-system'; import { useRouteCatalog } from '../api/queries/catalog'; import { useAgents } from '../api/queries/agents'; -import { useSearchExecutions } from '../api/queries/executions'; +import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions'; import { useAuthStore } from '../auth/auth-store'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { ContentTabs } from './ContentTabs'; @@ -21,6 +21,7 @@ function healthToColor(health: string): string { function buildSearchData( catalog: any[] | undefined, agents: any[] | undefined, + attrKeys: string[] | undefined, ): SearchResult[] { if (!catalog) return []; const results: SearchResult[] = []; @@ -61,6 +62,17 @@ function buildSearchData( } } + if (attrKeys) { + for (const key of attrKeys) { + results.push({ + id: `attr-key-${key}`, + category: 'attribute', + title: key, + meta: 'attribute key', + }); + } + } + return results; } @@ -94,6 +106,7 @@ function LayoutContent() { const { timeRange } = useGlobalFilters(); const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString()); const { data: agents } = useAgents(); + const { data: attributeKeys } = useAttributeKeys(); const { username, logout } = useAuthStore(); const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette(); const { scope, setTab } = useScope(); @@ -141,8 +154,8 @@ function LayoutContent() { }, [catalog]); const catalogData = useMemo( - () => buildSearchData(catalog, agents as any[]), - [catalog, agents], + () => buildSearchData(catalog, agents as any[], attributeKeys), + [catalog, agents, attributeKeys], ); // Stable reference for catalog data — only changes when catalog/agents actually change,