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]} + + )} + + + + ); +}