From 801ba6aa547e1b37d0d026b138c1557fe70bbe5a Mon Sep 17 00:00:00 2001 From: vinzee Date: Fri, 29 May 2026 14:25:39 -0700 Subject: [PATCH] Add query syntax reference to search input Introduce a quick-reference guide for SQL and Lucene query syntax directly within the search interface. Users can now click a help icon next to the language selector to view common query examples, operators, and formatting rules tailored to the active query language. This improves usability and helps users construct valid search queries more easily. --- .../SearchInput/SearchWhereInput.tsx | 146 ++++--- .../SearchInput/SyntaxReferenceModal.tsx | 355 ++++++++++++++++++ 2 files changed, 440 insertions(+), 61 deletions(-) create mode 100644 packages/app/src/components/SearchInput/SyntaxReferenceModal.tsx diff --git a/packages/app/src/components/SearchInput/SearchWhereInput.tsx b/packages/app/src/components/SearchInput/SearchWhereInput.tsx index f4fbd4be3d..296a23cb76 100644 --- a/packages/app/src/components/SearchInput/SearchWhereInput.tsx +++ b/packages/app/src/components/SearchInput/SearchWhereInput.tsx @@ -1,11 +1,14 @@ import { FieldPath, useController, UseControllerProps } from 'react-hook-form'; import { TableConnectionChoice } from '@hyperdx/common-utils/dist/core/metadata'; -import { Box, Flex, Kbd } from '@mantine/core'; +import { ActionIcon, Box, Flex, Kbd, Tooltip } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconHelp } from '@tabler/icons-react'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; import InputLanguageSwitch from './InputLanguageSwitch'; import SearchInputV2 from './SearchInputV2'; +import SyntaxReferenceModal from './SyntaxReferenceModal'; import styles from './SearchWhereInput.module.scss'; @@ -164,6 +167,9 @@ export default function SearchWhereInput({ languageName = `${name}Language`, sourceId, }: SearchWhereInputProps) { + const [syntaxRefOpened, { open: openSyntaxRef, close: closeSyntaxRef }] = + useDisclosure(false); + const { field: languageField } = useController({ control, name: languageName as FieldPath, @@ -182,68 +188,86 @@ export default function SearchWhereInput({ const sizeClass = size === 'xs' ? styles.sizeXs : styles.sizeSm; return ( - - e.preventDefault()} + <> + + - - - - {isSql ? ( - - ) : ( - e.preventDefault()} + > + - )} - {enableHotkey && ( - - / - - )} + + + + + + + + {isSql ? ( + + ) : ( + + )} + {enableHotkey && ( + + / + + )} + - + ); } diff --git a/packages/app/src/components/SearchInput/SyntaxReferenceModal.tsx b/packages/app/src/components/SearchInput/SyntaxReferenceModal.tsx new file mode 100644 index 0000000000..dfdf8e2115 --- /dev/null +++ b/packages/app/src/components/SearchInput/SyntaxReferenceModal.tsx @@ -0,0 +1,355 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Box, + Code, + Divider, + Group, + Modal, + ScrollArea, + SegmentedControl, + Stack, + Table, + Text, + TextInput, + Title, + Tooltip, +} from '@mantine/core'; +import { IconExternalLink, IconSearch } from '@tabler/icons-react'; + +type Language = 'sql' | 'lucene'; + +type Row = { expr: string; desc: string }; +type Section = { title: string; rows: Row[] }; + +const SQL_SECTIONS: Section[] = [ + { + title: 'String matching', + rows: [ + { expr: "ServiceName = 'api'", desc: 'Exact match' }, + { expr: "Body = 'connection refused'", desc: 'Exact phrase match' }, + { + expr: "Body ILIKE '%timeout%'", + desc: 'Substring search (case-insensitive)', + }, + { + expr: "hasAllTokens(Body, 'connection timeout')", + desc: 'Full-text search (requires text index)', + }, + { + expr: "ServiceName LIKE 'auth-%'", + desc: 'Prefix wildcard (case-sensitive)', + }, + { expr: "SpanName LIKE '%checkout%'", desc: 'Substring match' }, + { expr: "Body ILIKE '%error%'", desc: 'Case-insensitive substring' }, + { + expr: "match(SpanName, '^/api/(checkout|payment)/.*')", + desc: 'Regular expression', + }, + ], + }, + { + title: 'Boolean operators', + rows: [ + { + expr: "ServiceName = 'api' AND SpanName = 'checkout'", + desc: 'Both must match', + }, + { + expr: "ServiceName = 'api' OR ServiceName = 'worker'", + desc: 'Either matches', + }, + { + expr: "ServiceName IN ('api', 'worker')", + desc: 'Match multiple values', + }, + { expr: "ServiceName != 'healthcheck'", desc: 'Exclude a value' }, + { + expr: "(StatusCode = 500 OR StatusCode = 503) AND ServiceName = 'api'", + desc: 'Nested boolean logic', + }, + { expr: 'Duration > 1000000', desc: 'Numeric comparison' }, + { expr: 'Duration BETWEEN 100 AND 1000', desc: 'Range (inclusive)' }, + { expr: 'Duration / 1e6 > 100', desc: 'Math expression' }, + ], + }, + { + title: 'Existence & absence', + rows: [ + { expr: 'isNotNull(StatusCode)', desc: 'Field exists / is not null' }, + { expr: 'isNull(Body)', desc: 'Field is absent / null' }, + ], + }, + { + title: 'Map', + rows: [ + { + expr: "LogAttributes['http.method'] = 'POST'", + desc: 'Access map/attribute column by key', + }, + { + expr: "ResourceAttributes['service.env'] = 'prod'", + desc: 'Resource attribute filter', + }, + ], + }, + { + title: 'Arrays', + rows: [ + { + expr: "has(Events.Name, 'exception')", + desc: 'Array column contains value (traces)', + }, + ], + }, +]; + +const LUCENE_SECTIONS: Section[] = [ + { + title: 'String matching', + rows: [ + { expr: 'ServiceName:api', desc: 'Exact match' }, + { expr: '"connection refused"', desc: 'Exact phrase match' }, + { expr: 'timeout', desc: 'Full-text search' }, + { expr: 'ServiceName:auth-*', desc: 'Prefix wildcard' }, + { expr: 'SpanName:*checkout*', desc: 'Substring wildcard' }, + { expr: 'SpanName:*checkout', desc: 'Suffix wildcard' }, + { expr: 'Duration:[100 TO 500]', desc: 'Numeric range (inclusive)' }, + { expr: 'Duration:{100 TO 500}', desc: 'Numeric range (exclusive)' }, + { expr: 'Duration:>1000000', desc: 'Greater-than comparison' }, + ], + }, + { + title: 'Boolean operators', + rows: [ + { + expr: 'ServiceName:api AND SpanName:checkout', + desc: 'Both conditions must match', + }, + { + expr: 'ServiceName:api OR ServiceName:worker', + desc: 'Either condition matches', + }, + { + expr: 'ServiceName:(api OR worker)', + desc: 'Match multiple values for one field', + }, + { expr: 'NOT ServiceName:healthcheck', desc: 'Exclude matches' }, + { expr: '-ServiceName:healthcheck', desc: 'Shorthand for NOT' }, + { + expr: '(ServiceName:api OR ServiceName:worker) AND StatusCode:500', + desc: 'Nested boolean logic', + }, + ], + }, + { + title: 'Existence & absence', + rows: [ + { expr: 'StatusCode:*', desc: 'Field exists (not null)' }, + { expr: '-Body:*', desc: 'Field is absent / null' }, + ], + }, + { + title: 'Map', + rows: [ + { + expr: 'LogAttributes.http.method:POST', + desc: 'Access map/attribute column by key', + }, + { + expr: 'ResourceAttributes.service.env:prod', + desc: 'Resource attribute filter', + }, + ], + }, + { + title: 'Arrays', + rows: [ + { + expr: 'Events.Name:exception', + desc: 'Array column contains value (traces)', + }, + ], + }, +]; + +function filterSections(sections: Section[], query: string): Section[] { + if (!query.trim()) return sections; + const q = query.toLowerCase(); + return sections + .map(section => ({ + ...section, + rows: section.rows.filter( + row => + row.expr.toLowerCase().includes(q) || + row.desc.toLowerCase().includes(q), + ), + })) + .filter(section => section.rows.length > 0); +} + +function Highlight({ text, query }: { text: string; query: string }) { + if (!query.trim()) return <>{text}; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return <>{text}; + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + query.length)} + + {text.slice(idx + query.length)} + + ); +} + +function SyntaxTable({ + sections, + query, +}: { + sections: Section[]; + query: string; +}) { + const filtered = useMemo( + () => filterSections(sections, query), + [sections, query], + ); + + if (filtered.length === 0) { + return ( + + No results for “{query}” + + ); + } + + return ( + + {filtered.map((section, si) => ( + + {si > 0 && } + + {section.title} + + + + {section.rows.map(row => ( + + + + + + + + + + + + + ))} + +
+
+ ))} +
+ ); +} + +const INTRO: Record = { + sql: '', + lucene: '', +}; + +export default function SyntaxReferenceModal({ + opened, + onClose, + language: initialLanguage, +}: { + opened: boolean; + onClose: () => void; + language: Language; +}) { + const [language, setLanguage] = useState(initialLanguage); + const [query, setQuery] = useState(''); + + // Sync tab when the modal opens or caller switches language externally + useEffect(() => { + if (opened) setLanguage(initialLanguage); + }, [opened, initialLanguage]); + + const sections = language === 'sql' ? SQL_SECTIONS : LUCENE_SECTIONS; + + return ( + { + setQuery(''); + onClose(); + }} + title={Search Syntax Reference} + size="xl" + scrollAreaComponent={ScrollArea.Autosize} + > + + + { + setLanguage(val as Language); + setQuery(''); + }} + data={[ + { value: 'lucene', label: 'Lucene' }, + { value: 'sql', label: 'SQL' }, + ]} + /> + } + value={query} + onChange={e => setQuery(e.currentTarget.value)} + autoFocus + size="xs" + style={{ flex: 1 }} + /> + + + + + + + {INTRO[language] && ( + + {INTRO[language]} + + )} + + + + ); +}