From 1354426bf627fda649b21f84c14895983898c927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 30 Jun 2026 13:43:26 -0600 Subject: [PATCH 1/2] fix[frontend](logexplorer/sql): added sql autocompletion on sql logexplorer query builder --- .../components/LogExplorerView.tsx | 15 +- .../components/SqlAutocompleteDropdown.tsx | 73 +++++ .../components/SqlQueryEditor.tsx | 309 ++++++++++++++++++ .../log-explorer/domain/sql-keywords.ts | 47 +++ .../log-explorer/hooks/useSqlAutocomplete.ts | 52 +++ .../services/autocomplete-trie.service.ts | 81 +++++ 6 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/log-explorer/components/SqlAutocompleteDropdown.tsx create mode 100644 frontend/src/features/log-explorer/components/SqlQueryEditor.tsx create mode 100644 frontend/src/features/log-explorer/domain/sql-keywords.ts create mode 100644 frontend/src/features/log-explorer/hooks/useSqlAutocomplete.ts create mode 100644 frontend/src/features/log-explorer/services/autocomplete-trie.service.ts diff --git a/frontend/src/features/log-explorer/components/LogExplorerView.tsx b/frontend/src/features/log-explorer/components/LogExplorerView.tsx index 5f500d305..9ff8bdee2 100644 --- a/frontend/src/features/log-explorer/components/LogExplorerView.tsx +++ b/frontend/src/features/log-explorer/components/LogExplorerView.tsx @@ -37,6 +37,7 @@ import { Input } from '@/shared/components/ui/input' import { TimeRangePicker, presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' import { ResultsHeader, ResultRow, flattenDoc } from './log-results' import { IndexPatternSelector } from './IndexPatternSelector' +import { SqlQueryEditor } from './SqlQueryEditor' import { logExplorerHttpService as svc, LogExplorerHttpError, @@ -442,6 +443,7 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp onSqlMode={(v) => setSqlMode(v)} sqlInput={sqlInput} onSqlInput={setSqlInput} + fields={fields} range={range} onRange={(r) => setRange(r)} onRun={submit} @@ -576,6 +578,7 @@ function QueryBar({ onSqlMode, sqlInput, onSqlInput, + fields, range, onRange, onRun, @@ -592,6 +595,7 @@ function QueryBar({ onSqlMode: (b: boolean) => void sqlInput: string onSqlInput: (q: string) => void + fields: IndexField[] range: TimeRange onRange: (r: TimeRange) => void onRun: () => void @@ -610,12 +614,13 @@ function QueryBar({ {/* Search input — free text or SQL */}
{sqlMode ? ( - onSqlInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && onRun()} - placeholder="SELECT * FROM "v11-log-*" ORDER BY @timestamp DESC" - className="h-9 border-0 bg-transparent font-mono text-xs shadow-none focus-visible:ring-0" + onChange={onSqlInput} + onRun={onRun} + fields={fields} + patterns={patterns} + placeholder={'SELECT * FROM "v11-log-*" ORDER BY @timestamp DESC'} /> ) : ( <> diff --git a/frontend/src/features/log-explorer/components/SqlAutocompleteDropdown.tsx b/frontend/src/features/log-explorer/components/SqlAutocompleteDropdown.tsx new file mode 100644 index 000000000..1f75a4c39 --- /dev/null +++ b/frontend/src/features/log-explorer/components/SqlAutocompleteDropdown.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react' +import { cn } from '@/shared/lib/utils' +import type { Suggestion } from '../services/autocomplete-trie.service' + +interface Props { + items: Suggestion[] + activeIndex: number + position: { x: number; y: number } + onPick: (item: Suggestion) => void + onHover: (index: number) => void +} + +const ROW_HEIGHT = 28 +const MAX_VISIBLE = 8 + +export function SqlAutocompleteDropdown({ + items, + activeIndex, + position, + onPick, + onHover, +}: Props) { + const listRef = useRef(null) + + useEffect(() => { + const el = listRef.current?.children[activeIndex] as HTMLElement | undefined + el?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex]) + + if (items.length === 0) return null + + return ( +
    e.preventDefault()} + > + {items.map((item, i) => ( +
  • onHover(i)} + onClick={() => onPick(item)} + > + {item.word} + + {item.tag === 'sql' ? 'SQL' : item.tag === 'index' ? 'index' : 'field'} + +
  • + ))} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/SqlQueryEditor.tsx b/frontend/src/features/log-explorer/components/SqlQueryEditor.tsx new file mode 100644 index 000000000..065a4ac17 --- /dev/null +++ b/frontend/src/features/log-explorer/components/SqlQueryEditor.tsx @@ -0,0 +1,309 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import Prism from 'prismjs' +import 'prismjs/components/prism-sql' +import { cn } from '@/shared/lib/utils' +import { useSqlAutocomplete } from '../hooks/useSqlAutocomplete' +import { SqlAutocompleteDropdown } from './SqlAutocompleteDropdown' +import type { Suggestion } from '../services/autocomplete-trie.service' +import type { IndexField, IndexPattern } from '../types/log-explorer.types' + +interface Props { + value: string + onChange: (v: string) => void + onRun: () => void + fields: IndexField[] + patterns: IndexPattern[] + placeholder?: string +} + +const MIN_ROWS = 4 +const MAX_ROWS = 12 +const LINE_HEIGHT_PX = 18 +const PADDING_Y = 8 +const TOKEN_CHARS = /[A-Za-z0-9_.@-]/ +const MIN_HEIGHT = MIN_ROWS * LINE_HEIGHT_PX + PADDING_Y * 2 +const MAX_HEIGHT = MAX_ROWS * LINE_HEIGHT_PX + PADDING_Y * 2 + +interface TokenSpan { + text: string + start: number + end: number +} + +function tokenAtCaret(value: string, caret: number): TokenSpan | null { + let start = caret + while (start > 0 && TOKEN_CHARS.test(value[start - 1])) start-- + if (start === caret) return null + return { text: value.slice(start, caret), start, end: caret } +} + +function getCaretViewportCoords(textarea: HTMLTextAreaElement): { left: number; top: number } { + const mirror = document.createElement('div') + const cs = window.getComputedStyle(textarea) + const copyProps = [ + 'boxSizing', 'width', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', + 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', + 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', + 'borderLeftWidth', 'tabSize', 'textIndent', 'textTransform', 'wordSpacing', + ] as const + for (const p of copyProps) { + mirror.style.setProperty(p as string, cs.getPropertyValue(p as string)) + } + mirror.style.position = 'absolute' + mirror.style.visibility = 'hidden' + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.wordWrap = 'break-word' + mirror.style.overflow = 'hidden' + mirror.style.top = '0' + mirror.style.left = '-9999px' + document.body.appendChild(mirror) + + const caret = textarea.selectionStart ?? textarea.value.length + mirror.textContent = textarea.value.slice(0, caret) + const marker = document.createElement('span') + marker.textContent = textarea.value.slice(caret, caret + 1) || '.' + mirror.appendChild(marker) + + const markerRect = marker.getBoundingClientRect() + const mirrorRect = mirror.getBoundingClientRect() + const taRect = textarea.getBoundingClientRect() + document.body.removeChild(mirror) + + return { + left: taRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft, + top: taRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop, + } +} + +export function SqlQueryEditor({ value, onChange, onRun, fields, patterns, placeholder }: Props) { + const textareaRef = useRef(null) + const wrapperRef = useRef(null) + const preRef = useRef(null) + const [height, setHeight] = useState(MIN_HEIGHT) + const [open, setOpen] = useState(false) + const [lastTimeout, setLastTimeout] = useState(-1) + const [items, setItems] = useState([]) + const [activeIndex, setActiveIndex] = useState(0) + const [anchor, setAnchor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + const tokenRef = useRef(null) + + const { suggest } = useSqlAutocomplete(fields, patterns) + + const highlighted = useMemo(() => { + const grammar = Prism.languages.sql + if (!grammar) return value + return Prism.highlight(value + '\n', grammar, 'sql') + }, [value]) + + const recalcHeight = useCallback(() => { + const ta = textareaRef.current + if (!ta) return + ta.style.height = 'auto' + const next = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, ta.scrollHeight)) + ta.style.height = '' + setHeight(next) + }, []) + + useLayoutEffect(() => { + recalcHeight() + }, [value, recalcHeight]) + + const closeDropdown = useCallback(() => { + setOpen(false) + setItems([]) + tokenRef.current = null + }, []) + + const refreshSuggestions = useCallback(() => { + const ta = textareaRef.current + if (!ta) return + const caret = ta.selectionStart ?? 0 + const token = tokenAtCaret(value, caret) + if (!token) { + closeDropdown() + return + } + const next = suggest(token.text, 30).filter( + (s) => s.word.toLowerCase() !== token.text.toLowerCase(), + ) + if (next.length === 0) { + closeDropdown() + return + } + tokenRef.current = token + setItems(next) + setActiveIndex(0) + const coords = getCaretViewportCoords(ta) + setAnchor({ x: coords.left, y: coords.top + LINE_HEIGHT_PX + 4 }) + setOpen(true) + }, [closeDropdown, suggest, value]) + + const accept = useCallback( + (item: Suggestion) => { + const ta = textareaRef.current + const span = tokenRef.current + if (!ta || !span) return + const next = value.slice(0, span.start) + item.word + value.slice(span.end) + onChange(next) + closeDropdown() + const newCaret = span.start + item.word.length + requestAnimationFrame(() => { + const t = textareaRef.current + if (!t) return + t.focus() + t.setSelectionRange(newCaret, newCaret) + }) + }, + [closeDropdown, onChange, value], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault() + closeDropdown() + onRun() + return + } + if (open) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((i) => (i + 1) % items.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((i) => (i - 1 + items.length) % items.length) + return + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + const picked = items[activeIndex] + if (picked) accept(picked) + return + } + if (e.key === 'Escape') { + e.preventDefault() + closeDropdown() + return + } + } else if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + onRun() + } + }, + [accept, activeIndex, closeDropdown, items, onRun, open], + ) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value) + }, + [onChange], + ) + + + useEffect(() => { + if(lastTimeout >=0){ + clearTimeout(lastTimeout) + } + + setLastTimeout( + setTimeout(()=>{ + if (document.activeElement === textareaRef.current) refreshSuggestions() + },300) + ) + }, [value, refreshSuggestions]) + + useEffect(() => { + if (!open) return + const onMouseDown = (e: MouseEvent) => { + const root = wrapperRef.current + if (!root) return + if (!root.contains(e.target as Node)) closeDropdown() + } + document.addEventListener('mousedown', onMouseDown) + return () => document.removeEventListener('mousedown', onMouseDown) + }, [open, closeDropdown]) + + const syncScroll = useCallback(() => { + const ta = textareaRef.current + const pre = preRef.current + if (!ta || !pre) return + pre.scrollTop = ta.scrollTop + pre.scrollLeft = ta.scrollLeft + }, []) + + return ( +
+ +
+        
+      
+