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..7b0dd2dd8
--- /dev/null
+++ b/frontend/src/features/log-explorer/components/SqlQueryEditor.tsx
@@ -0,0 +1,300 @@
+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 [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 (document.activeElement === textareaRef.current) refreshSuggestions()
+ }, [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 (
+
+
+
+
+
+
+ {open && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/features/log-explorer/domain/sql-keywords.ts b/frontend/src/features/log-explorer/domain/sql-keywords.ts
new file mode 100644
index 000000000..6ec90cfb5
--- /dev/null
+++ b/frontend/src/features/log-explorer/domain/sql-keywords.ts
@@ -0,0 +1,47 @@
+export const SQL_KEYWORDS: readonly string[] = Object.freeze([
+ 'SELECT',
+ 'FROM',
+ 'WHERE',
+ 'AND',
+ 'OR',
+ 'NOT',
+ 'IN',
+ 'LIKE',
+ 'BETWEEN',
+ 'IS',
+ 'NULL',
+ 'ORDER BY',
+ 'GROUP BY',
+ 'HAVING',
+ 'LIMIT',
+ 'OFFSET',
+ 'AS',
+ 'DESC',
+ 'ASC',
+ 'DISTINCT',
+ 'COUNT',
+ 'SUM',
+ 'AVG',
+ 'MIN',
+ 'MAX',
+ 'JOIN',
+ 'LEFT',
+ 'RIGHT',
+ 'INNER',
+ 'OUTER',
+ 'ON',
+ 'UNION',
+ 'ALL',
+ 'CASE',
+ 'WHEN',
+ 'THEN',
+ 'ELSE',
+ 'END',
+ 'EXISTS',
+ 'CAST',
+ 'EXTRACT',
+ 'DATE_FORMAT',
+ 'TIMESTAMP',
+ 'TRUE',
+ 'FALSE',
+])
diff --git a/frontend/src/features/log-explorer/hooks/useSqlAutocomplete.ts b/frontend/src/features/log-explorer/hooks/useSqlAutocomplete.ts
new file mode 100644
index 000000000..2f12f504b
--- /dev/null
+++ b/frontend/src/features/log-explorer/hooks/useSqlAutocomplete.ts
@@ -0,0 +1,52 @@
+import { useCallback, useEffect, useMemo } from 'react'
+
+import {
+ AutocompleteTrie,
+ Suggestion,
+ createAutocompleteTrie,
+} from '../services/autocomplete-trie.service'
+import { SQL_KEYWORDS } from '../domain/sql-keywords'
+import type { IndexField, IndexPattern } from '../types/log-explorer.types'
+
+interface UseSqlAutocompleteResult {
+ suggest: (prefix: string, limit?: number) => Suggestion[]
+}
+
+export function useSqlAutocomplete(
+ fields: IndexField[],
+ patterns: IndexPattern[],
+): UseSqlAutocompleteResult {
+ const trie = useMemo(() => {
+ const t = createAutocompleteTrie()
+ for (const kw of SQL_KEYWORDS) t.insert(kw, 'sql')
+ return t
+ }, [])
+
+ useEffect(() => {
+ trie.clearTag('field')
+ const seen = new Set()
+ for (const f of fields) {
+ const base = f.name.endsWith('.keyword') ? f.name.slice(0, -'.keyword'.length) : f.name
+ if (!base || seen.has(base)) continue
+ seen.add(base)
+ trie.insert(base, 'field')
+ }
+ }, [fields, trie])
+
+ useEffect(() => {
+ trie.clearTag('index')
+ const seen = new Set()
+ for (const p of patterns) {
+ if (!p.pattern || seen.has(p.pattern)) continue
+ seen.add(p.pattern)
+ trie.insert(p.pattern, 'index')
+ }
+ }, [patterns, trie])
+
+ const suggest = useCallback(
+ (prefix: string, limit = 20) => trie.suggest(prefix, limit),
+ [trie],
+ )
+
+ return { suggest }
+}
diff --git a/frontend/src/features/log-explorer/services/autocomplete-trie.service.ts b/frontend/src/features/log-explorer/services/autocomplete-trie.service.ts
new file mode 100644
index 000000000..2c40d31c3
--- /dev/null
+++ b/frontend/src/features/log-explorer/services/autocomplete-trie.service.ts
@@ -0,0 +1,81 @@
+export type SuggestionTag = 'sql' | 'field' | 'index'
+
+export interface Suggestion {
+ word: string
+ tag: SuggestionTag
+}
+
+interface TrieNode {
+ children: Map
+ word?: string
+ tag?: SuggestionTag
+}
+
+const createNode = (): TrieNode => ({ children: new Map() })
+
+export class AutocompleteTrie {
+ private readonly root: TrieNode = createNode()
+
+ insert(word: string, tag: SuggestionTag): void {
+ if (!word) return
+ let node = this.root
+ const lower = word.toLowerCase()
+ for (const ch of lower) {
+ let next = node.children.get(ch)
+ if (!next) {
+ next = createNode()
+ node.children.set(ch, next)
+ }
+ node = next
+ }
+ node.word = word
+ node.tag = tag
+ }
+
+ clearTag(tag: SuggestionTag): void {
+ this.stripTag(this.root, tag)
+ }
+
+ private stripTag(node: TrieNode, tag: SuggestionTag): boolean {
+ if (node.tag === tag) {
+ node.word = undefined
+ node.tag = undefined
+ }
+ for (const [ch, child] of node.children) {
+ const childEmpty = this.stripTag(child, tag)
+ if (childEmpty) node.children.delete(ch)
+ }
+ return node.word === undefined && node.children.size === 0
+ }
+
+ suggest(prefix: string, limit = 20): Suggestion[] {
+ if (!prefix) return []
+ let node: TrieNode | undefined = this.root
+ for (const ch of prefix.toLowerCase()) {
+ node = node.children.get(ch)
+ if (!node) return []
+ }
+ const buckets: Record = { sql: [], field: [], index: [] }
+ this.collect(node, buckets, limit)
+ return [...buckets.sql, ...buckets.index, ...buckets.field].slice(0, limit)
+ }
+
+ private collect(
+ node: TrieNode,
+ buckets: Record,
+ limit: number,
+ ): void {
+ const size = buckets.sql.length + buckets.field.length + buckets.index.length
+ if (size >= limit) return
+ if (node.word && node.tag) {
+ buckets[node.tag].push({ word: node.word, tag: node.tag })
+ }
+ for (const child of node.children.values()) {
+ const cur = buckets.sql.length + buckets.field.length + buckets.index.length
+ if (cur >= limit) return
+ this.collect(child, buckets, limit)
+ }
+ }
+}
+
+export const createAutocompleteTrie = (): AutocompleteTrie => new AutocompleteTrie()