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) <noreply@anthropic.com>
This commit is contained in:
@@ -172,6 +172,12 @@ public class SearchController {
|
|||||||
return ResponseEntity.ok(searchService.punchcard(from, to, application));
|
return ResponseEntity.ok(searchService.punchcard(from, to, application));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/attributes/keys")
|
||||||
|
@Operation(summary = "Distinct attribute key names across all executions")
|
||||||
|
public ResponseEntity<List<String>> attributeKeys() {
|
||||||
|
return ResponseEntity.ok(searchService.distinctAttributeKeys());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/errors/top")
|
@GetMapping("/errors/top")
|
||||||
@Operation(summary = "Top N errors with velocity trend")
|
@Operation(summary = "Top N errors with velocity trend")
|
||||||
public ResponseEntity<List<TopError>> topErrors(
|
public ResponseEntity<List<TopError>> topErrors(
|
||||||
|
|||||||
@@ -305,6 +305,21 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
|||||||
.replace("_", "\\_");
|
.replace("_", "\\_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> 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<String, String> parseAttributesJson(String json) {
|
private static Map<String, String> parseAttributesJson(String json) {
|
||||||
if (json == null || json.isBlank()) return null;
|
if (json == null || json.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public class SearchService {
|
|||||||
return searchIndex.count(request);
|
return searchIndex.count(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> distinctAttributeKeys() {
|
||||||
|
return searchIndex.distinctAttributeKeys();
|
||||||
|
}
|
||||||
|
|
||||||
public ExecutionStats stats(Instant from, Instant to) {
|
public ExecutionStats stats(Instant from, Instant to) {
|
||||||
return statsStore.stats(from, to);
|
return statsStore.stats(from, to);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.cameleer3.server.core.search.SearchRequest;
|
|||||||
import com.cameleer3.server.core.search.SearchResult;
|
import com.cameleer3.server.core.search.SearchResult;
|
||||||
import com.cameleer3.server.core.storage.model.ExecutionDocument;
|
import com.cameleer3.server.core.storage.model.ExecutionDocument;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface SearchIndex {
|
public interface SearchIndex {
|
||||||
|
|
||||||
SearchResult<ExecutionSummary> search(SearchRequest request);
|
SearchResult<ExecutionSummary> search(SearchRequest request);
|
||||||
@@ -14,4 +16,7 @@ public interface SearchIndex {
|
|||||||
void index(ExecutionDocument document);
|
void index(ExecutionDocument document);
|
||||||
|
|
||||||
void delete(String executionId);
|
void delete(String executionId);
|
||||||
|
|
||||||
|
/** Returns distinct attribute key names across all executions. */
|
||||||
|
List<String> distinctAttributeKeys();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string[]>;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSearchExecutions(filters: SearchRequest, live = false) {
|
export function useSearchExecutions(filters: SearchRequest, live = false) {
|
||||||
const liveQuery = useLiveQuery(5_000);
|
const liveQuery = useLiveQuery(5_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, Glob
|
|||||||
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
|
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
|
||||||
import { useRouteCatalog } from '../api/queries/catalog';
|
import { useRouteCatalog } from '../api/queries/catalog';
|
||||||
import { useAgents } from '../api/queries/agents';
|
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 { useAuthStore } from '../auth/auth-store';
|
||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import { ContentTabs } from './ContentTabs';
|
import { ContentTabs } from './ContentTabs';
|
||||||
@@ -21,6 +21,7 @@ function healthToColor(health: string): string {
|
|||||||
function buildSearchData(
|
function buildSearchData(
|
||||||
catalog: any[] | undefined,
|
catalog: any[] | undefined,
|
||||||
agents: any[] | undefined,
|
agents: any[] | undefined,
|
||||||
|
attrKeys: string[] | undefined,
|
||||||
): SearchResult[] {
|
): SearchResult[] {
|
||||||
if (!catalog) return [];
|
if (!catalog) return [];
|
||||||
const results: SearchResult[] = [];
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +106,7 @@ function LayoutContent() {
|
|||||||
const { timeRange } = useGlobalFilters();
|
const { timeRange } = useGlobalFilters();
|
||||||
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString());
|
const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString());
|
||||||
const { data: agents } = useAgents();
|
const { data: agents } = useAgents();
|
||||||
|
const { data: attributeKeys } = useAttributeKeys();
|
||||||
const { username, logout } = useAuthStore();
|
const { username, logout } = useAuthStore();
|
||||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||||
const { scope, setTab } = useScope();
|
const { scope, setTab } = useScope();
|
||||||
@@ -141,8 +154,8 @@ function LayoutContent() {
|
|||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
const catalogData = useMemo(
|
const catalogData = useMemo(
|
||||||
() => buildSearchData(catalog, agents as any[]),
|
() => buildSearchData(catalog, agents as any[], attributeKeys),
|
||||||
[catalog, agents],
|
[catalog, agents, attributeKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stable reference for catalog data — only changes when catalog/agents actually change,
|
// Stable reference for catalog data — only changes when catalog/agents actually change,
|
||||||
|
|||||||
Reference in New Issue
Block a user