Skip to content

Commit f2ec43e

Browse files
authored
feat(logs): added intelligent search with suggestions to logs (#1329)
* update infra and remove railway * feat(logs): added intelligent search to logs * Revert "update infra and remove railway" This reverts commit abfa2f8. * cleanup * cleanup
1 parent 3e5d373 commit f2ec43e

File tree

8 files changed

+1463
-21
lines changed

8 files changed

+1463
-21
lines changed

apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { createLogger } from '@/lib/logs/console/logger'
1919
import { useFolderStore } from '@/stores/folders/store'
2020
import { useFilterStore } from '@/stores/logs/filters/store'
2121

22+
const logger = createLogger('LogsFolderFilter')
23+
2224
interface FolderOption {
2325
id: string
2426
name: string
@@ -34,7 +36,6 @@ export default function FolderFilter() {
3436
const [folders, setFolders] = useState<FolderOption[]>([])
3537
const [loading, setLoading] = useState(true)
3638
const [search, setSearch] = useState('')
37-
const logger = useMemo(() => createLogger('LogsFolderFilter'), [])
3839

3940
// Fetch all available folders from the API
4041
useEffect(() => {

apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
import { createLogger } from '@/lib/logs/console/logger'
1818
import { useFilterStore } from '@/stores/logs/filters/store'
1919

20+
const logger = createLogger('LogsWorkflowFilter')
21+
2022
interface WorkflowOption {
2123
id: string
2224
name: string
@@ -28,7 +30,6 @@ export default function Workflow() {
2830
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
2931
const [loading, setLoading] = useState(true)
3032
const [search, setSearch] = useState('')
31-
const logger = useMemo(() => createLogger('LogsWorkflowFilter'), [])
3233

3334
// Fetch all available workflows from the API
3435
useEffect(() => {
@@ -55,7 +56,6 @@ export default function Workflow() {
5556
fetchWorkflows()
5657
}, [])
5758

58-
// Get display text for the dropdown button
5959
const getSelectedWorkflowsText = () => {
6060
if (workflowIds.length === 0) return 'All workflows'
6161
if (workflowIds.length === 1) {
@@ -65,12 +65,10 @@ export default function Workflow() {
6565
return `${workflowIds.length} workflows selected`
6666
}
6767

68-
// Check if a workflow is selected
6968
const isWorkflowSelected = (workflowId: string) => {
7069
return workflowIds.includes(workflowId)
7170
}
7271

73-
// Clear all selections
7472
const clearSelections = () => {
7573
setWorkflowIds([])
7674
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
'use client'
2+
3+
import { useMemo } from 'react'
4+
import { Search, X } from 'lucide-react'
5+
import { Badge } from '@/components/ui/badge'
6+
import { Button } from '@/components/ui/button'
7+
import { Input } from '@/components/ui/input'
8+
import { parseQuery } from '@/lib/logs/query-parser'
9+
import { SearchSuggestions } from '@/lib/logs/search-suggestions'
10+
import { cn } from '@/lib/utils'
11+
import { useAutocomplete } from '@/app/workspace/[workspaceId]/logs/hooks/use-autocomplete'
12+
13+
interface AutocompleteSearchProps {
14+
value: string
15+
onChange: (value: string) => void
16+
placeholder?: string
17+
availableWorkflows?: string[]
18+
availableFolders?: string[]
19+
className?: string
20+
}
21+
22+
export function AutocompleteSearch({
23+
value,
24+
onChange,
25+
placeholder = 'Search logs...',
26+
availableWorkflows = [],
27+
availableFolders = [],
28+
className,
29+
}: AutocompleteSearchProps) {
30+
const suggestionEngine = useMemo(() => {
31+
return new SearchSuggestions(availableWorkflows, availableFolders)
32+
}, [availableWorkflows, availableFolders])
33+
34+
const {
35+
state,
36+
inputRef,
37+
dropdownRef,
38+
handleInputChange,
39+
handleCursorChange,
40+
handleSuggestionHover,
41+
handleSuggestionSelect,
42+
handleKeyDown,
43+
handleFocus,
44+
handleBlur,
45+
} = useAutocomplete({
46+
getSuggestions: (inputValue, cursorPos) =>
47+
suggestionEngine.getSuggestions(inputValue, cursorPos),
48+
generatePreview: (suggestion, inputValue, cursorPos) =>
49+
suggestionEngine.generatePreview(suggestion, inputValue, cursorPos),
50+
onQueryChange: onChange,
51+
validateQuery: (query) => suggestionEngine.validateQuery(query),
52+
debounceMs: 100,
53+
})
54+
55+
const parsedQuery = parseQuery(value)
56+
const hasFilters = parsedQuery.filters.length > 0
57+
const hasTextSearch = parsedQuery.textSearch.length > 0
58+
59+
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
60+
const newValue = e.target.value
61+
const cursorPos = e.target.selectionStart || 0
62+
handleInputChange(newValue, cursorPos)
63+
}
64+
65+
const updateCursorPosition = (element: HTMLInputElement) => {
66+
const cursorPos = element.selectionStart || 0
67+
handleCursorChange(cursorPos)
68+
}
69+
70+
const removeFilter = (filterToRemove: (typeof parsedQuery.filters)[0]) => {
71+
const remainingFilters = parsedQuery.filters.filter(
72+
(f) => !(f.field === filterToRemove.field && f.value === filterToRemove.value)
73+
)
74+
75+
const filterStrings = remainingFilters.map(
76+
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
77+
)
78+
79+
const newQuery = [...filterStrings, parsedQuery.textSearch].filter(Boolean).join(' ')
80+
81+
onChange(newQuery)
82+
}
83+
84+
return (
85+
<div className={cn('relative', className)}>
86+
{/* Search Input */}
87+
<div
88+
className={cn(
89+
'relative flex items-center gap-2 rounded-lg border bg-background pr-2 pl-3 transition-all duration-200',
90+
'h-9 w-full min-w-[600px] max-w-[800px]',
91+
state.isOpen && 'ring-1 ring-ring'
92+
)}
93+
>
94+
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
95+
96+
{/* Text display with ghost text */}
97+
<div className='relative flex-1 font-[380] font-sans text-base leading-none'>
98+
{/* Invisible input for cursor and interactions */}
99+
<Input
100+
ref={inputRef}
101+
placeholder={state.inputValue ? '' : placeholder}
102+
value={state.inputValue}
103+
onChange={onInputChange}
104+
onFocus={handleFocus}
105+
onBlur={handleBlur}
106+
onClick={(e) => updateCursorPosition(e.currentTarget)}
107+
onKeyUp={(e) => updateCursorPosition(e.currentTarget)}
108+
onKeyDown={handleKeyDown}
109+
onSelect={(e) => updateCursorPosition(e.currentTarget)}
110+
className='relative z-10 w-full border-0 bg-transparent p-0 font-[380] font-sans text-base text-transparent leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
111+
style={{ background: 'transparent' }}
112+
/>
113+
114+
{/* Always-visible text overlay */}
115+
<div className='pointer-events-none absolute inset-0 flex items-center'>
116+
<span className='whitespace-pre font-[380] font-sans text-base leading-none'>
117+
<span className='text-foreground'>{state.inputValue}</span>
118+
{state.showPreview &&
119+
state.previewValue &&
120+
state.previewValue !== state.inputValue &&
121+
state.inputValue && (
122+
<span className='text-muted-foreground/50'>
123+
{state.previewValue.slice(state.inputValue.length)}
124+
</span>
125+
)}
126+
</span>
127+
</div>
128+
</div>
129+
130+
{/* Clear all button */}
131+
{(hasFilters || hasTextSearch) && (
132+
<Button
133+
type='button'
134+
variant='ghost'
135+
size='sm'
136+
className='h-6 w-6 p-0 hover:bg-muted/50'
137+
onClick={() => onChange('')}
138+
>
139+
<X className='h-3 w-3' />
140+
</Button>
141+
)}
142+
</div>
143+
144+
{/* Suggestions Dropdown */}
145+
{state.isOpen && state.suggestions.length > 0 && (
146+
<div
147+
ref={dropdownRef}
148+
className='absolute z-[9999] mt-1 w-full min-w-[500px] overflow-hidden rounded-md border bg-popover shadow-md'
149+
>
150+
<div className='max-h-96 overflow-y-auto py-1'>
151+
{state.suggestionType === 'filter-keys' && (
152+
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
153+
SUGGESTED FILTERS
154+
</div>
155+
)}
156+
{state.suggestionType === 'filter-values' && (
157+
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
158+
{state.suggestions[0]?.category?.toUpperCase() || 'VALUES'}
159+
</div>
160+
)}
161+
162+
{state.suggestions.map((suggestion, index) => (
163+
<button
164+
key={suggestion.id}
165+
className={cn(
166+
'w-full px-3 py-2 text-left text-sm',
167+
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
168+
'transition-colors hover:bg-accent hover:text-accent-foreground',
169+
index === state.highlightedIndex && 'bg-accent text-accent-foreground'
170+
)}
171+
onMouseEnter={() => handleSuggestionHover(index)}
172+
onMouseDown={(e) => {
173+
e.preventDefault()
174+
e.stopPropagation()
175+
handleSuggestionSelect(suggestion)
176+
}}
177+
>
178+
<div className='flex items-center justify-between'>
179+
<div className='flex-1'>
180+
<div className='font-medium text-sm'>{suggestion.label}</div>
181+
{suggestion.description && (
182+
<div className='mt-0.5 text-muted-foreground text-xs'>
183+
{suggestion.description}
184+
</div>
185+
)}
186+
</div>
187+
<div className='ml-4 font-mono text-muted-foreground text-xs'>
188+
{suggestion.value}
189+
</div>
190+
</div>
191+
</button>
192+
))}
193+
</div>
194+
</div>
195+
)}
196+
197+
{/* Active filters as chips */}
198+
{hasFilters && (
199+
<div className='mt-3 flex flex-wrap items-center gap-2'>
200+
<span className='font-medium text-muted-foreground text-xs'>ACTIVE FILTERS:</span>
201+
{parsedQuery.filters.map((filter, index) => (
202+
<Badge
203+
key={`${filter.field}-${filter.value}-${index}`}
204+
variant='secondary'
205+
className='h-6 border border-border/50 bg-muted/50 font-mono text-muted-foreground text-xs hover:bg-muted'
206+
>
207+
<span className='mr-1'>{filter.field}:</span>
208+
<span>
209+
{filter.operator !== '=' && filter.operator}
210+
{filter.originalValue}
211+
</span>
212+
<Button
213+
type='button'
214+
variant='ghost'
215+
size='sm'
216+
className='ml-1 h-3 w-3 p-0 text-muted-foreground hover:bg-muted/50 hover:text-foreground'
217+
onClick={() => removeFilter(filter)}
218+
>
219+
<X className='h-2.5 w-2.5' />
220+
</Button>
221+
</Badge>
222+
))}
223+
{parsedQuery.filters.length > 1 && (
224+
<Button
225+
type='button'
226+
variant='ghost'
227+
size='sm'
228+
className='h-6 text-muted-foreground text-xs hover:text-foreground'
229+
onClick={() => onChange(parsedQuery.textSearch)}
230+
>
231+
Clear all
232+
</Button>
233+
)}
234+
</div>
235+
)}
236+
237+
{/* Text search indicator */}
238+
{hasTextSearch && (
239+
<div className='mt-2 flex items-center gap-2'>
240+
<span className='font-medium text-muted-foreground text-xs'>TEXT SEARCH:</span>
241+
<Badge variant='outline' className='text-xs'>
242+
"{parsedQuery.textSearch}"
243+
</Badge>
244+
</div>
245+
)}
246+
</div>
247+
)
248+
}

0 commit comments

Comments
 (0)