From 34ce8a63012f41a5fc9890af19c9eda64838afb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 20:51:23 +0000 Subject: [PATCH 01/10] fix: support service name expression and quick attribute filters in surrounding context - Use serviceNameExpression from the source configuration for the Service filter instead of hardcoded ResourceAttributes['service.name']. This makes the Service filter work with non-OTEL schemas that use custom column names (e.g. ModuleName). - Use resourceAttributesExpression from the source instead of hardcoded 'ResourceAttributes' column name for Host/Pod/Node filters. - Add quick event attribute filters: users can toggle attributes from the current event (resource attributes, event attributes, and top-level columns) to narrow down surrounding context results. - Quick filters are additive (AND'd) with the selected context filter. Co-authored-by: Mike Shi --- .../app/src/components/ContextSidePanel.tsx | 346 ++++++++++++++++-- 1 file changed, 318 insertions(+), 28 deletions(-) diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index e73f5a9532..10b3abee10 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -9,8 +9,18 @@ import { isTraceSource, TSource, } from '@hyperdx/common-utils/dist/types'; -import { Badge, Flex, Group, SegmentedControl } from '@mantine/core'; +import { + ActionIcon, + Badge, + Flex, + Group, + ScrollArea, + SegmentedControl, + Text, + Tooltip, +} from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; +import { IconPlus, IconX } from '@tabler/icons-react'; import SearchWhereInput, { getStoredLanguage, @@ -46,6 +56,155 @@ interface ContextSubpanelProps { onBreadcrumbClick?: BreadcrumbNavigationCallback; } +interface QuickFilterItem { + id: string; + label: string; + value: string; + generateWhere: (isSql: boolean) => string; +} + +function formatColumnEquals( + column: string, + value: string, + isSql: boolean, +): string { + if (isSql) { + return `${column} = '${value.replace(/'/g, "''")}'`; + } + return `${column}:"${value.replace(/"/g, '\\"')}"`; +} + +function extractQuickFilters( + rowData: Record, + source: TSource, +): QuickFilterItem[] { + const filters: QuickFilterItem[] = []; + const skipAliases = new Set(Object.values(ROW_DATA_ALIASES)); + + const serviceNameExpr = + isLogSource(source) || isTraceSource(source) + ? source.serviceNameExpression + : undefined; + const resourceAttrExpr = + 'resourceAttributesExpression' in source + ? source.resourceAttributesExpression + : undefined; + const eventAttrExpr = + isLogSource(source) || isTraceSource(source) + ? source.eventAttributesExpression + : undefined; + + const resourceAttrs = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]; + if (resourceAttrs && typeof resourceAttrs === 'object' && resourceAttrExpr) { + for (const [key, val] of Object.entries(resourceAttrs)) { + if (typeof val !== 'string' || !val || val.length > 200) continue; + if (key === 'service.name' && serviceNameExpr) continue; + filters.push({ + id: `ra:${key}`, + label: key, + value: String(val), + generateWhere: isSql => + formatAttributeClause(resourceAttrExpr, key, String(val), isSql), + }); + } + } + + const eventAttrs = rowData[ROW_DATA_ALIASES.EVENT_ATTRIBUTES]; + if (eventAttrs && typeof eventAttrs === 'object' && eventAttrExpr) { + for (const [key, val] of Object.entries(eventAttrs)) { + if (typeof val !== 'string' || !val || val.length > 200) continue; + filters.push({ + id: `ea:${key}`, + label: key, + value: String(val), + generateWhere: isSql => + formatAttributeClause(eventAttrExpr, key, String(val), isSql), + }); + } + } + + for (const [key, val] of Object.entries(rowData)) { + if (skipAliases.has(key) || key.startsWith('__hdx_')) continue; + if (typeof val !== 'string' || !val || val.length > 200) continue; + if (/timestamp|ttl/i.test(key)) continue; + if (serviceNameExpr && key === serviceNameExpr) continue; + + filters.push({ + id: `col:${key}`, + label: key, + value: String(val), + generateWhere: isSql => formatColumnEquals(key, String(val), isSql), + }); + } + + return filters; +} + +const quickFilterPillStyle = { + display: 'inline-flex', + alignItems: 'center' as const, + gap: 4, + padding: '1px 6px', + borderRadius: 3, + fontSize: 11, + lineHeight: '18px', + cursor: 'pointer', + whiteSpace: 'nowrap' as const, + maxWidth: 260, + overflow: 'hidden', +}; + +function QuickFilterPill({ + filter, + isSelected, + onToggle, +}: { + filter: QuickFilterItem; + isSelected: boolean; + onToggle: () => void; +}) { + return ( + + + + {filter.label} + + + {' = '} + + + {filter.value} + + {isSelected ? ( + + ) : ( + + )} + + + ); +} + // Custom hook to manage nested panel state export function useNestedPanelState(isNested?: boolean) { // Query state (URL-based) for root level @@ -142,11 +301,19 @@ export default function ContextSubpanel({ [date, range], ); - /* Functions to help generate WHERE clause based on - which Context the user chooses (All, Host, Node, etc...). - Since we support lucene and sql, we need to format the condition - based on the language - */ + // Extract source-specific expressions + const serviceNameExpr = + isLogSource(source) || isTraceSource(source) + ? source.serviceNameExpression + : undefined; + const serviceName = rowData[ROW_DATA_ALIASES.SERVICE_NAME] as + | string + | undefined; + const resourceAttrExpr = + 'resourceAttributesExpression' in source + ? source.resourceAttributesExpression + : undefined; + const { 'k8s.node.name': k8sNodeName, 'k8s.pod.name': k8sPodName, @@ -154,6 +321,25 @@ export default function ContextSubpanel({ 'service.name': service, } = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES] ?? {}; + // Resolve effective service name: prefer serviceNameExpression, fall back to + // resource attribute + const effectiveServiceName = serviceName || service; + + // Quick filter state + const [selectedFilterIds, setSelectedFilterIds] = useState([]); + const [showQuickFilters, setShowQuickFilters] = useState(false); + + const availableQuickFilters = useMemo( + () => extractQuickFilters(rowData, source), + [rowData, source], + ); + + const toggleQuickFilter = useCallback((id: string) => { + setSelectedFilterIds(prev => + prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id], + ); + }, []); + const CONTEXT_MAPPING = useMemo( () => ({ @@ -167,7 +353,7 @@ export default function ContextSubpanel({ }, [ContextBy.Service]: { field: 'service.name', - value: service, + value: effectiveServiceName, }, [ContextBy.Host]: { field: 'host.name', @@ -182,38 +368,75 @@ export default function ContextSubpanel({ value: k8sNodeName, }, }) as const, - [k8sNodeName, k8sPodName, host, service, debouncedWhere], + [k8sNodeName, k8sPodName, host, effectiveServiceName, debouncedWhere], ); - // Main function to generate WHERE clause based on context const getWhereClause = useCallback( (contextBy: ContextBy): string => { const isSql = originalLanguage === 'sql'; - const mapping = CONTEXT_MAPPING[contextBy]; + const clauses: string[] = []; - if (contextBy === ContextBy.All) { - return mapping.value; + if (contextBy === ContextBy.Custom) { + const customWhere = CONTEXT_MAPPING[contextBy].value.trim(); + if (customWhere) { + clauses.push(customWhere); + } + } else if (contextBy === ContextBy.Service) { + if (serviceNameExpr && serviceName) { + clauses.push(formatColumnEquals(serviceNameExpr, serviceName, isSql)); + } else if (service) { + clauses.push( + formatAttributeClause( + resourceAttrExpr || 'ResourceAttributes', + 'service.name', + service, + isSql, + ), + ); + } + } else if (contextBy !== ContextBy.All) { + const mapping = CONTEXT_MAPPING[contextBy]; + if (mapping.value) { + clauses.push( + formatAttributeClause( + resourceAttrExpr || 'ResourceAttributes', + mapping.field, + mapping.value, + isSql, + ), + ); + } } - if (contextBy === ContextBy.Custom) { - return mapping.value.trim(); + for (const filterId of selectedFilterIds) { + const filter = availableQuickFilters.find(f => f.id === filterId); + if (filter) { + clauses.push(filter.generateWhere(isSql)); + } } - const attributeClause = formatAttributeClause( - 'ResourceAttributes', - mapping.field, - mapping.value, - isSql, - ); - return attributeClause; + if (clauses.length === 0) return ''; + if (clauses.length === 1) return clauses[0]; + return clauses.map(c => `(${c})`).join(' AND '); }, - [CONTEXT_MAPPING, originalLanguage], + [ + CONTEXT_MAPPING, + originalLanguage, + serviceNameExpr, + serviceName, + service, + resourceAttrExpr, + selectedFilterIds, + availableQuickFilters, + ], ); function generateSegmentedControlData() { return [ { label: 'All', value: ContextBy.All }, - ...(service ? [{ label: 'Service', value: ContextBy.Service }] : []), + ...(effectiveServiceName + ? [{ label: 'Service', value: ContextBy.Service }] + : []), ...(host ? [{ label: 'Host', value: ContextBy.Host }] : []), ...(k8sPodName ? [{ label: 'Pod', value: ContextBy.Pod }] : []), ...(k8sNodeName ? [{ label: 'Node', value: ContextBy.Node }] : []), @@ -223,7 +446,6 @@ export default function ContextSubpanel({ const config = useMemo(() => { const whereClause = getWhereClause(contextBy); - // missing query info, build config from source with default value if (!dbSqlRowTableConfig) return { connection: source.connection, @@ -256,6 +478,13 @@ export default function ContextSubpanel({ source, ]); + const activeQuickFilterLabels = selectedFilterIds + .map(id => { + const filter = availableQuickFilters.find(f => f.id === id); + return filter ? `${filter.label}=${filter.value}` : null; + }) + .filter(Boolean); + return ( <> {config && ( @@ -292,18 +521,79 @@ export default function ContextSubpanel({ onChange={value => setRange(Number(value))} /> - +
- {contextBy !== ContextBy.All && ( - - {contextBy}:{CONTEXT_MAPPING[contextBy].value} + {contextBy !== ContextBy.All && + contextBy !== ContextBy.Custom && ( + + {contextBy}:{CONTEXT_MAPPING[contextBy].value} + + )} + {contextBy === ContextBy.Custom && debouncedWhere && ( + + custom query )} + {activeQuickFilterLabels.map(label => ( + + {label} + + ))} Time range: ±{ms(range / 2)}
+ {availableQuickFilters.length > 0 && ( + + setShowQuickFilters(v => !v)} + title={ + showQuickFilters ? 'Hide event filters' : 'Show event filters' + } + > + + + + Event Filters + + {selectedFilterIds.length > 0 && ( + setSelectedFilterIds([])} + > + Clear all + + )} + + )} + {showQuickFilters && availableQuickFilters.length > 0 && ( + + + {availableQuickFilters.map(filter => ( + toggleQuickFilter(filter.id)} + /> + ))} + + + )}
Date: Tue, 30 Jun 2026 21:42:59 +0000 Subject: [PATCH 02/10] chore: add changeset for surrounding context filters fix Co-authored-by: Mike Shi --- .changeset/fix-surrounding-context-filters.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-surrounding-context-filters.md diff --git a/.changeset/fix-surrounding-context-filters.md b/.changeset/fix-surrounding-context-filters.md new file mode 100644 index 0000000000..f7e9019156 --- /dev/null +++ b/.changeset/fix-surrounding-context-filters.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Fix Surrounding Context filters for non-OTEL schemas by using the source's serviceNameExpression for the "Service" filter instead of hardcoded ResourceAttributes lookup. Also adds quick event attribute filters that let users toggle attributes from the current event to narrow surrounding context results. From 1250d472dfeecd39e07fd1f80f25063a248e4436 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 00:02:13 +0000 Subject: [PATCH 03/10] refactor: unify surrounding context filters into single pill-based UI Address UX feedback: - Remove confusing two-layer system (ContextBy segmented control + separate event filters). All filtering is now done via a single set of always-visible filter pills. - Filter pills are always visible (no toggle/expand needed). - Service, Host, Pod, Node are promoted as the first pills so they're easy to find. - Selected pills show a clear X icon for easy removal. - Property names no longer truncated (wider labels, larger pill maxWidth). - Custom search is available via a search icon toggle. - Multiple pills can be selected simultaneously (AND'd together). Co-authored-by: Mike Shi --- .../app/src/components/ContextSidePanel.tsx | 433 +++++++----------- 1 file changed, 173 insertions(+), 260 deletions(-) diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 10b3abee10..e51bda6ed7 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -20,7 +20,7 @@ import { Tooltip, } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { IconPlus, IconX } from '@tabler/icons-react'; +import { IconSearch, IconX } from '@tabler/icons-react'; import SearchWhereInput, { getStoredLanguage, @@ -38,15 +38,6 @@ import { } from './DBRowSidePanelHeader'; import { DBSqlRowTable } from './DBRowTable'; -enum ContextBy { - All = 'all', - Custom = 'custom', - Host = 'host', - Node = 'k8s.node.name', - Pod = 'k8s.pod.name', - Service = 'service', -} - interface ContextSubpanelProps { source: TSource; dbSqlRowTableConfig: BuilderChartConfigWithDateRange | undefined; @@ -74,6 +65,12 @@ function formatColumnEquals( return `${column}:"${value.replace(/"/g, '\\"')}"`; } +const PROMOTED_RESOURCE_ATTR_KEYS = [ + 'host.name', + 'k8s.pod.name', + 'k8s.node.name', +]; + function extractQuickFilters( rowData: Record, source: TSource, @@ -94,35 +91,88 @@ function extractQuickFilters( ? source.eventAttributesExpression : undefined; - const resourceAttrs = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]; - if (resourceAttrs && typeof resourceAttrs === 'object' && resourceAttrExpr) { + const resourceAttrs = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES] as + | Record + | undefined; + + // Service name pill (promoted, always first) + const serviceNameValue = rowData[ROW_DATA_ALIASES.SERVICE_NAME]; + if (serviceNameExpr && serviceNameValue) { + filters.push({ + id: 'svc', + label: serviceNameExpr, + value: String(serviceNameValue), + generateWhere: isSql => + formatColumnEquals(serviceNameExpr, String(serviceNameValue), isSql), + }); + } else if ( + resourceAttrs?.['service.name'] && + typeof resourceAttrs['service.name'] === 'string' && + resourceAttrExpr + ) { + filters.push({ + id: 'ra:service.name', + label: 'service.name', + value: String(resourceAttrs['service.name']), + generateWhere: isSql => + formatAttributeClause( + resourceAttrExpr, + 'service.name', + String(resourceAttrs['service.name']), + isSql, + ), + }); + } + + // Promoted resource attribute pills (host, k8s) + if (resourceAttrs && resourceAttrExpr) { + for (const key of PROMOTED_RESOURCE_ATTR_KEYS) { + const val = resourceAttrs[key]; + if (typeof val !== 'string' || !val) continue; + filters.push({ + id: `ra:${key}`, + label: key, + value: val, + generateWhere: isSql => + formatAttributeClause(resourceAttrExpr, key, val, isSql), + }); + } + } + + // Remaining resource attributes + const addedIds = new Set(filters.map(f => f.id)); + if (resourceAttrs && resourceAttrExpr) { for (const [key, val] of Object.entries(resourceAttrs)) { + if (addedIds.has(`ra:${key}`)) continue; if (typeof val !== 'string' || !val || val.length > 200) continue; - if (key === 'service.name' && serviceNameExpr) continue; filters.push({ id: `ra:${key}`, label: key, - value: String(val), + value: val, generateWhere: isSql => - formatAttributeClause(resourceAttrExpr, key, String(val), isSql), + formatAttributeClause(resourceAttrExpr, key, val, isSql), }); } } + // Event attributes const eventAttrs = rowData[ROW_DATA_ALIASES.EVENT_ATTRIBUTES]; if (eventAttrs && typeof eventAttrs === 'object' && eventAttrExpr) { - for (const [key, val] of Object.entries(eventAttrs)) { + for (const [key, val] of Object.entries( + eventAttrs as Record, + )) { if (typeof val !== 'string' || !val || val.length > 200) continue; filters.push({ id: `ea:${key}`, label: key, - value: String(val), + value: val, generateWhere: isSql => - formatAttributeClause(eventAttrExpr, key, String(val), isSql), + formatAttributeClause(eventAttrExpr, key, val, isSql), }); } } + // Top-level columns for (const [key, val] of Object.entries(rowData)) { if (skipAliases.has(key) || key.startsWith('__hdx_')) continue; if (typeof val !== 'string' || !val || val.length > 200) continue; @@ -140,21 +190,21 @@ function extractQuickFilters( return filters; } -const quickFilterPillStyle = { +const filterPillStyle = { display: 'inline-flex', alignItems: 'center' as const, gap: 4, - padding: '1px 6px', - borderRadius: 3, - fontSize: 11, - lineHeight: '18px', + padding: '2px 8px', + borderRadius: 4, + fontSize: 12, + lineHeight: '20px', cursor: 'pointer', whiteSpace: 'nowrap' as const, - maxWidth: 260, + maxWidth: 400, overflow: 'hidden', }; -function QuickFilterPill({ +function FilterPill({ filter, isSelected, onToggle, @@ -165,40 +215,44 @@ function QuickFilterPill({ }) { return ( + + {filter.label} + + + = + - {filter.label} - - - {' = '} - - {filter.value} - {isSelected ? ( - - ) : ( - + {isSelected && ( + )} @@ -245,7 +299,7 @@ export default function ContextSubpanel({ const { whereLanguage: originalLanguage = 'lucene' } = dbSqlRowTableConfig ?? {}; const [range, setRange] = useState(ms('30s')); - const [contextBy, setContextBy] = useState(ContextBy.All); + const [showCustomSearch, setShowCustomSearch] = useState(false); const { control } = useForm({ defaultValues: { where: '', @@ -301,151 +355,48 @@ export default function ContextSubpanel({ [date, range], ); - // Extract source-specific expressions - const serviceNameExpr = - isLogSource(source) || isTraceSource(source) - ? source.serviceNameExpression - : undefined; - const serviceName = rowData[ROW_DATA_ALIASES.SERVICE_NAME] as - | string - | undefined; - const resourceAttrExpr = - 'resourceAttributesExpression' in source - ? source.resourceAttributesExpression - : undefined; - - const { - 'k8s.node.name': k8sNodeName, - 'k8s.pod.name': k8sPodName, - 'host.name': host, - 'service.name': service, - } = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES] ?? {}; - - // Resolve effective service name: prefer serviceNameExpression, fall back to - // resource attribute - const effectiveServiceName = serviceName || service; - - // Quick filter state + // Filter state const [selectedFilterIds, setSelectedFilterIds] = useState([]); - const [showQuickFilters, setShowQuickFilters] = useState(false); const availableQuickFilters = useMemo( () => extractQuickFilters(rowData, source), [rowData, source], ); - const toggleQuickFilter = useCallback((id: string) => { + const toggleFilter = useCallback((id: string) => { setSelectedFilterIds(prev => prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id], ); }, []); - const CONTEXT_MAPPING = useMemo( - () => - ({ - [ContextBy.All]: { - field: '', - value: '', - }, - [ContextBy.Custom]: { - field: '', - value: debouncedWhere || '', - }, - [ContextBy.Service]: { - field: 'service.name', - value: effectiveServiceName, - }, - [ContextBy.Host]: { - field: 'host.name', - value: host, - }, - [ContextBy.Pod]: { - field: 'k8s.pod.name', - value: k8sPodName, - }, - [ContextBy.Node]: { - field: 'k8s.node.name', - value: k8sNodeName, - }, - }) as const, - [k8sNodeName, k8sPodName, host, effectiveServiceName, debouncedWhere], - ); + const getWhereClause = useCallback((): string => { + const isSql = originalLanguage === 'sql'; + const clauses: string[] = []; - const getWhereClause = useCallback( - (contextBy: ContextBy): string => { - const isSql = originalLanguage === 'sql'; - const clauses: string[] = []; - - if (contextBy === ContextBy.Custom) { - const customWhere = CONTEXT_MAPPING[contextBy].value.trim(); - if (customWhere) { - clauses.push(customWhere); - } - } else if (contextBy === ContextBy.Service) { - if (serviceNameExpr && serviceName) { - clauses.push(formatColumnEquals(serviceNameExpr, serviceName, isSql)); - } else if (service) { - clauses.push( - formatAttributeClause( - resourceAttrExpr || 'ResourceAttributes', - 'service.name', - service, - isSql, - ), - ); - } - } else if (contextBy !== ContextBy.All) { - const mapping = CONTEXT_MAPPING[contextBy]; - if (mapping.value) { - clauses.push( - formatAttributeClause( - resourceAttrExpr || 'ResourceAttributes', - mapping.field, - mapping.value, - isSql, - ), - ); - } - } - - for (const filterId of selectedFilterIds) { - const filter = availableQuickFilters.find(f => f.id === filterId); - if (filter) { - clauses.push(filter.generateWhere(isSql)); - } + for (const filterId of selectedFilterIds) { + const filter = availableQuickFilters.find(f => f.id === filterId); + if (filter) { + clauses.push(filter.generateWhere(isSql)); } + } - if (clauses.length === 0) return ''; - if (clauses.length === 1) return clauses[0]; - return clauses.map(c => `(${c})`).join(' AND '); - }, - [ - CONTEXT_MAPPING, - originalLanguage, - serviceNameExpr, - serviceName, - service, - resourceAttrExpr, - selectedFilterIds, - availableQuickFilters, - ], - ); + if (showCustomSearch && debouncedWhere?.trim()) { + clauses.push(debouncedWhere.trim()); + } - function generateSegmentedControlData() { - return [ - { label: 'All', value: ContextBy.All }, - ...(effectiveServiceName - ? [{ label: 'Service', value: ContextBy.Service }] - : []), - ...(host ? [{ label: 'Host', value: ContextBy.Host }] : []), - ...(k8sPodName ? [{ label: 'Pod', value: ContextBy.Pod }] : []), - ...(k8sNodeName ? [{ label: 'Node', value: ContextBy.Node }] : []), - { label: 'Custom', value: ContextBy.Custom }, - ]; - } + if (clauses.length === 0) return ''; + if (clauses.length === 1) return clauses[0]; + return clauses.map(c => `(${c})`).join(' AND '); + }, [ + originalLanguage, + selectedFilterIds, + availableQuickFilters, + showCustomSearch, + debouncedWhere, + ]); const config = useMemo(() => { - const whereClause = getWhereClause(contextBy); + const whereClause = getWhereClause(); if (!dbSqlRowTableConfig) return { connection: source.connection, @@ -474,99 +425,44 @@ export default function ContextSubpanel({ getWhereClause, originalLanguage, newDateRange, - contextBy, source, ]); - const activeQuickFilterLabels = selectedFilterIds - .map(id => { - const filter = availableQuickFilters.find(f => f.id === id); - return filter ? `${filter.label}=${filter.value}` : null; - }) - .filter(Boolean); - return ( <> {config && ( - - setContextBy(v as ContextBy)} - /> - {contextBy === ContextBy.Custom && ( - - )} - setRange(Number(value))} - /> - - -
- {contextBy !== ContextBy.All && - contextBy !== ContextBy.Custom && ( - - {contextBy}:{CONTEXT_MAPPING[contextBy].value} - - )} - {contextBy === ContextBy.Custom && debouncedWhere && ( - - custom query - - )} - {activeQuickFilterLabels.map(label => ( - - {label} - - ))} + + - Time range: ±{ms(range / 2)} + ±{ms(range / 2)} -
-
- {availableQuickFilters.length > 0 && ( - - setShowQuickFilters(v => !v)} - title={ - showQuickFilters ? 'Hide event filters' : 'Show event filters' - } - > - - - - Event Filters - + data={[ + { label: '100ms', value: ms('100ms').toString() }, + { label: '500ms', value: ms('500ms').toString() }, + { label: '1s', value: ms('1s').toString() }, + { label: '5s', value: ms('5s').toString() }, + { label: '30s', value: ms('30s').toString() }, + { label: '1m', value: ms('1m').toString() }, + { label: '5m', value: ms('5m').toString() }, + { label: '15m', value: ms('15m').toString() }, + ]} + value={range.toString()} + onChange={value => setRange(Number(value))} + /> + + + + setShowCustomSearch(v => !v)} + > + + + {selectedFilterIds.length > 0 && ( setSelectedFilterIds([])} > - Clear all + Clear filters )} + + {showCustomSearch && ( + + + )} - {showQuickFilters && availableQuickFilters.length > 0 && ( - + {availableQuickFilters.length > 0 && ( + {availableQuickFilters.map(filter => ( - toggleQuickFilter(filter.id)} + onToggle={() => toggleFilter(filter.id)} /> ))} From 95466e9f9184a0162ab022466708946d7b819cff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 00:05:46 +0000 Subject: [PATCH 04/10] refactor: extract filter pills into ContextFilterPills.tsx Split filter pill logic (extractQuickFilters, FilterPill component, and helper functions) into a separate file to keep ContextSidePanel.tsx under the 300-line limit. Co-authored-by: Mike Shi --- .../app/src/components/ContextFilterPills.tsx | 223 +++++++++++++++++ .../app/src/components/ContextSidePanel.tsx | 226 +----------------- 2 files changed, 230 insertions(+), 219 deletions(-) create mode 100644 packages/app/src/components/ContextFilterPills.tsx diff --git a/packages/app/src/components/ContextFilterPills.tsx b/packages/app/src/components/ContextFilterPills.tsx new file mode 100644 index 0000000000..8501dc4a65 --- /dev/null +++ b/packages/app/src/components/ContextFilterPills.tsx @@ -0,0 +1,223 @@ +import { + isLogSource, + isTraceSource, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { Text, Tooltip } from '@mantine/core'; +import { IconX } from '@tabler/icons-react'; + +import { formatAttributeClause } from '@/utils'; + +import { ROW_DATA_ALIASES } from './DBRowDataPanel'; + +export interface QuickFilterItem { + id: string; + label: string; + value: string; + generateWhere: (isSql: boolean) => string; +} + +export function formatColumnEquals( + column: string, + value: string, + isSql: boolean, +): string { + if (isSql) { + return `${column} = '${value.replace(/'/g, "''")}'`; + } + return `${column}:"${value.replace(/"/g, '\\"')}"`; +} + +const PROMOTED_RESOURCE_ATTR_KEYS = [ + 'host.name', + 'k8s.pod.name', + 'k8s.node.name', +]; + +export function extractQuickFilters( + rowData: Record, + source: TSource, +): QuickFilterItem[] { + const filters: QuickFilterItem[] = []; + const skipAliases = new Set(Object.values(ROW_DATA_ALIASES)); + + const serviceNameExpr = + isLogSource(source) || isTraceSource(source) + ? source.serviceNameExpression + : undefined; + const resourceAttrExpr = + 'resourceAttributesExpression' in source + ? source.resourceAttributesExpression + : undefined; + const eventAttrExpr = + isLogSource(source) || isTraceSource(source) + ? source.eventAttributesExpression + : undefined; + + const resourceAttrs = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES] as + | Record + | undefined; + + // Service name pill (promoted, always first) + const serviceNameValue = rowData[ROW_DATA_ALIASES.SERVICE_NAME]; + if (serviceNameExpr && serviceNameValue) { + filters.push({ + id: 'svc', + label: serviceNameExpr, + value: String(serviceNameValue), + generateWhere: isSql => + formatColumnEquals(serviceNameExpr, String(serviceNameValue), isSql), + }); + } else if ( + resourceAttrs?.['service.name'] && + typeof resourceAttrs['service.name'] === 'string' && + resourceAttrExpr + ) { + filters.push({ + id: 'ra:service.name', + label: 'service.name', + value: String(resourceAttrs['service.name']), + generateWhere: isSql => + formatAttributeClause( + resourceAttrExpr, + 'service.name', + String(resourceAttrs['service.name']), + isSql, + ), + }); + } + + // Promoted resource attribute pills (host, k8s) + if (resourceAttrs && resourceAttrExpr) { + for (const key of PROMOTED_RESOURCE_ATTR_KEYS) { + const val = resourceAttrs[key]; + if (typeof val !== 'string' || !val) continue; + filters.push({ + id: `ra:${key}`, + label: key, + value: val, + generateWhere: isSql => + formatAttributeClause(resourceAttrExpr, key, val, isSql), + }); + } + } + + // Remaining resource attributes + const addedIds = new Set(filters.map(f => f.id)); + if (resourceAttrs && resourceAttrExpr) { + for (const [key, val] of Object.entries(resourceAttrs)) { + if (addedIds.has(`ra:${key}`)) continue; + if (typeof val !== 'string' || !val || val.length > 200) continue; + filters.push({ + id: `ra:${key}`, + label: key, + value: val, + generateWhere: isSql => + formatAttributeClause(resourceAttrExpr, key, val, isSql), + }); + } + } + + // Event attributes + const eventAttrs = rowData[ROW_DATA_ALIASES.EVENT_ATTRIBUTES]; + if (eventAttrs && typeof eventAttrs === 'object' && eventAttrExpr) { + for (const [key, val] of Object.entries( + eventAttrs as Record, + )) { + if (typeof val !== 'string' || !val || val.length > 200) continue; + filters.push({ + id: `ea:${key}`, + label: key, + value: val, + generateWhere: isSql => + formatAttributeClause(eventAttrExpr, key, val, isSql), + }); + } + } + + // Top-level columns + for (const [key, val] of Object.entries(rowData)) { + if (skipAliases.has(key) || key.startsWith('__hdx_')) continue; + if (typeof val !== 'string' || !val || val.length > 200) continue; + if (/timestamp|ttl/i.test(key)) continue; + if (serviceNameExpr && key === serviceNameExpr) continue; + + filters.push({ + id: `col:${key}`, + label: key, + value: String(val), + generateWhere: isSql => formatColumnEquals(key, String(val), isSql), + }); + } + + return filters; +} + +const filterPillStyle = { + display: 'inline-flex', + alignItems: 'center' as const, + gap: 4, + padding: '2px 8px', + borderRadius: 4, + fontSize: 12, + lineHeight: '20px', + cursor: 'pointer', + whiteSpace: 'nowrap' as const, + maxWidth: 400, + overflow: 'hidden', +}; + +export function FilterPill({ + filter, + isSelected, + onToggle, +}: { + filter: QuickFilterItem; + isSelected: boolean; + onToggle: () => void; +}) { + return ( + + + + {filter.label} + + + = + + + {filter.value} + + {isSelected && ( + + )} + + + ); +} diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index e51bda6ed7..0adb048a4a 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -20,16 +20,16 @@ import { Tooltip, } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { IconSearch, IconX } from '@tabler/icons-react'; +import { IconSearch } from '@tabler/icons-react'; import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; import { useSource } from '@/source'; -import { formatAttributeClause } from '@/utils'; import { parseAsStringEncoded } from '@/utils/queryParsers'; +import { extractQuickFilters, FilterPill } from './ContextFilterPills'; import { ROW_DATA_ALIASES } from './DBRowDataPanel'; import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel'; import { @@ -47,218 +47,6 @@ interface ContextSubpanelProps { onBreadcrumbClick?: BreadcrumbNavigationCallback; } -interface QuickFilterItem { - id: string; - label: string; - value: string; - generateWhere: (isSql: boolean) => string; -} - -function formatColumnEquals( - column: string, - value: string, - isSql: boolean, -): string { - if (isSql) { - return `${column} = '${value.replace(/'/g, "''")}'`; - } - return `${column}:"${value.replace(/"/g, '\\"')}"`; -} - -const PROMOTED_RESOURCE_ATTR_KEYS = [ - 'host.name', - 'k8s.pod.name', - 'k8s.node.name', -]; - -function extractQuickFilters( - rowData: Record, - source: TSource, -): QuickFilterItem[] { - const filters: QuickFilterItem[] = []; - const skipAliases = new Set(Object.values(ROW_DATA_ALIASES)); - - const serviceNameExpr = - isLogSource(source) || isTraceSource(source) - ? source.serviceNameExpression - : undefined; - const resourceAttrExpr = - 'resourceAttributesExpression' in source - ? source.resourceAttributesExpression - : undefined; - const eventAttrExpr = - isLogSource(source) || isTraceSource(source) - ? source.eventAttributesExpression - : undefined; - - const resourceAttrs = rowData[ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES] as - | Record - | undefined; - - // Service name pill (promoted, always first) - const serviceNameValue = rowData[ROW_DATA_ALIASES.SERVICE_NAME]; - if (serviceNameExpr && serviceNameValue) { - filters.push({ - id: 'svc', - label: serviceNameExpr, - value: String(serviceNameValue), - generateWhere: isSql => - formatColumnEquals(serviceNameExpr, String(serviceNameValue), isSql), - }); - } else if ( - resourceAttrs?.['service.name'] && - typeof resourceAttrs['service.name'] === 'string' && - resourceAttrExpr - ) { - filters.push({ - id: 'ra:service.name', - label: 'service.name', - value: String(resourceAttrs['service.name']), - generateWhere: isSql => - formatAttributeClause( - resourceAttrExpr, - 'service.name', - String(resourceAttrs['service.name']), - isSql, - ), - }); - } - - // Promoted resource attribute pills (host, k8s) - if (resourceAttrs && resourceAttrExpr) { - for (const key of PROMOTED_RESOURCE_ATTR_KEYS) { - const val = resourceAttrs[key]; - if (typeof val !== 'string' || !val) continue; - filters.push({ - id: `ra:${key}`, - label: key, - value: val, - generateWhere: isSql => - formatAttributeClause(resourceAttrExpr, key, val, isSql), - }); - } - } - - // Remaining resource attributes - const addedIds = new Set(filters.map(f => f.id)); - if (resourceAttrs && resourceAttrExpr) { - for (const [key, val] of Object.entries(resourceAttrs)) { - if (addedIds.has(`ra:${key}`)) continue; - if (typeof val !== 'string' || !val || val.length > 200) continue; - filters.push({ - id: `ra:${key}`, - label: key, - value: val, - generateWhere: isSql => - formatAttributeClause(resourceAttrExpr, key, val, isSql), - }); - } - } - - // Event attributes - const eventAttrs = rowData[ROW_DATA_ALIASES.EVENT_ATTRIBUTES]; - if (eventAttrs && typeof eventAttrs === 'object' && eventAttrExpr) { - for (const [key, val] of Object.entries( - eventAttrs as Record, - )) { - if (typeof val !== 'string' || !val || val.length > 200) continue; - filters.push({ - id: `ea:${key}`, - label: key, - value: val, - generateWhere: isSql => - formatAttributeClause(eventAttrExpr, key, val, isSql), - }); - } - } - - // Top-level columns - for (const [key, val] of Object.entries(rowData)) { - if (skipAliases.has(key) || key.startsWith('__hdx_')) continue; - if (typeof val !== 'string' || !val || val.length > 200) continue; - if (/timestamp|ttl/i.test(key)) continue; - if (serviceNameExpr && key === serviceNameExpr) continue; - - filters.push({ - id: `col:${key}`, - label: key, - value: String(val), - generateWhere: isSql => formatColumnEquals(key, String(val), isSql), - }); - } - - return filters; -} - -const filterPillStyle = { - display: 'inline-flex', - alignItems: 'center' as const, - gap: 4, - padding: '2px 8px', - borderRadius: 4, - fontSize: 12, - lineHeight: '20px', - cursor: 'pointer', - whiteSpace: 'nowrap' as const, - maxWidth: 400, - overflow: 'hidden', -}; - -function FilterPill({ - filter, - isSelected, - onToggle, -}: { - filter: QuickFilterItem; - isSelected: boolean; - onToggle: () => void; -}) { - return ( - - - - {filter.label} - - - = - - - {filter.value} - - {isSelected && ( - - )} - - - ); -} - // Custom hook to manage nested panel state export function useNestedPanelState(isNested?: boolean) { // Query state (URL-based) for root level @@ -358,7 +146,7 @@ export default function ContextSubpanel({ // Filter state const [selectedFilterIds, setSelectedFilterIds] = useState([]); - const availableQuickFilters = useMemo( + const availableFilters = useMemo( () => extractQuickFilters(rowData, source), [rowData, source], ); @@ -374,7 +162,7 @@ export default function ContextSubpanel({ const clauses: string[] = []; for (const filterId of selectedFilterIds) { - const filter = availableQuickFilters.find(f => f.id === filterId); + const filter = availableFilters.find(f => f.id === filterId); if (filter) { clauses.push(filter.generateWhere(isSql)); } @@ -390,7 +178,7 @@ export default function ContextSubpanel({ }, [ originalLanguage, selectedFilterIds, - availableQuickFilters, + availableFilters, showCustomSearch, debouncedWhere, ]); @@ -487,7 +275,7 @@ export default function ContextSubpanel({ /> )} - {availableQuickFilters.length > 0 && ( + {availableFilters.length > 0 && ( - {availableQuickFilters.map(filter => ( + {availableFilters.map(filter => ( Date: Wed, 1 Jul 2026 00:08:51 +0000 Subject: [PATCH 05/10] fix: reset filter state on row change and remove unused export - Add useEffect to reset selectedFilterIds when rowId changes, preventing stale filters from carrying over between events. - Remove export from formatColumnEquals (only used internally) to fix Knip unused-export CI check. Co-authored-by: Mike Shi --- packages/app/src/components/ContextFilterPills.tsx | 2 +- packages/app/src/components/ContextSidePanel.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/ContextFilterPills.tsx b/packages/app/src/components/ContextFilterPills.tsx index 8501dc4a65..f301d61997 100644 --- a/packages/app/src/components/ContextFilterPills.tsx +++ b/packages/app/src/components/ContextFilterPills.tsx @@ -17,7 +17,7 @@ export interface QuickFilterItem { generateWhere: (isSql: boolean) => string; } -export function formatColumnEquals( +function formatColumnEquals( column: string, value: string, isSql: boolean, diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 0adb048a4a..5bd55fce04 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import ms from 'ms'; import { useQueryState } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; @@ -146,6 +146,10 @@ export default function ContextSubpanel({ // Filter state const [selectedFilterIds, setSelectedFilterIds] = useState([]); + useEffect(() => { + setSelectedFilterIds([]); + }, [rowId]); + const availableFilters = useMemo( () => extractQuickFilters(rowData, source), [rowData, source], From 16915c3ae8edeead6d7cbe01d0d77ba079fcbb3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 03:59:36 +0000 Subject: [PATCH 06/10] refactor: redesign surrounding context with preset-based filter UI Implement the hybrid preset + pill design: - MATCH ON segmented control (All/Service/Host/Pod/Node/Custom) acts as preset shortcuts that auto-select groups of related filter pills. - Service preset selects the service pill; Pod selects service + pod + namespace; Host selects service + host; Node selects service + node. - All available attribute pills are always visible below the presets. - Users can manually toggle individual pills on top of or instead of presets for fine-grained control. - 'Matching on N attributes' header with Clear all. - Legend distinguishes matching (solid yellow border) from available (dashed border). - Pills show checkmark when selected, plus when available. - k8s.namespace.name added to promoted resource attributes for Pod preset support. Co-authored-by: Mike Shi --- .../app/src/components/ContextFilterPills.tsx | 109 ++++++++--- .../app/src/components/ContextSidePanel.tsx | 179 ++++++++++-------- 2 files changed, 189 insertions(+), 99 deletions(-) diff --git a/packages/app/src/components/ContextFilterPills.tsx b/packages/app/src/components/ContextFilterPills.tsx index f301d61997..7ccd022429 100644 --- a/packages/app/src/components/ContextFilterPills.tsx +++ b/packages/app/src/components/ContextFilterPills.tsx @@ -3,8 +3,8 @@ import { isTraceSource, TSource, } from '@hyperdx/common-utils/dist/types'; -import { Text, Tooltip } from '@mantine/core'; -import { IconX } from '@tabler/icons-react'; +import { Flex, Group, Text, Tooltip } from '@mantine/core'; +import { IconCheck, IconPlus } from '@tabler/icons-react'; import { formatAttributeClause } from '@/utils'; @@ -31,6 +31,7 @@ function formatColumnEquals( const PROMOTED_RESOURCE_ATTR_KEYS = [ 'host.name', 'k8s.pod.name', + 'k8s.namespace.name', 'k8s.node.name', ]; @@ -58,7 +59,6 @@ export function extractQuickFilters( | Record | undefined; - // Service name pill (promoted, always first) const serviceNameValue = rowData[ROW_DATA_ALIASES.SERVICE_NAME]; if (serviceNameExpr && serviceNameValue) { filters.push({ @@ -87,7 +87,6 @@ export function extractQuickFilters( }); } - // Promoted resource attribute pills (host, k8s) if (resourceAttrs && resourceAttrExpr) { for (const key of PROMOTED_RESOURCE_ATTR_KEYS) { const val = resourceAttrs[key]; @@ -102,7 +101,6 @@ export function extractQuickFilters( } } - // Remaining resource attributes const addedIds = new Set(filters.map(f => f.id)); if (resourceAttrs && resourceAttrExpr) { for (const [key, val] of Object.entries(resourceAttrs)) { @@ -118,7 +116,6 @@ export function extractQuickFilters( } } - // Event attributes const eventAttrs = rowData[ROW_DATA_ALIASES.EVENT_ATTRIBUTES]; if (eventAttrs && typeof eventAttrs === 'object' && eventAttrExpr) { for (const [key, val] of Object.entries( @@ -135,7 +132,6 @@ export function extractQuickFilters( } } - // Top-level columns for (const [key, val] of Object.entries(rowData)) { if (skipAliases.has(key) || key.startsWith('__hdx_')) continue; if (typeof val !== 'string' || !val || val.length > 200) continue; @@ -153,17 +149,53 @@ export function extractQuickFilters( return filters; } +// Preset definitions: which filter IDs each preset auto-selects +const MATCH_PRESET_IDS: Record = { + service: ['svc', 'ra:service.name'], + host: ['svc', 'ra:service.name', 'ra:host.name'], + pod: ['svc', 'ra:service.name', 'ra:k8s.pod.name', 'ra:k8s.namespace.name'], + node: ['svc', 'ra:service.name', 'ra:k8s.node.name'], +}; + +export function getPresetFilterIds( + preset: string, + available: QuickFilterItem[], +): string[] { + const wantedIds = MATCH_PRESET_IDS[preset] ?? []; + const availableIds = new Set(available.map(f => f.id)); + return wantedIds.filter(id => availableIds.has(id)); +} + +export function getAvailablePresets( + available: QuickFilterItem[], +): { label: string; value: string }[] { + const ids = new Set(available.map(f => f.id)); + const hasService = ids.has('svc') || ids.has('ra:service.name'); + const hasHost = ids.has('ra:host.name'); + const hasPod = ids.has('ra:k8s.pod.name'); + const hasNode = ids.has('ra:k8s.node.name'); + + return [ + { label: 'All', value: 'all' }, + ...(hasService ? [{ label: 'Service', value: 'service' }] : []), + ...(hasHost ? [{ label: 'Host', value: 'host' }] : []), + ...(hasPod ? [{ label: 'Pod', value: 'pod' }] : []), + ...(hasNode ? [{ label: 'Node', value: 'node' }] : []), + { label: 'Custom', value: 'custom' }, + ]; +} + const filterPillStyle = { display: 'inline-flex', alignItems: 'center' as const, - gap: 4, - padding: '2px 8px', + gap: 5, + padding: '3px 10px', borderRadius: 4, fontSize: 12, lineHeight: '20px', cursor: 'pointer', whiteSpace: 'nowrap' as const, - maxWidth: 400, + maxWidth: 360, overflow: 'hidden', }; @@ -188,14 +220,17 @@ export function FilterPill({ onClick={onToggle} style={{ ...filterPillStyle, - backgroundColor: isSelected ? 'var(--color-bg-hover)' : 'transparent', border: isSelected - ? '1px solid var(--color-border-emphasis)' - : '1px solid var(--color-border)', - opacity: isSelected ? 1 : 0.7, + ? '1.5px solid var(--mantine-color-yellow-5)' + : '1px dashed var(--color-border)', }} > - + {isSelected ? ( + + ) : ( + + )} + {filter.label} @@ -204,20 +239,48 @@ export function FilterPill({ {filter.value} - {isSelected && ( - - )} ); } + +export function FilterLegend() { + return ( + + + + + matching + + + + + + available — tap to add + + + + ); +} diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 5bd55fce04..dafbd23b28 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -10,17 +10,14 @@ import { TSource, } from '@hyperdx/common-utils/dist/types'; import { - ActionIcon, Badge, Flex, Group, ScrollArea, SegmentedControl, Text, - Tooltip, } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { IconSearch } from '@tabler/icons-react'; import SearchWhereInput, { getStoredLanguage, @@ -29,7 +26,13 @@ import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; import { useSource } from '@/source'; import { parseAsStringEncoded } from '@/utils/queryParsers'; -import { extractQuickFilters, FilterPill } from './ContextFilterPills'; +import { + extractQuickFilters, + FilterLegend, + FilterPill, + getAvailablePresets, + getPresetFilterIds, +} from './ContextFilterPills'; import { ROW_DATA_ALIASES } from './DBRowDataPanel'; import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel'; import { @@ -49,21 +52,14 @@ interface ContextSubpanelProps { // Custom hook to manage nested panel state export function useNestedPanelState(isNested?: boolean) { - // Query state (URL-based) for root level const queryState = { contextRowId: useQueryState('contextRowId', parseAsStringEncoded), - // Source IDs are MongoDB ObjectIDs (hex strings) and contain no special - // characters, so no encoding is needed here. contextRowSource: useQueryState('contextRowSource'), }; - - // Local state for nested levels const localState = { contextRowId: useState(null), contextRowSource: useState(null), }; - - // Choose which state to use based on nesting level const activeState = isNested ? localState : queryState; return { @@ -87,6 +83,7 @@ export default function ContextSubpanel({ const { whereLanguage: originalLanguage = 'lucene' } = dbSqlRowTableConfig ?? {}; const [range, setRange] = useState(ms('30s')); + const [activePreset, setActivePreset] = useState('all'); const [showCustomSearch, setShowCustomSearch] = useState(false); const { control } = useForm({ defaultValues: { @@ -101,9 +98,7 @@ export default function ContextSubpanel({ const formWhere = useWatch({ control, name: 'where' }); const [debouncedWhere] = useDebouncedValue(formWhere, 1000); - // State management for nested panels const isNested = !!breadcrumbPath?.length; - const { contextRowId, contextRowSource, @@ -114,7 +109,6 @@ export default function ContextSubpanel({ const { data: contextRowSidePanelSource } = useSource({ id: contextRowSource || '', }); - const [contextAliasWith, setContextAliasWith] = useState([]); const handleContextSidePanelClose = useCallback(() => { @@ -134,7 +128,6 @@ export default function ContextSubpanel({ ); const date = useMemo(() => new Date(origTimestamp), [origTimestamp]); - const newDateRange = useMemo( (): [Date, Date] => [ new Date(date.getTime() - range / 2), @@ -148,6 +141,8 @@ export default function ContextSubpanel({ useEffect(() => { setSelectedFilterIds([]); + setActivePreset('all'); + setShowCustomSearch(false); }, [rowId]); const availableFilters = useMemo( @@ -155,6 +150,29 @@ export default function ContextSubpanel({ [rowData, source], ); + const presetOptions = useMemo( + () => getAvailablePresets(availableFilters), + [availableFilters], + ); + + const handlePresetChange = useCallback( + (preset: string) => { + setActivePreset(preset); + if (preset === 'custom') { + setShowCustomSearch(true); + return; + } + setShowCustomSearch(false); + if (preset === 'all') { + setSelectedFilterIds([]); + return; + } + const ids = getPresetFilterIds(preset, availableFilters); + setSelectedFilterIds(ids); + }, + [availableFilters], + ); + const toggleFilter = useCallback((id: string) => { setSelectedFilterIds(prev => prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id], @@ -220,54 +238,44 @@ export default function ContextSubpanel({ source, ]); + const displayedPreset = + selectedFilterIds.length === 0 && !showCustomSearch ? 'all' : activePreset; + return ( <> {config && ( - - - - ±{ms(range / 2)} - - setRange(Number(value))} - /> - - - - setShowCustomSearch(v => !v)} - > - - - - {selectedFilterIds.length > 0 && ( - setSelectedFilterIds([])} - > - Clear filters - - )} - + + + ±{ms(range / 2)} + + setRange(Number(value))} + /> + + + Match on + + + {showCustomSearch && ( )} {availableFilters.length > 0 && ( - - - {availableFilters.map(filter => ( - toggleFilter(filter.id)} - /> - ))} - - + + + + Matching on{' '} + + {selectedFilterIds.length} + {' '} + attributes + + {selectedFilterIds.length > 0 && ( + { + setSelectedFilterIds([]); + setActivePreset('all'); + }} + > + Clear all + + )} + + + + {availableFilters.map(filter => ( + toggleFilter(filter.id)} + /> + ))} + + + + )}
Date: Wed, 1 Jul 2026 04:21:29 +0000 Subject: [PATCH 07/10] fix: polish surrounding context filter UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename 'All' preset to 'Anything' for clarity. - MATCH ON segmented control uses fit-content width instead of stretching 100%. - Manual pill toggles flip the preset to 'Custom' to indicate a non-preset selection. - Remove Badge border from the ±time display for visual consistency; show as plain text. - Remove unused Badge import. Co-authored-by: Mike Shi --- .../app/src/components/ContextFilterPills.tsx | 2 +- packages/app/src/components/ContextSidePanel.tsx | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/ContextFilterPills.tsx b/packages/app/src/components/ContextFilterPills.tsx index 7ccd022429..71e4a01ab5 100644 --- a/packages/app/src/components/ContextFilterPills.tsx +++ b/packages/app/src/components/ContextFilterPills.tsx @@ -176,7 +176,7 @@ export function getAvailablePresets( const hasNode = ids.has('ra:k8s.node.name'); return [ - { label: 'All', value: 'all' }, + { label: 'Anything', value: 'all' }, ...(hasService ? [{ label: 'Service', value: 'service' }] : []), ...(hasHost ? [{ label: 'Host', value: 'host' }] : []), ...(hasPod ? [{ label: 'Pod', value: 'pod' }] : []), diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index dafbd23b28..40289b738e 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -9,14 +9,7 @@ import { isTraceSource, TSource, } from '@hyperdx/common-utils/dist/types'; -import { - Badge, - Flex, - Group, - ScrollArea, - SegmentedControl, - Text, -} from '@mantine/core'; +import { Flex, Group, ScrollArea, SegmentedControl, Text } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import SearchWhereInput, { @@ -177,6 +170,7 @@ export default function ContextSubpanel({ setSelectedFilterIds(prev => prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id], ); + setActivePreset('custom'); }, []); const getWhereClause = useCallback((): string => { @@ -246,9 +240,9 @@ export default function ContextSubpanel({ {config && ( - + ±{ms(range / 2)} - + {showCustomSearch && ( From 6d4e195bb9d19a3d32dc8909e5f721477dcc61ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 04:35:07 +0000 Subject: [PATCH 08/10] fix: show custom search input when pill toggled to Custom mode Co-authored-by: Mike Shi --- packages/app/src/components/ContextSidePanel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 40289b738e..4fda509b6b 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -171,6 +171,7 @@ export default function ContextSubpanel({ prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id], ); setActivePreset('custom'); + setShowCustomSearch(true); }, []); const getWhereClause = useCallback((): string => { From ac8b034d5be3408f50f0d757aaec0c73609b5c02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 05:26:44 +0000 Subject: [PATCH 09/10] refactor: derive showCustomSearch from preset, DRY formatColumnEquals, add tests - Remove showCustomSearch state; derive from activePreset === 'custom'. - Move formatColumnEquals to @/utils alongside formatAttributeClause (DRY: was the only local utility not shared). - Add ErrorBoundary around filter pills section to limit blast radius. - Add 19 unit tests for ContextFilterPills (extractQuickFilters, getPresetFilterIds, getAvailablePresets) covering OTEL/non-OTEL schemas, value escaping, promoted attributes, and preset logic. - Add 2 unit tests for formatColumnEquals in utils.test.ts. Co-authored-by: Mike Shi --- packages/app/src/__tests__/utils.test.ts | 21 ++ .../app/src/components/ContextFilterPills.tsx | 14 +- .../app/src/components/ContextSidePanel.tsx | 96 +++--- .../__tests__/ContextFilterPills.test.ts | 304 ++++++++++++++++++ packages/app/src/utils.ts | 11 + 5 files changed, 387 insertions(+), 59 deletions(-) create mode 100644 packages/app/src/components/__tests__/ContextFilterPills.test.ts diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index b023cb8c6d..d4d32a7bd3 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -13,6 +13,7 @@ import { COLORS, evaluateColorCondition, formatAttributeClause, + formatColumnEquals, formatDurationMs, formatDurationMsCompact, formatNumber, @@ -59,6 +60,26 @@ describe('formatAttributeClause', () => { }); }); +describe('formatColumnEquals', () => { + it('formats SQL column equality with quote escaping', () => { + expect(formatColumnEquals('ServiceName', 'my-svc', true)).toBe( + "ServiceName = 'my-svc'", + ); + expect(formatColumnEquals('Name', "O'Brien", true)).toBe( + "Name = 'O''Brien'", + ); + }); + + it('formats Lucene column equality with quote escaping', () => { + expect(formatColumnEquals('ServiceName', 'my-svc', false)).toBe( + 'ServiceName:"my-svc"', + ); + expect(formatColumnEquals('Name', 'say "hello"', false)).toBe( + 'Name:"say \\"hello\\""', + ); + }); +}); + describe('getMetricTableName', () => { // Base source object with required properties const createBaseSource = () => ({ diff --git a/packages/app/src/components/ContextFilterPills.tsx b/packages/app/src/components/ContextFilterPills.tsx index 71e4a01ab5..6afdf1a97e 100644 --- a/packages/app/src/components/ContextFilterPills.tsx +++ b/packages/app/src/components/ContextFilterPills.tsx @@ -6,7 +6,7 @@ import { import { Flex, Group, Text, Tooltip } from '@mantine/core'; import { IconCheck, IconPlus } from '@tabler/icons-react'; -import { formatAttributeClause } from '@/utils'; +import { formatAttributeClause, formatColumnEquals } from '@/utils'; import { ROW_DATA_ALIASES } from './DBRowDataPanel'; @@ -17,17 +17,6 @@ export interface QuickFilterItem { generateWhere: (isSql: boolean) => string; } -function formatColumnEquals( - column: string, - value: string, - isSql: boolean, -): string { - if (isSql) { - return `${column} = '${value.replace(/'/g, "''")}'`; - } - return `${column}:"${value.replace(/"/g, '\\"')}"`; -} - const PROMOTED_RESOURCE_ATTR_KEYS = [ 'host.name', 'k8s.pod.name', @@ -149,7 +138,6 @@ export function extractQuickFilters( return filters; } -// Preset definitions: which filter IDs each preset auto-selects const MATCH_PRESET_IDS: Record = { service: ['svc', 'ra:service.name'], host: ['svc', 'ra:service.name', 'ra:host.name'], diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 4fda509b6b..13f4317431 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import ms from 'ms'; import { useQueryState } from 'nuqs'; +import { ErrorBoundary } from 'react-error-boundary'; import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { @@ -77,7 +78,6 @@ export default function ContextSubpanel({ dbSqlRowTableConfig ?? {}; const [range, setRange] = useState(ms('30s')); const [activePreset, setActivePreset] = useState('all'); - const [showCustomSearch, setShowCustomSearch] = useState(false); const { control } = useForm({ defaultValues: { where: '', @@ -129,13 +129,13 @@ export default function ContextSubpanel({ [date, range], ); - // Filter state + // Filter state — showCustomSearch is derived, not stored const [selectedFilterIds, setSelectedFilterIds] = useState([]); + const showCustomSearch = activePreset === 'custom'; useEffect(() => { setSelectedFilterIds([]); setActivePreset('all'); - setShowCustomSearch(false); }, [rowId]); const availableFilters = useMemo( @@ -151,12 +151,7 @@ export default function ContextSubpanel({ const handlePresetChange = useCallback( (preset: string) => { setActivePreset(preset); - if (preset === 'custom') { - setShowCustomSearch(true); - return; - } - setShowCustomSearch(false); - if (preset === 'all') { + if (preset === 'custom' || preset === 'all') { setSelectedFilterIds([]); return; } @@ -171,7 +166,6 @@ export default function ContextSubpanel({ prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id], ); setActivePreset('custom'); - setShowCustomSearch(true); }, []); const getWhereClause = useCallback((): string => { @@ -234,7 +228,9 @@ export default function ContextSubpanel({ ]); const displayedPreset = - selectedFilterIds.length === 0 && !showCustomSearch ? 'all' : activePreset; + selectedFilterIds.length === 0 && activePreset !== 'custom' + ? 'all' + : activePreset; return ( <> @@ -284,43 +280,51 @@ export default function ContextSubpanel({ )} {availableFilters.length > 0 && ( - - - - Matching on{' '} - - {selectedFilterIds.length} - {' '} - attributes + ( + + Unable to load event filters. - {selectedFilterIds.length > 0 && ( - { - setSelectedFilterIds([]); - setActivePreset('all'); - }} - > - Clear all + )} + > + + + + Matching on{' '} + + {selectedFilterIds.length} + {' '} + attributes - )} - - - - {availableFilters.map(filter => ( - toggleFilter(filter.id)} - /> - ))} - - - - + {selectedFilterIds.length > 0 && ( + { + setSelectedFilterIds([]); + setActivePreset('all'); + }} + > + Clear all + + )} + + + + {availableFilters.map(filter => ( + toggleFilter(filter.id)} + /> + ))} + + + + + )}
+ ({ + id: 'src-1', + kind: 'log', + name: 'Test Logs', + from: { databaseName: 'default', tableName: 'otel_logs' }, + connection: 'conn-1', + timestampValueExpression: 'Timestamp', + serviceNameExpression: 'ServiceName', + resourceAttributesExpression: 'ResourceAttributes', + eventAttributesExpression: 'LogAttributes', + defaultTableSelectExpression: '*', + ...overrides, + }) as unknown as TSource; + +describe('extractQuickFilters', () => { + it('creates a service pill from serviceNameExpression', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-service', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc'); + expect(svcFilter).toBeDefined(); + expect(svcFilter!.label).toBe('ServiceName'); + expect(svcFilter!.value).toBe('my-service'); + }); + + it('falls back to resource attribute service.name when no serviceNameExpression', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': 'api-gateway', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'ra:service.name'); + expect(svcFilter).toBeDefined(); + expect(svcFilter!.value).toBe('api-gateway'); + }); + + it('promotes host.name, k8s.pod.name, k8s.namespace.name, k8s.node.name', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'host.name': 'host-1', + 'k8s.pod.name': 'pod-abc', + 'k8s.namespace.name': 'default', + 'k8s.node.name': 'node-1', + 'other.attr': 'value', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + const ids = filters.map(f => f.id); + + expect(ids.indexOf('ra:host.name')).toBeLessThan( + ids.indexOf('ra:other.attr'), + ); + expect(ids.indexOf('ra:k8s.pod.name')).toBeLessThan( + ids.indexOf('ra:other.attr'), + ); + }); + + it('includes event attributes', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + 'http.method': 'GET', + 'http.url': '/api/health', + }, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.id === 'ea:http.method')).toBeDefined(); + expect(filters.find(f => f.id === 'ea:http.url')).toBeDefined(); + }); + + it('includes top-level columns as col: filters', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + SeverityText: 'ERROR', + ScopeName: 'my-scope', + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.id === 'col:SeverityText')).toBeDefined(); + expect(filters.find(f => f.id === 'col:ScopeName')).toBeDefined(); + }); + + it('skips timestamp-like columns and __hdx_ aliases', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.BODY]: 'test body', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: {}, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + TimestampTime: '2024-01-01', + EventTimeTTL: '2024-01-01', + __hdx_custom: 'hidden', + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.label === 'TimestampTime')).toBeUndefined(); + expect(filters.find(f => f.label === 'EventTimeTTL')).toBeUndefined(); + expect(filters.find(f => f.label === '__hdx_custom')).toBeUndefined(); + expect(filters.find(f => f.label === '__hdx_body')).toBeUndefined(); + }); + + it('skips values longer than 200 characters', () => { + const longValue = 'a'.repeat(201); + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { longkey: longValue }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.id === 'ra:longkey')).toBeUndefined(); + }); + + it('generates correct SQL WHERE clauses', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: "O'Brien", + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { 'host.name': 'host-1' }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc')!; + expect(svcFilter.generateWhere(true)).toBe("ServiceName = 'O''Brien'"); + + const hostFilter = filters.find(f => f.id === 'ra:host.name')!; + expect(hostFilter.generateWhere(true)).toBe( + "ResourceAttributes['host.name']='host-1'", + ); + }); + + it('generates correct Lucene WHERE clauses', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { 'host.name': 'host-1' }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc')!; + expect(svcFilter.generateWhere(false)).toBe('ServiceName:"my-svc"'); + + const hostFilter = filters.find(f => f.id === 'ra:host.name')!; + expect(hostFilter.generateWhere(false)).toBe( + 'ResourceAttributes.host.name:"host-1"', + ); + }); +}); + +describe('getPresetFilterIds', () => { + const available: QuickFilterItem[] = [ + { id: 'svc', label: 'ServiceName', value: 'api', generateWhere: () => '' }, + { + id: 'ra:host.name', + label: 'host.name', + value: 'h1', + generateWhere: () => '', + }, + { + id: 'ra:k8s.pod.name', + label: 'k8s.pod.name', + value: 'pod-1', + generateWhere: () => '', + }, + { + id: 'ra:k8s.namespace.name', + label: 'k8s.namespace.name', + value: 'ns', + generateWhere: () => '', + }, + { + id: 'ra:k8s.node.name', + label: 'k8s.node.name', + value: 'node-1', + generateWhere: () => '', + }, + ]; + + it('returns service IDs for "service" preset', () => { + expect(getPresetFilterIds('service', available)).toEqual(['svc']); + }); + + it('returns service + host IDs for "host" preset', () => { + expect(getPresetFilterIds('host', available)).toEqual([ + 'svc', + 'ra:host.name', + ]); + }); + + it('returns service + pod + namespace IDs for "pod" preset', () => { + expect(getPresetFilterIds('pod', available)).toEqual([ + 'svc', + 'ra:k8s.pod.name', + 'ra:k8s.namespace.name', + ]); + }); + + it('returns service + node IDs for "node" preset', () => { + expect(getPresetFilterIds('node', available)).toEqual([ + 'svc', + 'ra:k8s.node.name', + ]); + }); + + it('returns empty for unknown preset', () => { + expect(getPresetFilterIds('unknown', available)).toEqual([]); + }); + + it('only returns IDs that exist in available filters', () => { + const limited: QuickFilterItem[] = [ + { + id: 'ra:host.name', + label: 'host.name', + value: 'h1', + generateWhere: () => '', + }, + ]; + expect(getPresetFilterIds('host', limited)).toEqual(['ra:host.name']); + }); +}); + +describe('getAvailablePresets', () => { + it('always includes Anything and Custom', () => { + const presets = getAvailablePresets([]); + expect(presets.map(p => p.value)).toContain('all'); + expect(presets.map(p => p.value)).toContain('custom'); + expect(presets.find(p => p.value === 'all')!.label).toBe('Anything'); + }); + + it('includes Service when svc filter exists', () => { + const available: QuickFilterItem[] = [ + { + id: 'svc', + label: 'ServiceName', + value: 'api', + generateWhere: () => '', + }, + ]; + const presets = getAvailablePresets(available); + expect(presets.map(p => p.value)).toContain('service'); + }); + + it('includes Pod when k8s.pod.name filter exists', () => { + const available: QuickFilterItem[] = [ + { + id: 'ra:k8s.pod.name', + label: 'k8s.pod.name', + value: 'pod-1', + generateWhere: () => '', + }, + ]; + const presets = getAvailablePresets(available); + expect(presets.map(p => p.value)).toContain('pod'); + }); + + it('does not include Host when host.name is absent', () => { + const available: QuickFilterItem[] = [ + { + id: 'svc', + label: 'ServiceName', + value: 'api', + generateWhere: () => '', + }, + ]; + const presets = getAvailablePresets(available); + expect(presets.map(p => p.value)).not.toContain('host'); + }); +}); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index fadce48ef6..72c8257809 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1235,6 +1235,17 @@ export function formatAttributeClause( : `${column}.${field}:"${value}"`; } +export function formatColumnEquals( + column: string, + value: string, + isSql: boolean, +): string { + if (isSql) { + return `${column} = '${value.replace(/'/g, "''")}'`; + } + return `${column}:"${value.replace(/"/g, '\\"')}"`; +} + /** * Gets the appropriate table name for a source based on metric type * @param source The data source From c97c9ca11cda4e6ffc209534b56530cbaf6890ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 18:45:11 +0000 Subject: [PATCH 10/10] fix: address surrounding context review feedback - Escape attribute filter values in formatAttributeClause for SQL and Lucene. - Reset custom search form state when the selected row changes. - Use the active custom where language when generating surrounding context queries. - Avoid duplicate service.name pills when serviceNameExpression is available. - Avoid generating Lucene clauses from non-bare serviceNameExpression values. - Extract and test buildContextWhereClause for 0/1/many/custom clause paths. - Add ContextSidePanel regression tests for custom input visibility and row-change reset. Co-authored-by: Mike Shi --- packages/app/src/__tests__/utils.test.ts | 8 + .../app/src/components/ContextFilterPills.tsx | 90 +++++++++- .../app/src/components/ContextSidePanel.tsx | 42 +++-- .../__tests__/ContextFilterPills.test.ts | 159 ++++++++++++++++++ .../__tests__/ContextSidePanel.test.tsx | 135 +++++++++++++++ packages/app/src/utils.ts | 16 +- 6 files changed, 416 insertions(+), 34 deletions(-) create mode 100644 packages/app/src/components/__tests__/ContextSidePanel.test.tsx diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index d4d32a7bd3..e88972ec47 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -43,6 +43,10 @@ describe('formatAttributeClause', () => { expect(formatAttributeClause('data', 'user-id', 'abc-123', true)).toBe( "data['user-id']='abc-123'", ); + + expect(formatAttributeClause('data', 'user-id', "O'Brien", true)).toBe( + "data['user-id']='O''Brien'", + ); }); it('should format lucene attribute clause correctly', () => { @@ -57,6 +61,10 @@ describe('formatAttributeClause', () => { expect(formatAttributeClause('data', 'user-id', 'abc-123', false)).toBe( 'data.user-id:"abc-123"', ); + + expect(formatAttributeClause('data', 'user-id', 'say "hello"', false)).toBe( + 'data.user-id:"say \\"hello\\""', + ); }); }); diff --git a/packages/app/src/components/ContextFilterPills.tsx b/packages/app/src/components/ContextFilterPills.tsx index 6afdf1a97e..976549ab32 100644 --- a/packages/app/src/components/ContextFilterPills.tsx +++ b/packages/app/src/components/ContextFilterPills.tsx @@ -17,6 +17,15 @@ export interface QuickFilterItem { generateWhere: (isSql: boolean) => string; } +export interface BuildContextWhereClauseOptions { + selectedFilterIds: string[]; + availableFilters: QuickFilterItem[]; + isSql: boolean; + customWhere?: string; +} + +const MAX_FILTER_VALUE_LENGTH = 200; + const PROMOTED_RESOURCE_ATTR_KEYS = [ 'host.name', 'k8s.pod.name', @@ -24,6 +33,14 @@ const PROMOTED_RESOURCE_ATTR_KEYS = [ 'k8s.node.name', ]; +function isSafeLuceneFieldExpression(expression: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(expression); +} + +function isSafeAttributeKey(key: string): boolean { + return /^[A-Za-z0-9_.-]+$/.test(key); +} + export function extractQuickFilters( rowData: Record, source: TSource, @@ -49,13 +66,29 @@ export function extractQuickFilters( | undefined; const serviceNameValue = rowData[ROW_DATA_ALIASES.SERVICE_NAME]; - if (serviceNameExpr && serviceNameValue) { + if (serviceNameExpr && typeof serviceNameValue === 'string') { + const resourceServiceName = + typeof resourceAttrs?.['service.name'] === 'string' + ? resourceAttrs['service.name'] + : undefined; filters.push({ id: 'svc', label: serviceNameExpr, - value: String(serviceNameValue), - generateWhere: isSql => - formatColumnEquals(serviceNameExpr, String(serviceNameValue), isSql), + value: serviceNameValue, + generateWhere: isSql => { + if (isSql || isSafeLuceneFieldExpression(serviceNameExpr)) { + return formatColumnEquals(serviceNameExpr, serviceNameValue, isSql); + } + if (resourceAttrExpr && resourceServiceName) { + return formatAttributeClause( + resourceAttrExpr, + 'service.name', + resourceServiceName, + isSql, + ); + } + return ''; + }, }); } else if ( resourceAttrs?.['service.name'] && @@ -91,10 +124,19 @@ export function extractQuickFilters( } const addedIds = new Set(filters.map(f => f.id)); + if (filters.some(f => f.id === 'svc')) { + addedIds.add('ra:service.name'); + } if (resourceAttrs && resourceAttrExpr) { for (const [key, val] of Object.entries(resourceAttrs)) { if (addedIds.has(`ra:${key}`)) continue; - if (typeof val !== 'string' || !val || val.length > 200) continue; + if (!isSafeAttributeKey(key)) continue; + if ( + typeof val !== 'string' || + !val || + val.length > MAX_FILTER_VALUE_LENGTH + ) + continue; filters.push({ id: `ra:${key}`, label: key, @@ -110,7 +152,13 @@ export function extractQuickFilters( for (const [key, val] of Object.entries( eventAttrs as Record, )) { - if (typeof val !== 'string' || !val || val.length > 200) continue; + if (!isSafeAttributeKey(key)) continue; + if ( + typeof val !== 'string' || + !val || + val.length > MAX_FILTER_VALUE_LENGTH + ) + continue; filters.push({ id: `ea:${key}`, label: key, @@ -123,7 +171,9 @@ export function extractQuickFilters( for (const [key, val] of Object.entries(rowData)) { if (skipAliases.has(key) || key.startsWith('__hdx_')) continue; - if (typeof val !== 'string' || !val || val.length > 200) continue; + if (!isSafeLuceneFieldExpression(key)) continue; + if (typeof val !== 'string' || !val || val.length > MAX_FILTER_VALUE_LENGTH) + continue; if (/timestamp|ttl/i.test(key)) continue; if (serviceNameExpr && key === serviceNameExpr) continue; @@ -173,6 +223,32 @@ export function getAvailablePresets( ]; } +export function buildContextWhereClause({ + selectedFilterIds, + availableFilters, + isSql, + customWhere, +}: BuildContextWhereClauseOptions): string { + const clauses: string[] = []; + + for (const filterId of selectedFilterIds) { + const filter = availableFilters.find(f => f.id === filterId); + const clause = filter?.generateWhere(isSql).trim(); + if (clause) { + clauses.push(clause); + } + } + + const trimmedCustomWhere = customWhere?.trim(); + if (trimmedCustomWhere) { + clauses.push(trimmedCustomWhere); + } + + if (clauses.length === 0) return ''; + if (clauses.length === 1) return clauses[0]; + return clauses.map(c => `(${c})`).join(' AND '); +} + const filterPillStyle = { display: 'inline-flex', alignItems: 'center' as const, diff --git a/packages/app/src/components/ContextSidePanel.tsx b/packages/app/src/components/ContextSidePanel.tsx index 13f4317431..8fb3dd5888 100644 --- a/packages/app/src/components/ContextSidePanel.tsx +++ b/packages/app/src/components/ContextSidePanel.tsx @@ -21,6 +21,7 @@ import { useSource } from '@/source'; import { parseAsStringEncoded } from '@/utils/queryParsers'; import { + buildContextWhereClause, extractQuickFilters, FilterLegend, FilterPill, @@ -78,7 +79,7 @@ export default function ContextSubpanel({ dbSqlRowTableConfig ?? {}; const [range, setRange] = useState(ms('30s')); const [activePreset, setActivePreset] = useState('all'); - const { control } = useForm({ + const { control, reset } = useForm({ defaultValues: { where: '', whereLanguage: @@ -89,7 +90,9 @@ export default function ContextSubpanel({ }); const formWhere = useWatch({ control, name: 'where' }); + const formWhereLanguage = useWatch({ control, name: 'whereLanguage' }); const [debouncedWhere] = useDebouncedValue(formWhere, 1000); + const effectiveWhereLanguage = formWhereLanguage || originalLanguage; const isNested = !!breadcrumbPath?.length; const { @@ -136,7 +139,11 @@ export default function ContextSubpanel({ useEffect(() => { setSelectedFilterIds([]); setActivePreset('all'); - }, [rowId]); + reset({ + where: '', + whereLanguage: originalLanguage, + }); + }, [originalLanguage, reset, rowId]); const availableFilters = useMemo( () => extractQuickFilters(rowData, source), @@ -169,25 +176,14 @@ export default function ContextSubpanel({ }, []); const getWhereClause = useCallback((): string => { - const isSql = originalLanguage === 'sql'; - const clauses: string[] = []; - - for (const filterId of selectedFilterIds) { - const filter = availableFilters.find(f => f.id === filterId); - if (filter) { - clauses.push(filter.generateWhere(isSql)); - } - } - - if (showCustomSearch && debouncedWhere?.trim()) { - clauses.push(debouncedWhere.trim()); - } - - if (clauses.length === 0) return ''; - if (clauses.length === 1) return clauses[0]; - return clauses.map(c => `(${c})`).join(' AND '); + return buildContextWhereClause({ + selectedFilterIds, + availableFilters, + isSql: effectiveWhereLanguage === 'sql', + customWhere: showCustomSearch ? debouncedWhere : '', + }); }, [ - originalLanguage, + effectiveWhereLanguage, selectedFilterIds, availableFilters, showCustomSearch, @@ -208,21 +204,21 @@ export default function ContextSubpanel({ limit: { limit: 200 }, orderBy: `${source.timestampValueExpression} DESC`, where: whereClause, - whereLanguage: originalLanguage, + whereLanguage: effectiveWhereLanguage, dateRange: newDateRange, }; return { ...dbSqlRowTableConfig, where: whereClause, - whereLanguage: originalLanguage, + whereLanguage: effectiveWhereLanguage, dateRange: newDateRange, filters: [], }; }, [ dbSqlRowTableConfig, + effectiveWhereLanguage, getWhereClause, - originalLanguage, newDateRange, source, ]); diff --git a/packages/app/src/components/__tests__/ContextFilterPills.test.ts b/packages/app/src/components/__tests__/ContextFilterPills.test.ts index e9e3b1d962..4a25cdd7ed 100644 --- a/packages/app/src/components/__tests__/ContextFilterPills.test.ts +++ b/packages/app/src/components/__tests__/ContextFilterPills.test.ts @@ -1,6 +1,7 @@ import { TSource } from '@hyperdx/common-utils/dist/types'; import { + buildContextWhereClause, extractQuickFilters, getAvailablePresets, getPresetFilterIds, @@ -163,6 +164,30 @@ describe('extractQuickFilters', () => { ); }); + it('escapes quotes in attribute WHERE clauses', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'host.name': "host-'1", + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + message: 'say "hello"', + }, + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + const hostFilter = filters.find(f => f.id === 'ra:host.name')!; + expect(hostFilter.generateWhere(true)).toBe( + "ResourceAttributes['host.name']='host-''1'", + ); + + const messageFilter = filters.find(f => f.id === 'ea:message')!; + expect(messageFilter.generateWhere(false)).toBe( + 'LogAttributes.message:"say \\"hello\\""', + ); + }); + it('generates correct Lucene WHERE clauses', () => { const rowData = { [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-svc', @@ -181,6 +206,61 @@ describe('extractQuickFilters', () => { 'ResourceAttributes.host.name:"host-1"', ); }); + + it('does not duplicate service.name when serviceNameExpression is selected', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': 'my-svc', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource(); + const filters = extractQuickFilters(rowData, source); + + expect(filters.filter(f => f.id === 'ra:service.name')).toHaveLength(0); + expect(filters.filter(f => f.id === 'svc')).toHaveLength(1); + }); + + it('falls back to resource service.name for unsafe Lucene service expressions', () => { + const rowData = { + [ROW_DATA_ALIASES.SERVICE_NAME]: 'my-svc', + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': 'my-svc', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: {}, + }; + const source = makeLogSource({ + serviceNameExpression: "ResourceAttributes['service.name']", + }); + const filters = extractQuickFilters(rowData, source); + + const svcFilter = filters.find(f => f.id === 'svc')!; + expect(svcFilter.generateWhere(false)).toBe( + 'ResourceAttributes.service.name:"my-svc"', + ); + }); + + it('skips unsafe keys that cannot be represented in Lucene', () => { + const rowData = { + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'bad key': 'value', + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + 'bad/event': 'value', + }, + 'bad column': 'value', + }; + const source = makeLogSource({ serviceNameExpression: undefined }); + const filters = extractQuickFilters(rowData, source); + + expect(filters.find(f => f.label === 'bad key')).toBeUndefined(); + expect(filters.find(f => f.label === 'bad/event')).toBeUndefined(); + expect(filters.find(f => f.label === 'bad column')).toBeUndefined(); + }); }); describe('getPresetFilterIds', () => { @@ -302,3 +382,82 @@ describe('getAvailablePresets', () => { expect(presets.map(p => p.value)).not.toContain('host'); }); }); + +describe('buildContextWhereClause', () => { + const filters: QuickFilterItem[] = [ + { + id: 'svc', + label: 'ServiceName', + value: 'api', + generateWhere: isSql => + isSql ? "ServiceName = 'api'" : 'ServiceName:"api"', + }, + { + id: 'ra:host.name', + label: 'host.name', + value: 'h1', + generateWhere: isSql => + isSql + ? "ResourceAttributes['host.name']='h1'" + : 'ResourceAttributes.host.name:"h1"', + }, + { + id: 'empty', + label: 'empty', + value: 'empty', + generateWhere: () => '', + }, + ]; + + it('returns empty string with no filters or custom query', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: [], + availableFilters: filters, + isSql: true, + }), + ).toBe(''); + }); + + it('returns a single clause without wrapping', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['svc'], + availableFilters: filters, + isSql: true, + }), + ).toBe("ServiceName = 'api'"); + }); + + it('ANDs multiple selected filter clauses', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['svc', 'ra:host.name'], + availableFilters: filters, + isSql: true, + }), + ).toBe("(ServiceName = 'api') AND (ResourceAttributes['host.name']='h1')"); + }); + + it('appends custom search clauses', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['svc'], + availableFilters: filters, + isSql: false, + customWhere: 'SeverityText:"ERROR"', + }), + ).toBe('(ServiceName:"api") AND (SeverityText:"ERROR")'); + }); + + it('trims and ignores empty generated/custom clauses', () => { + expect( + buildContextWhereClause({ + selectedFilterIds: ['empty'], + availableFilters: filters, + isSql: true, + customWhere: ' ', + }), + ).toBe(''); + }); +}); diff --git a/packages/app/src/components/__tests__/ContextSidePanel.test.tsx b/packages/app/src/components/__tests__/ContextSidePanel.test.tsx new file mode 100644 index 0000000000..73ad763ce5 --- /dev/null +++ b/packages/app/src/components/__tests__/ContextSidePanel.test.tsx @@ -0,0 +1,135 @@ +import type { ComponentProps } from 'react'; +import { SourceKind, TLogSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import ContextSubpanel from '@/components/ContextSidePanel'; +import { ROW_DATA_ALIASES } from '@/components/DBRowDataPanel'; + +jest.mock('nuqs', () => ({ + createParser: jest.fn(config => config), + useQueryState: jest.fn(() => [null, jest.fn()]), +})); + +jest.mock('@/source', () => ({ + useSource: jest.fn(() => ({ data: null })), +})); + +jest.mock('@/components/DBRowSidePanel', () => { + const React = jest.requireActual('react'); + return { + __esModule: true, + default: () => null, + RowSidePanelContext: React.createContext({ + setChildModalOpen: jest.fn(), + }), + }; +}); + +jest.mock('@/components/DBRowTable', () => ({ + DBSqlRowTable: ({ config }: { config: { where?: string } }) => ( +
+ ), +})); + +jest.mock('@/components/SearchInput/SearchWhereInput', () => { + const React = jest.requireActual('react'); + const { useController } = jest.requireActual('react-hook-form'); + + function MockSearchWhereInput({ + control, + name, + }: { + control: any; + name: string; + }) { + const { field } = useController({ control, name }); + return ; + } + + return { + __esModule: true, + default: MockSearchWhereInput, + getStoredLanguage: jest.fn(() => 'lucene'), + }; +}); + +const source: TLogSource = { + id: 'source-id', + kind: SourceKind.Log, + name: 'logs', + connection: 'conn-id', + from: { databaseName: 'default', tableName: 'logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, Body', + serviceNameExpression: 'ServiceName', + resourceAttributesExpression: 'ResourceAttributes', + eventAttributesExpression: 'LogAttributes', +}; + +const makeRowData = (serviceName: string) => ({ + [ROW_DATA_ALIASES.TIMESTAMP]: '2024-01-01T00:00:00Z', + [ROW_DATA_ALIASES.SERVICE_NAME]: serviceName, + [ROW_DATA_ALIASES.RESOURCE_ATTRIBUTES]: { + 'service.name': serviceName, + 'host.name': `${serviceName}-host`, + }, + [ROW_DATA_ALIASES.EVENT_ATTRIBUTES]: { + 'http.method': 'GET', + }, + Body: `body ${serviceName}`, +}); + +function renderContextSubpanel( + props: Partial> = {}, +) { + const allProps = { + source, + dbSqlRowTableConfig: undefined, + rowData: makeRowData('api'), + rowId: 'row-1', + ...props, + }; + + return render( + + + , + ); +} + +describe('ContextSubpanel', () => { + it('shows custom search input when manually selecting a pill', () => { + renderContextSubpanel(); + + expect(screen.queryByLabelText('custom-search')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('context-filter-ra:host.name')); + + expect(screen.getByLabelText('custom-search')).toBeInTheDocument(); + }); + + it('clears stale custom search text when row changes', () => { + const { rerender } = renderContextSubpanel(); + + fireEvent.click(screen.getByText('Custom')); + fireEvent.change(screen.getByLabelText('custom-search'), { + target: { value: 'SeverityText:"ERROR"' }, + }); + + rerender( + + + , + ); + + fireEvent.click(screen.getByText('Custom')); + + expect(screen.getByLabelText('custom-search')).toHaveValue(''); + }); +}); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 72c8257809..a45a17d048 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1224,6 +1224,14 @@ export const optionsToSelectData = (options: Record) => Object.entries(options).map(([value, label]) => ({ value, label })); // Helper function to format attribute clause +function escapeSqlValueSingleQuoted(value: string): string { + return value.replace(/'/g, "''"); +} + +function escapeLuceneDoubleQuoted(value: string): string { + return value.replace(/"/g, '\\"'); +} + export function formatAttributeClause( column: string, field: string, @@ -1231,8 +1239,8 @@ export function formatAttributeClause( isSql: boolean, ): string { return isSql - ? `${column}['${field}']='${value}'` - : `${column}.${field}:"${value}"`; + ? `${column}['${field}']='${escapeSqlValueSingleQuoted(value)}'` + : `${column}.${field}:"${escapeLuceneDoubleQuoted(value)}"`; } export function formatColumnEquals( @@ -1241,9 +1249,9 @@ export function formatColumnEquals( isSql: boolean, ): string { if (isSql) { - return `${column} = '${value.replace(/'/g, "''")}'`; + return `${column} = '${escapeSqlValueSingleQuoted(value)}'`; } - return `${column}:"${value.replace(/"/g, '\\"')}"`; + return `${column}:"${escapeLuceneDoubleQuoted(value)}"`; } /**