mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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 abfa2f8d51.
* cleanup
* cleanup
This commit is contained in:
@@ -19,6 +19,8 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
|
||||
const logger = createLogger('LogsFolderFilter')
|
||||
|
||||
interface FolderOption {
|
||||
id: string
|
||||
name: string
|
||||
@@ -34,7 +36,6 @@ export default function FolderFilter() {
|
||||
const [folders, setFolders] = useState<FolderOption[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const logger = useMemo(() => createLogger('LogsFolderFilter'), [])
|
||||
|
||||
// Fetch all available folders from the API
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
|
||||
const logger = createLogger('LogsWorkflowFilter')
|
||||
|
||||
interface WorkflowOption {
|
||||
id: string
|
||||
name: string
|
||||
@@ -28,7 +30,6 @@ export default function Workflow() {
|
||||
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const logger = useMemo(() => createLogger('LogsWorkflowFilter'), [])
|
||||
|
||||
// Fetch all available workflows from the API
|
||||
useEffect(() => {
|
||||
@@ -55,7 +56,6 @@ export default function Workflow() {
|
||||
fetchWorkflows()
|
||||
}, [])
|
||||
|
||||
// Get display text for the dropdown button
|
||||
const getSelectedWorkflowsText = () => {
|
||||
if (workflowIds.length === 0) return 'All workflows'
|
||||
if (workflowIds.length === 1) {
|
||||
@@ -65,12 +65,10 @@ export default function Workflow() {
|
||||
return `${workflowIds.length} workflows selected`
|
||||
}
|
||||
|
||||
// Check if a workflow is selected
|
||||
const isWorkflowSelected = (workflowId: string) => {
|
||||
return workflowIds.includes(workflowId)
|
||||
}
|
||||
|
||||
// Clear all selections
|
||||
const clearSelections = () => {
|
||||
setWorkflowIds([])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { parseQuery } from '@/lib/logs/query-parser'
|
||||
import { SearchSuggestions } from '@/lib/logs/search-suggestions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAutocomplete } from '@/app/workspace/[workspaceId]/logs/hooks/use-autocomplete'
|
||||
|
||||
interface AutocompleteSearchProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
availableWorkflows?: string[]
|
||||
availableFolders?: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AutocompleteSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search logs...',
|
||||
availableWorkflows = [],
|
||||
availableFolders = [],
|
||||
className,
|
||||
}: AutocompleteSearchProps) {
|
||||
const suggestionEngine = useMemo(() => {
|
||||
return new SearchSuggestions(availableWorkflows, availableFolders)
|
||||
}, [availableWorkflows, availableFolders])
|
||||
|
||||
const {
|
||||
state,
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
handleInputChange,
|
||||
handleCursorChange,
|
||||
handleSuggestionHover,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
} = useAutocomplete({
|
||||
getSuggestions: (inputValue, cursorPos) =>
|
||||
suggestionEngine.getSuggestions(inputValue, cursorPos),
|
||||
generatePreview: (suggestion, inputValue, cursorPos) =>
|
||||
suggestionEngine.generatePreview(suggestion, inputValue, cursorPos),
|
||||
onQueryChange: onChange,
|
||||
validateQuery: (query) => suggestionEngine.validateQuery(query),
|
||||
debounceMs: 100,
|
||||
})
|
||||
|
||||
const parsedQuery = parseQuery(value)
|
||||
const hasFilters = parsedQuery.filters.length > 0
|
||||
const hasTextSearch = parsedQuery.textSearch.length > 0
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPos = e.target.selectionStart || 0
|
||||
handleInputChange(newValue, cursorPos)
|
||||
}
|
||||
|
||||
const updateCursorPosition = (element: HTMLInputElement) => {
|
||||
const cursorPos = element.selectionStart || 0
|
||||
handleCursorChange(cursorPos)
|
||||
}
|
||||
|
||||
const removeFilter = (filterToRemove: (typeof parsedQuery.filters)[0]) => {
|
||||
const remainingFilters = parsedQuery.filters.filter(
|
||||
(f) => !(f.field === filterToRemove.field && f.value === filterToRemove.value)
|
||||
)
|
||||
|
||||
const filterStrings = remainingFilters.map(
|
||||
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
|
||||
)
|
||||
|
||||
const newQuery = [...filterStrings, parsedQuery.textSearch].filter(Boolean).join(' ')
|
||||
|
||||
onChange(newQuery)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{/* Search Input */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 rounded-lg border bg-background pr-2 pl-3 transition-all duration-200',
|
||||
'h-9 w-full min-w-[600px] max-w-[800px]',
|
||||
state.isOpen && 'ring-1 ring-ring'
|
||||
)}
|
||||
>
|
||||
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
|
||||
|
||||
{/* Text display with ghost text */}
|
||||
<div className='relative flex-1 font-[380] font-sans text-base leading-none'>
|
||||
{/* Invisible input for cursor and interactions */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={state.inputValue ? '' : placeholder}
|
||||
value={state.inputValue}
|
||||
onChange={onInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={(e) => updateCursorPosition(e.currentTarget)}
|
||||
onKeyUp={(e) => updateCursorPosition(e.currentTarget)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelect={(e) => updateCursorPosition(e.currentTarget)}
|
||||
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'
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
|
||||
{/* Always-visible text overlay */}
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center'>
|
||||
<span className='whitespace-pre font-[380] font-sans text-base leading-none'>
|
||||
<span className='text-foreground'>{state.inputValue}</span>
|
||||
{state.showPreview &&
|
||||
state.previewValue &&
|
||||
state.previewValue !== state.inputValue &&
|
||||
state.inputValue && (
|
||||
<span className='text-muted-foreground/50'>
|
||||
{state.previewValue.slice(state.inputValue.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear all button */}
|
||||
{(hasFilters || hasTextSearch) && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-muted/50'
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{state.isOpen && state.suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className='absolute z-[9999] mt-1 w-full min-w-[500px] overflow-hidden rounded-md border bg-popover shadow-md'
|
||||
>
|
||||
<div className='max-h-96 overflow-y-auto py-1'>
|
||||
{state.suggestionType === 'filter-keys' && (
|
||||
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
|
||||
SUGGESTED FILTERS
|
||||
</div>
|
||||
)}
|
||||
{state.suggestionType === 'filter-values' && (
|
||||
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
|
||||
{state.suggestions[0]?.category?.toUpperCase() || 'VALUES'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm',
|
||||
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
|
||||
'transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
index === state.highlightedIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onMouseEnter={() => handleSuggestionHover(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSuggestionSelect(suggestion)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium text-sm'>{suggestion.label}</div>
|
||||
{suggestion.description && (
|
||||
<div className='mt-0.5 text-muted-foreground text-xs'>
|
||||
{suggestion.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='ml-4 font-mono text-muted-foreground text-xs'>
|
||||
{suggestion.value}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filters as chips */}
|
||||
{hasFilters && (
|
||||
<div className='mt-3 flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium text-muted-foreground text-xs'>ACTIVE FILTERS:</span>
|
||||
{parsedQuery.filters.map((filter, index) => (
|
||||
<Badge
|
||||
key={`${filter.field}-${filter.value}-${index}`}
|
||||
variant='secondary'
|
||||
className='h-6 border border-border/50 bg-muted/50 font-mono text-muted-foreground text-xs hover:bg-muted'
|
||||
>
|
||||
<span className='mr-1'>{filter.field}:</span>
|
||||
<span>
|
||||
{filter.operator !== '=' && filter.operator}
|
||||
{filter.originalValue}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='ml-1 h-3 w-3 p-0 text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||
onClick={() => removeFilter(filter)}
|
||||
>
|
||||
<X className='h-2.5 w-2.5' />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
{parsedQuery.filters.length > 1 && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 text-muted-foreground text-xs hover:text-foreground'
|
||||
onClick={() => onChange(parsedQuery.textSearch)}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text search indicator */}
|
||||
{hasTextSearch && (
|
||||
<div className='mt-2 flex items-center gap-2'>
|
||||
<span className='font-medium text-muted-foreground text-xs'>TEXT SEARCH:</span>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
"{parsedQuery.textSearch}"
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useCallback, useMemo, useReducer, useRef } from 'react'
|
||||
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface SuggestionGroup {
|
||||
type: 'filter-keys' | 'filter-values'
|
||||
filterKey?: string
|
||||
suggestions: Suggestion[]
|
||||
}
|
||||
|
||||
interface AutocompleteState {
|
||||
// Input state
|
||||
inputValue: string
|
||||
cursorPosition: number
|
||||
|
||||
// Dropdown state
|
||||
isOpen: boolean
|
||||
suggestions: Suggestion[]
|
||||
suggestionType: 'filter-keys' | 'filter-values' | null
|
||||
highlightedIndex: number
|
||||
|
||||
// Preview state
|
||||
previewValue: string
|
||||
showPreview: boolean
|
||||
|
||||
// Query state
|
||||
isValidQuery: boolean
|
||||
pendingQuery: string | null
|
||||
}
|
||||
|
||||
type AutocompleteAction =
|
||||
| { type: 'SET_INPUT_VALUE'; payload: { value: string; cursorPosition: number } }
|
||||
| { type: 'SET_CURSOR_POSITION'; payload: number }
|
||||
| { type: 'OPEN_DROPDOWN'; payload: SuggestionGroup }
|
||||
| { type: 'CLOSE_DROPDOWN' }
|
||||
| { type: 'HIGHLIGHT_SUGGESTION'; payload: { index: number; preview?: string } }
|
||||
| { type: 'SET_PREVIEW'; payload: { value: string; show: boolean } }
|
||||
| { type: 'CLEAR_PREVIEW' }
|
||||
| { type: 'SET_QUERY_VALIDITY'; payload: boolean }
|
||||
| { type: 'RESET' }
|
||||
|
||||
const initialState: AutocompleteState = {
|
||||
inputValue: '',
|
||||
cursorPosition: 0,
|
||||
isOpen: false,
|
||||
suggestions: [],
|
||||
suggestionType: null,
|
||||
highlightedIndex: -1,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
isValidQuery: true,
|
||||
pendingQuery: null,
|
||||
}
|
||||
|
||||
function autocompleteReducer(
|
||||
state: AutocompleteState,
|
||||
action: AutocompleteAction
|
||||
): AutocompleteState {
|
||||
switch (action.type) {
|
||||
case 'SET_INPUT_VALUE':
|
||||
return {
|
||||
...state,
|
||||
inputValue: action.payload.value,
|
||||
cursorPosition: action.payload.cursorPosition,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
}
|
||||
|
||||
case 'SET_CURSOR_POSITION':
|
||||
return {
|
||||
...state,
|
||||
cursorPosition: action.payload,
|
||||
}
|
||||
|
||||
case 'OPEN_DROPDOWN':
|
||||
return {
|
||||
...state,
|
||||
isOpen: true,
|
||||
suggestions: action.payload.suggestions,
|
||||
suggestionType: action.payload.type,
|
||||
highlightedIndex: action.payload.suggestions.length > 0 ? 0 : -1,
|
||||
}
|
||||
|
||||
case 'CLOSE_DROPDOWN':
|
||||
return {
|
||||
...state,
|
||||
isOpen: false,
|
||||
suggestions: [],
|
||||
suggestionType: null,
|
||||
highlightedIndex: -1,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
}
|
||||
|
||||
case 'HIGHLIGHT_SUGGESTION':
|
||||
return {
|
||||
...state,
|
||||
highlightedIndex: action.payload.index,
|
||||
previewValue: action.payload.preview || '',
|
||||
showPreview: !!action.payload.preview,
|
||||
}
|
||||
|
||||
case 'SET_PREVIEW':
|
||||
return {
|
||||
...state,
|
||||
previewValue: action.payload.value,
|
||||
showPreview: action.payload.show,
|
||||
}
|
||||
|
||||
case 'CLEAR_PREVIEW':
|
||||
return {
|
||||
...state,
|
||||
previewValue: '',
|
||||
showPreview: false,
|
||||
}
|
||||
|
||||
case 'SET_QUERY_VALIDITY':
|
||||
return {
|
||||
...state,
|
||||
isValidQuery: action.payload,
|
||||
}
|
||||
|
||||
case 'RESET':
|
||||
return initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export interface AutocompleteOptions {
|
||||
getSuggestions: (value: string, cursorPosition: number) => SuggestionGroup | null
|
||||
generatePreview: (suggestion: Suggestion, currentValue: string, cursorPosition: number) => string
|
||||
onQueryChange: (query: string) => void
|
||||
validateQuery?: (query: string) => boolean
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
export function useAutocomplete({
|
||||
getSuggestions,
|
||||
generatePreview,
|
||||
onQueryChange,
|
||||
validateQuery,
|
||||
debounceMs = 150,
|
||||
}: AutocompleteOptions) {
|
||||
const [state, dispatch] = useReducer(autocompleteReducer, initialState)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const currentSuggestion = useMemo(() => {
|
||||
if (state.highlightedIndex >= 0 && state.suggestions[state.highlightedIndex]) {
|
||||
return state.suggestions[state.highlightedIndex]
|
||||
}
|
||||
return null
|
||||
}, [state.highlightedIndex, state.suggestions])
|
||||
|
||||
const updateSuggestions = useCallback(() => {
|
||||
const suggestionGroup = getSuggestions(state.inputValue, state.cursorPosition)
|
||||
|
||||
if (suggestionGroup && suggestionGroup.suggestions.length > 0) {
|
||||
dispatch({ type: 'OPEN_DROPDOWN', payload: suggestionGroup })
|
||||
|
||||
const firstSuggestion = suggestionGroup.suggestions[0]
|
||||
const preview = generatePreview(firstSuggestion, state.inputValue, state.cursorPosition)
|
||||
dispatch({
|
||||
type: 'HIGHLIGHT_SUGGESTION',
|
||||
payload: { index: 0, preview },
|
||||
})
|
||||
} else {
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
}
|
||||
}, [state.inputValue, state.cursorPosition, getSuggestions, generatePreview])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(value: string, cursorPosition: number) => {
|
||||
dispatch({ type: 'SET_INPUT_VALUE', payload: { value, cursorPosition } })
|
||||
|
||||
const isValid = validateQuery ? validateQuery(value) : true
|
||||
dispatch({ type: 'SET_QUERY_VALIDITY', payload: isValid })
|
||||
|
||||
if (isValid) {
|
||||
onQueryChange(value)
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(updateSuggestions, debounceMs)
|
||||
},
|
||||
[updateSuggestions, onQueryChange, validateQuery, debounceMs]
|
||||
)
|
||||
|
||||
const handleCursorChange = useCallback(
|
||||
(position: number) => {
|
||||
dispatch({ type: 'SET_CURSOR_POSITION', payload: position })
|
||||
updateSuggestions()
|
||||
},
|
||||
[updateSuggestions]
|
||||
)
|
||||
|
||||
const handleSuggestionHover = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0 && index < state.suggestions.length) {
|
||||
const suggestion = state.suggestions[index]
|
||||
const preview = generatePreview(suggestion, state.inputValue, state.cursorPosition)
|
||||
dispatch({
|
||||
type: 'HIGHLIGHT_SUGGESTION',
|
||||
payload: { index, preview },
|
||||
})
|
||||
}
|
||||
},
|
||||
[state.suggestions, state.inputValue, state.cursorPosition, generatePreview]
|
||||
)
|
||||
|
||||
const handleSuggestionSelect = useCallback(
|
||||
(suggestion?: Suggestion) => {
|
||||
const selectedSuggestion = suggestion || currentSuggestion
|
||||
if (!selectedSuggestion) return
|
||||
|
||||
let newValue = generatePreview(selectedSuggestion, state.inputValue, state.cursorPosition)
|
||||
|
||||
let newCursorPosition = newValue.length
|
||||
|
||||
if (state.suggestionType === 'filter-keys' && selectedSuggestion.value.endsWith(':')) {
|
||||
newCursorPosition = newValue.lastIndexOf(':') + 1
|
||||
} else if (state.suggestionType === 'filter-values') {
|
||||
newValue = `${newValue} `
|
||||
newCursorPosition = newValue.length
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SET_INPUT_VALUE',
|
||||
payload: { value: newValue, cursorPosition: newCursorPosition },
|
||||
})
|
||||
|
||||
const isValid = validateQuery ? validateQuery(newValue.trim()) : true
|
||||
dispatch({ type: 'SET_QUERY_VALIDITY', payload: isValid })
|
||||
|
||||
if (isValid) {
|
||||
onQueryChange(newValue.trim())
|
||||
}
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
requestAnimationFrame(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setTimeout(updateSuggestions, 0)
|
||||
},
|
||||
[
|
||||
currentSuggestion,
|
||||
state.inputValue,
|
||||
state.cursorPosition,
|
||||
state.suggestionType,
|
||||
generatePreview,
|
||||
onQueryChange,
|
||||
validateQuery,
|
||||
updateSuggestions,
|
||||
]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (!state.isOpen) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault()
|
||||
const nextIndex = Math.min(state.highlightedIndex + 1, state.suggestions.length - 1)
|
||||
handleSuggestionHover(nextIndex)
|
||||
break
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault()
|
||||
const prevIndex = Math.max(state.highlightedIndex - 1, 0)
|
||||
handleSuggestionHover(prevIndex)
|
||||
break
|
||||
}
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
handleSuggestionSelect()
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
break
|
||||
|
||||
case 'Tab':
|
||||
if (currentSuggestion) {
|
||||
event.preventDefault()
|
||||
handleSuggestionSelect()
|
||||
} else {
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[
|
||||
state.isOpen,
|
||||
state.highlightedIndex,
|
||||
state.suggestions.length,
|
||||
handleSuggestionHover,
|
||||
handleSuggestionSelect,
|
||||
currentSuggestion,
|
||||
]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
updateSuggestions()
|
||||
}, [updateSuggestions])
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
state,
|
||||
currentSuggestion,
|
||||
|
||||
// Refs
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
|
||||
// Handlers
|
||||
handleInputChange,
|
||||
handleCursorChange,
|
||||
handleSuggestionHover,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
|
||||
// Actions
|
||||
closeDropdown: () => dispatch({ type: 'CLOSE_DROPDOWN' }),
|
||||
clearPreview: () => dispatch({ type: 'CLEAR_PREVIEW' }),
|
||||
reset: () => dispatch({ type: 'RESET' }),
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AlertCircle, Info, Loader2, Play, RefreshCw, Search, Square } from 'lucide-react'
|
||||
import { AlertCircle, Info, Loader2, Play, RefreshCw, Square } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/components/search/search'
|
||||
import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
@@ -17,7 +18,6 @@ import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types'
|
||||
const logger = createLogger('Logs')
|
||||
const LOGS_PER_PAGE = 50
|
||||
|
||||
// Get color for different trigger types using app's color scheme
|
||||
const getTriggerColor = (trigger: string | null | undefined): string => {
|
||||
if (!trigger) return '#9ca3af'
|
||||
|
||||
@@ -98,6 +98,10 @@ export default function Logs() {
|
||||
const [searchQuery, setSearchQuery] = useState(storeSearchQuery)
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
// Available data for suggestions
|
||||
const [availableWorkflows, setAvailableWorkflows] = useState<string[]>([])
|
||||
const [availableFolders, setAvailableFolders] = useState<string[]>([])
|
||||
|
||||
// Live and refresh state
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
@@ -108,6 +112,22 @@ export default function Logs() {
|
||||
setSearchQuery(storeSearchQuery)
|
||||
}, [storeSearchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const workflowNames = new Set<string>()
|
||||
const folderNames = new Set<string>()
|
||||
|
||||
logs.forEach((log) => {
|
||||
if (log.workflow?.name) {
|
||||
workflowNames.add(log.workflow.name)
|
||||
}
|
||||
// Note: folder info would need to be added to the logs response
|
||||
// For now, we'll leave folders empty
|
||||
})
|
||||
|
||||
setAvailableWorkflows(Array.from(workflowNames).slice(0, 10)) // Limit to top 10
|
||||
setAvailableFolders([]) // TODO: Add folder data to logs response
|
||||
}, [logs])
|
||||
|
||||
// Update store when debounced search query changes
|
||||
useEffect(() => {
|
||||
if (isInitialized.current && debouncedSearchQuery !== storeSearchQuery) {
|
||||
@@ -323,7 +343,27 @@ export default function Logs() {
|
||||
// Get fresh query params by calling buildQueryParams from store
|
||||
const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState()
|
||||
const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE)
|
||||
const response = await fetch(`/api/logs?${queryParams}&details=basic`)
|
||||
|
||||
// Parse the current search query for enhanced filtering
|
||||
const parsedQuery = parseQuery(searchQuery)
|
||||
const enhancedParams = queryToApiParams(parsedQuery)
|
||||
|
||||
// Add enhanced search parameters to the query string
|
||||
const allParams = new URLSearchParams(queryParams)
|
||||
Object.entries(enhancedParams).forEach(([key, value]) => {
|
||||
if (key === 'triggers' && allParams.has('triggers')) {
|
||||
// Combine triggers from both sources
|
||||
const existingTriggers = allParams.get('triggers')?.split(',') || []
|
||||
const searchTriggers = value.split(',')
|
||||
const combined = [...new Set([...existingTriggers, ...searchTriggers])]
|
||||
allParams.set('triggers', combined.join(','))
|
||||
} else {
|
||||
allParams.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
allParams.set('details', 'basic')
|
||||
const response = await fetch(`/api/logs?${allParams.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching logs: ${response.statusText}`)
|
||||
@@ -430,12 +470,28 @@ export default function Logs() {
|
||||
params.set('offset', '0') // Always start from page 1
|
||||
params.set('workspaceId', workspaceId)
|
||||
|
||||
// Add filters
|
||||
// Parse the search query for enhanced filtering
|
||||
const parsedQuery = parseQuery(searchQuery)
|
||||
const enhancedParams = queryToApiParams(parsedQuery)
|
||||
|
||||
// Add filters from store
|
||||
if (level !== 'all') params.set('level', level)
|
||||
if (triggers.length > 0) params.set('triggers', triggers.join(','))
|
||||
if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(','))
|
||||
if (folderIds.length > 0) params.set('folderIds', folderIds.join(','))
|
||||
if (searchQuery.trim()) params.set('search', searchQuery.trim())
|
||||
|
||||
// Add enhanced search parameters (these may override some store filters)
|
||||
Object.entries(enhancedParams).forEach(([key, value]) => {
|
||||
if (key === 'triggers' && params.has('triggers')) {
|
||||
// Combine triggers from both sources
|
||||
const storeTriggers = params.get('triggers')?.split(',') || []
|
||||
const searchTriggers = value.split(',')
|
||||
const combined = [...new Set([...storeTriggers, ...searchTriggers])]
|
||||
params.set('triggers', combined.join(','))
|
||||
} else {
|
||||
params.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
// Add time range filter
|
||||
if (timeRange !== 'All time') {
|
||||
@@ -588,16 +644,14 @@ export default function Logs() {
|
||||
</div>
|
||||
|
||||
{/* Search and Controls */}
|
||||
<div className='mb-8 flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center'>
|
||||
<div className='flex h-9 w-full min-w-[200px] max-w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
|
||||
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
|
||||
<Input
|
||||
placeholder='Search logs...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-8 flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-start'>
|
||||
<AutocompleteSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder='Search logs...'
|
||||
availableWorkflows={availableWorkflows}
|
||||
availableFolders={availableFolders}
|
||||
/>
|
||||
|
||||
<div className='flex flex-shrink-0 items-center gap-3'>
|
||||
<Tooltip>
|
||||
|
||||
208
apps/sim/lib/logs/query-parser.ts
Normal file
208
apps/sim/lib/logs/query-parser.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Query language parser for logs search
|
||||
*
|
||||
* Supports syntax like:
|
||||
* level:error workflow:"my-workflow" trigger:api cost:>0.005 date:today
|
||||
*/
|
||||
|
||||
export interface ParsedFilter {
|
||||
field: string
|
||||
operator: '=' | '>' | '<' | '>=' | '<=' | '!='
|
||||
value: string | number | boolean
|
||||
originalValue: string
|
||||
}
|
||||
|
||||
export interface ParsedQuery {
|
||||
filters: ParsedFilter[]
|
||||
textSearch: string // Any remaining text not in field:value format
|
||||
}
|
||||
|
||||
const FILTER_FIELDS = {
|
||||
level: 'string',
|
||||
status: 'string', // alias for level
|
||||
workflow: 'string',
|
||||
trigger: 'string',
|
||||
execution: 'string',
|
||||
id: 'string',
|
||||
cost: 'number',
|
||||
duration: 'number',
|
||||
date: 'date',
|
||||
folder: 'string',
|
||||
} as const
|
||||
|
||||
type FilterField = keyof typeof FILTER_FIELDS
|
||||
|
||||
/**
|
||||
* Parse a search query string into structured filters and text search
|
||||
*/
|
||||
export function parseQuery(query: string): ParsedQuery {
|
||||
const filters: ParsedFilter[] = []
|
||||
const tokens: string[] = []
|
||||
|
||||
const filterRegex = /(\w+):((?:[><!]=?|=)?(?:"[^"]*"|[^\s]+))/g
|
||||
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = filterRegex.exec(query)) !== null) {
|
||||
const [fullMatch, field, valueWithOperator] = match
|
||||
|
||||
const beforeText = query.slice(lastIndex, match.index).trim()
|
||||
if (beforeText) {
|
||||
tokens.push(beforeText)
|
||||
}
|
||||
|
||||
const parsedFilter = parseFilter(field, valueWithOperator)
|
||||
if (parsedFilter) {
|
||||
filters.push(parsedFilter)
|
||||
} else {
|
||||
tokens.push(fullMatch)
|
||||
}
|
||||
|
||||
lastIndex = match.index + fullMatch.length
|
||||
}
|
||||
|
||||
const remainingText = query.slice(lastIndex).trim()
|
||||
if (remainingText) {
|
||||
tokens.push(remainingText)
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
textSearch: tokens.join(' ').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single field:value filter
|
||||
*/
|
||||
function parseFilter(field: string, valueWithOperator: string): ParsedFilter | null {
|
||||
if (!(field in FILTER_FIELDS)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filterField = field as FilterField
|
||||
const fieldType = FILTER_FIELDS[filterField]
|
||||
|
||||
let operator: ParsedFilter['operator'] = '='
|
||||
let value = valueWithOperator
|
||||
|
||||
if (value.startsWith('>=')) {
|
||||
operator = '>='
|
||||
value = value.slice(2)
|
||||
} else if (value.startsWith('<=')) {
|
||||
operator = '<='
|
||||
value = value.slice(2)
|
||||
} else if (value.startsWith('!=')) {
|
||||
operator = '!='
|
||||
value = value.slice(2)
|
||||
} else if (value.startsWith('>')) {
|
||||
operator = '>'
|
||||
value = value.slice(1)
|
||||
} else if (value.startsWith('<')) {
|
||||
operator = '<'
|
||||
value = value.slice(1)
|
||||
} else if (value.startsWith('=')) {
|
||||
operator = '='
|
||||
value = value.slice(1)
|
||||
}
|
||||
|
||||
const originalValue = value
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
let parsedValue: string | number | boolean = value
|
||||
|
||||
if (fieldType === 'number') {
|
||||
if (field === 'duration' && value.endsWith('ms')) {
|
||||
parsedValue = Number.parseFloat(value.slice(0, -2))
|
||||
} else if (field === 'duration' && value.endsWith('s')) {
|
||||
parsedValue = Number.parseFloat(value.slice(0, -1)) * 1000 // Convert to ms
|
||||
} else {
|
||||
parsedValue = Number.parseFloat(value)
|
||||
}
|
||||
|
||||
if (Number.isNaN(parsedValue)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
field: filterField,
|
||||
operator,
|
||||
value: parsedValue,
|
||||
originalValue,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed query back to URL parameters for the logs API
|
||||
*/
|
||||
export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, string> {
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
if (parsedQuery.textSearch) {
|
||||
params.search = parsedQuery.textSearch
|
||||
}
|
||||
|
||||
for (const filter of parsedQuery.filters) {
|
||||
switch (filter.field) {
|
||||
case 'level':
|
||||
case 'status':
|
||||
if (filter.operator === '=') {
|
||||
params.level = filter.value as string
|
||||
}
|
||||
break
|
||||
|
||||
case 'trigger':
|
||||
if (filter.operator === '=') {
|
||||
const existing = params.triggers ? params.triggers.split(',') : []
|
||||
existing.push(filter.value as string)
|
||||
params.triggers = existing.join(',')
|
||||
}
|
||||
break
|
||||
|
||||
case 'workflow':
|
||||
if (filter.operator === '=') {
|
||||
params.workflowName = filter.value as string
|
||||
}
|
||||
break
|
||||
|
||||
case 'execution':
|
||||
if (filter.operator === '=' && parsedQuery.textSearch) {
|
||||
params.search = `${parsedQuery.textSearch} ${filter.value}`.trim()
|
||||
} else if (filter.operator === '=') {
|
||||
params.search = filter.value as string
|
||||
}
|
||||
break
|
||||
|
||||
case 'date':
|
||||
if (filter.operator === '=' && filter.value === 'today') {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
params.startDate = today.toISOString()
|
||||
} else if (filter.operator === '=' && filter.value === 'yesterday') {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
yesterday.setHours(0, 0, 0, 0)
|
||||
params.startDate = yesterday.toISOString()
|
||||
|
||||
const endOfYesterday = new Date(yesterday)
|
||||
endOfYesterday.setHours(23, 59, 59, 999)
|
||||
params.endDate = endOfYesterday.toISOString()
|
||||
}
|
||||
break
|
||||
|
||||
case 'cost':
|
||||
params[`cost_${filter.operator}_${filter.value}`] = 'true'
|
||||
break
|
||||
|
||||
case 'duration':
|
||||
params[`duration_${filter.operator}_${filter.value}`] = 'true'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
157
apps/sim/lib/logs/search-suggestions.test.ts
Normal file
157
apps/sim/lib/logs/search-suggestions.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SearchSuggestions } from './search-suggestions'
|
||||
|
||||
describe('SearchSuggestions', () => {
|
||||
const engine = new SearchSuggestions(['workflow1', 'workflow2'], ['folder1', 'folder2'])
|
||||
|
||||
describe('validateQuery', () => {
|
||||
it.concurrent('should return false for incomplete filter expressions', () => {
|
||||
expect(engine.validateQuery('level:')).toBe(false)
|
||||
expect(engine.validateQuery('trigger:')).toBe(false)
|
||||
expect(engine.validateQuery('cost:')).toBe(false)
|
||||
expect(engine.validateQuery('some text level:')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for incomplete quoted strings', () => {
|
||||
expect(engine.validateQuery('workflow:"incomplete')).toBe(false)
|
||||
expect(engine.validateQuery('level:error workflow:"incomplete')).toBe(false)
|
||||
expect(engine.validateQuery('"incomplete string')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for complete queries', () => {
|
||||
expect(engine.validateQuery('level:error')).toBe(true)
|
||||
expect(engine.validateQuery('trigger:api')).toBe(true)
|
||||
expect(engine.validateQuery('cost:>0.01')).toBe(true)
|
||||
expect(engine.validateQuery('workflow:"test workflow"')).toBe(true)
|
||||
expect(engine.validateQuery('level:error trigger:api')).toBe(true)
|
||||
expect(engine.validateQuery('some search text')).toBe(true)
|
||||
expect(engine.validateQuery('')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return true for mixed complete queries', () => {
|
||||
expect(engine.validateQuery('search text level:error')).toBe(true)
|
||||
expect(engine.validateQuery('level:error some search')).toBe(true)
|
||||
expect(engine.validateQuery('workflow:"test" level:error search')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions', () => {
|
||||
it.concurrent('should return filter key suggestions at the beginning', () => {
|
||||
const result = engine.getSuggestions('', 0)
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return filter key suggestions for partial matches', () => {
|
||||
const result = engine.getSuggestions('lev', 3)
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return filter value suggestions after colon', () => {
|
||||
const result = engine.getSuggestions('level:', 6)
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
expect(result?.suggestions.some((s) => s.value === 'error')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return filtered value suggestions for partial values', () => {
|
||||
const result = engine.getSuggestions('level:err', 9)
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.value === 'error')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle workflow suggestions', () => {
|
||||
const result = engine.getSuggestions('workflow:', 9)
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.label === 'workflow1')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return null for text search context', () => {
|
||||
const result = engine.getSuggestions('some random text', 10)
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it.concurrent('should show filter key suggestions after completing a filter', () => {
|
||||
const result = engine.getSuggestions('level:error ', 12)
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should show filter key suggestions after multiple completed filters', () => {
|
||||
const result = engine.getSuggestions('level:error trigger:api ', 24)
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle partial filter keys after existing filters', () => {
|
||||
const result = engine.getSuggestions('level:error lev', 15)
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle filter values after existing filters', () => {
|
||||
const result = engine.getSuggestions('level:error level:', 18)
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.value === 'info')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generatePreview', () => {
|
||||
it.concurrent('should generate correct preview for filter keys', () => {
|
||||
const suggestion = { id: 'test', value: 'level:', label: 'Status', category: 'filters' }
|
||||
const preview = engine.generatePreview(suggestion, '', 0)
|
||||
expect(preview).toBe('level:')
|
||||
})
|
||||
|
||||
it.concurrent('should generate correct preview for filter values', () => {
|
||||
const suggestion = { id: 'test', value: 'error', label: 'Error', category: 'level' }
|
||||
const preview = engine.generatePreview(suggestion, 'level:', 6)
|
||||
expect(preview).toBe('level:error')
|
||||
})
|
||||
|
||||
it.concurrent('should handle partial replacements correctly', () => {
|
||||
const suggestion = { id: 'test', value: 'level:', label: 'Status', category: 'filters' }
|
||||
const preview = engine.generatePreview(suggestion, 'lev', 3)
|
||||
expect(preview).toBe('level:')
|
||||
})
|
||||
|
||||
it.concurrent('should handle quoted workflow values', () => {
|
||||
const suggestion = {
|
||||
id: 'test',
|
||||
value: '"workflow1"',
|
||||
label: 'workflow1',
|
||||
category: 'workflow',
|
||||
}
|
||||
const preview = engine.generatePreview(suggestion, 'workflow:', 9)
|
||||
expect(preview).toBe('workflow:"workflow1"')
|
||||
})
|
||||
|
||||
it.concurrent('should add space when adding filter after completed filter', () => {
|
||||
const suggestion = { id: 'test', value: 'trigger:', label: 'Trigger', category: 'filters' }
|
||||
const preview = engine.generatePreview(suggestion, 'level:error ', 12)
|
||||
expect(preview).toBe('level:error trigger:')
|
||||
})
|
||||
|
||||
it.concurrent('should handle multiple completed filters', () => {
|
||||
const suggestion = { id: 'test', value: 'cost:', label: 'Cost', category: 'filters' }
|
||||
const preview = engine.generatePreview(suggestion, 'level:error trigger:api ', 24)
|
||||
expect(preview).toBe('level:error trigger:api cost:')
|
||||
})
|
||||
|
||||
it.concurrent('should handle adding same filter type multiple times', () => {
|
||||
const suggestion = { id: 'test', value: 'level:', label: 'Status', category: 'filters' }
|
||||
const preview = engine.generatePreview(suggestion, 'level:error ', 12)
|
||||
expect(preview).toBe('level:error level:')
|
||||
})
|
||||
|
||||
it.concurrent('should handle filter value after existing filters', () => {
|
||||
const suggestion = { id: 'test', value: 'info', label: 'Info', category: 'level' }
|
||||
const preview = engine.generatePreview(suggestion, 'level:error level:', 19)
|
||||
expect(preview).toBe('level:error level:info')
|
||||
})
|
||||
})
|
||||
})
|
||||
420
apps/sim/lib/logs/search-suggestions.ts
Normal file
420
apps/sim/lib/logs/search-suggestions.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import type {
|
||||
Suggestion,
|
||||
SuggestionGroup,
|
||||
} from '@/app/workspace/[workspaceId]/logs/hooks/use-autocomplete'
|
||||
|
||||
export interface FilterDefinition {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
options: Array<{
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const FILTER_DEFINITIONS: FilterDefinition[] = [
|
||||
{
|
||||
key: 'level',
|
||||
label: 'Status',
|
||||
description: 'Filter by log level',
|
||||
options: [
|
||||
{ value: 'error', label: 'Error', description: 'Error logs only' },
|
||||
{ value: 'info', label: 'Info', description: 'Info logs only' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'trigger',
|
||||
label: 'Trigger',
|
||||
description: 'Filter by trigger type',
|
||||
options: [
|
||||
{ value: 'api', label: 'API', description: 'API-triggered executions' },
|
||||
{ value: 'manual', label: 'Manual', description: 'Manually triggered executions' },
|
||||
{ value: 'webhook', label: 'Webhook', description: 'Webhook-triggered executions' },
|
||||
{ value: 'chat', label: 'Chat', description: 'Chat-triggered executions' },
|
||||
{ value: 'schedule', label: 'Schedule', description: 'Scheduled executions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
label: 'Cost',
|
||||
description: 'Filter by execution cost',
|
||||
options: [
|
||||
{ value: '>0.01', label: 'Over $0.01', description: 'Executions costing more than $0.01' },
|
||||
{
|
||||
value: '<0.005',
|
||||
label: 'Under $0.005',
|
||||
description: 'Executions costing less than $0.005',
|
||||
},
|
||||
{ value: '>0.05', label: 'Over $0.05', description: 'Executions costing more than $0.05' },
|
||||
{ value: '=0', label: 'Free', description: 'Free executions' },
|
||||
{ value: '>0', label: 'Paid', description: 'Executions with cost' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: 'Date',
|
||||
description: 'Filter by date range',
|
||||
options: [
|
||||
{ value: 'today', label: 'Today', description: "Today's logs" },
|
||||
{ value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" },
|
||||
{ value: 'this-week', label: 'This week', description: "This week's logs" },
|
||||
{ value: 'last-week', label: 'Last week', description: "Last week's logs" },
|
||||
{ value: 'this-month', label: 'This month', description: "This month's logs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
label: 'Duration',
|
||||
description: 'Filter by execution duration',
|
||||
options: [
|
||||
{ value: '>5s', label: 'Over 5s', description: 'Executions longer than 5 seconds' },
|
||||
{ value: '<1s', label: 'Under 1s', description: 'Executions shorter than 1 second' },
|
||||
{ value: '>10s', label: 'Over 10s', description: 'Executions longer than 10 seconds' },
|
||||
{ value: '>30s', label: 'Over 30s', description: 'Executions longer than 30 seconds' },
|
||||
{ value: '<500ms', label: 'Under 0.5s', description: 'Very fast executions' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface QueryContext {
|
||||
type: 'initial' | 'filter-key-partial' | 'filter-value-context' | 'text-search'
|
||||
filterKey?: string
|
||||
partialInput?: string
|
||||
startPosition?: number
|
||||
endPosition?: number
|
||||
}
|
||||
|
||||
export class SearchSuggestions {
|
||||
private availableWorkflows: string[]
|
||||
private availableFolders: string[]
|
||||
|
||||
constructor(availableWorkflows: string[] = [], availableFolders: string[] = []) {
|
||||
this.availableWorkflows = availableWorkflows
|
||||
this.availableFolders = availableFolders
|
||||
}
|
||||
|
||||
updateAvailableData(workflows: string[] = [], folders: string[] = []) {
|
||||
this.availableWorkflows = workflows
|
||||
this.availableFolders = folders
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filter value is complete (matches a valid option)
|
||||
*/
|
||||
private isCompleteFilterValue(filterKey: string, value: string): boolean {
|
||||
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === filterKey)
|
||||
if (filterDef) {
|
||||
return filterDef.options.some((option) => option.value === value)
|
||||
}
|
||||
|
||||
// For workflow and folder filters, any quoted value is considered complete
|
||||
if (filterKey === 'workflow' || filterKey === 'folder') {
|
||||
return value.startsWith('"') && value.endsWith('"') && value.length > 2
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze the current input context to determine what suggestions to show.
|
||||
*/
|
||||
private analyzeContext(input: string, cursorPosition: number): QueryContext {
|
||||
const textBeforeCursor = input.slice(0, cursorPosition)
|
||||
|
||||
if (textBeforeCursor === '' || textBeforeCursor.endsWith(' ')) {
|
||||
return { type: 'initial' }
|
||||
}
|
||||
|
||||
// Check for filter value context (must be after a space or at start, and not empty value)
|
||||
const filterValueMatch = textBeforeCursor.match(/(?:^|\s)(\w+):([\w"<>=!]*)$/)
|
||||
if (filterValueMatch && filterValueMatch[2].length > 0 && !filterValueMatch[2].includes(' ')) {
|
||||
const filterKey = filterValueMatch[1]
|
||||
const filterValue = filterValueMatch[2]
|
||||
|
||||
// If the filter value is complete, treat as ready for next filter
|
||||
if (this.isCompleteFilterValue(filterKey, filterValue)) {
|
||||
return { type: 'initial' }
|
||||
}
|
||||
|
||||
// Otherwise, treat as partial value needing completion
|
||||
return {
|
||||
type: 'filter-value-context',
|
||||
filterKey,
|
||||
partialInput: filterValue,
|
||||
startPosition:
|
||||
filterValueMatch.index! +
|
||||
(filterValueMatch[0].startsWith(' ') ? 1 : 0) +
|
||||
filterKey.length +
|
||||
1,
|
||||
endPosition: cursorPosition,
|
||||
}
|
||||
}
|
||||
|
||||
// Check for empty filter key (just "key:" with no value)
|
||||
const emptyFilterMatch = textBeforeCursor.match(/(?:^|\s)(\w+):$/)
|
||||
if (emptyFilterMatch) {
|
||||
return { type: 'initial' } // Treat as initial to show filter value suggestions
|
||||
}
|
||||
|
||||
const filterKeyMatch = textBeforeCursor.match(/(?:^|\s)(\w+):?$/)
|
||||
if (filterKeyMatch && !filterKeyMatch[0].includes(':')) {
|
||||
return {
|
||||
type: 'filter-key-partial',
|
||||
partialInput: filterKeyMatch[1],
|
||||
startPosition: filterKeyMatch.index! + (filterKeyMatch[0].startsWith(' ') ? 1 : 0),
|
||||
endPosition: cursorPosition,
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'text-search' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filter key suggestions
|
||||
*/
|
||||
private getFilterKeySuggestions(partialInput?: string): Suggestion[] {
|
||||
const suggestions: Suggestion[] = []
|
||||
|
||||
for (const filter of FILTER_DEFINITIONS) {
|
||||
const matchesPartial =
|
||||
!partialInput ||
|
||||
filter.key.toLowerCase().startsWith(partialInput.toLowerCase()) ||
|
||||
filter.label.toLowerCase().startsWith(partialInput.toLowerCase())
|
||||
|
||||
if (matchesPartial) {
|
||||
suggestions.push({
|
||||
id: `filter-key-${filter.key}`,
|
||||
value: `${filter.key}:`,
|
||||
label: filter.label,
|
||||
description: filter.description,
|
||||
category: 'filters',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.availableWorkflows.length > 0) {
|
||||
const matchesWorkflow =
|
||||
!partialInput ||
|
||||
'workflow'.startsWith(partialInput.toLowerCase()) ||
|
||||
'workflows'.startsWith(partialInput.toLowerCase())
|
||||
|
||||
if (matchesWorkflow) {
|
||||
suggestions.push({
|
||||
id: 'filter-key-workflow',
|
||||
value: 'workflow:',
|
||||
label: 'Workflow',
|
||||
description: 'Filter by workflow name',
|
||||
category: 'filters',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.availableFolders.length > 0) {
|
||||
const matchesFolder =
|
||||
!partialInput ||
|
||||
'folder'.startsWith(partialInput.toLowerCase()) ||
|
||||
'folders'.startsWith(partialInput.toLowerCase())
|
||||
|
||||
if (matchesFolder) {
|
||||
suggestions.push({
|
||||
id: 'filter-key-folder',
|
||||
value: 'folder:',
|
||||
label: 'Folder',
|
||||
description: 'Filter by folder name',
|
||||
category: 'filters',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filter value suggestions for a specific filter key
|
||||
*/
|
||||
private getFilterValueSuggestions(filterKey: string, partialInput = ''): Suggestion[] {
|
||||
const suggestions: Suggestion[] = []
|
||||
|
||||
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === filterKey)
|
||||
if (filterDef) {
|
||||
for (const option of filterDef.options) {
|
||||
const matchesPartial =
|
||||
!partialInput ||
|
||||
option.value.toLowerCase().includes(partialInput.toLowerCase()) ||
|
||||
option.label.toLowerCase().includes(partialInput.toLowerCase())
|
||||
|
||||
if (matchesPartial) {
|
||||
suggestions.push({
|
||||
id: `filter-value-${filterKey}-${option.value}`,
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
category: filterKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
if (filterKey === 'workflow') {
|
||||
for (const workflow of this.availableWorkflows) {
|
||||
const matchesPartial =
|
||||
!partialInput || workflow.toLowerCase().includes(partialInput.toLowerCase())
|
||||
|
||||
if (matchesPartial) {
|
||||
suggestions.push({
|
||||
id: `filter-value-workflow-${workflow}`,
|
||||
value: `"${workflow}"`,
|
||||
label: workflow,
|
||||
description: 'Workflow name',
|
||||
category: 'workflow',
|
||||
})
|
||||
}
|
||||
}
|
||||
return suggestions.slice(0, 8)
|
||||
}
|
||||
|
||||
if (filterKey === 'folder') {
|
||||
for (const folder of this.availableFolders) {
|
||||
const matchesPartial =
|
||||
!partialInput || folder.toLowerCase().includes(partialInput.toLowerCase())
|
||||
|
||||
if (matchesPartial) {
|
||||
suggestions.push({
|
||||
id: `filter-value-folder-${folder}`,
|
||||
value: `"${folder}"`,
|
||||
label: folder,
|
||||
description: 'Folder name',
|
||||
category: 'folder',
|
||||
})
|
||||
}
|
||||
}
|
||||
return suggestions.slice(0, 8)
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions based on current input and cursor position
|
||||
*/
|
||||
getSuggestions(input: string, cursorPosition: number): SuggestionGroup | null {
|
||||
const context = this.analyzeContext(input, cursorPosition)
|
||||
|
||||
// Special case: check if we're at "key:" position for filter values
|
||||
const textBeforeCursor = input.slice(0, cursorPosition)
|
||||
const emptyFilterMatch = textBeforeCursor.match(/(?:^|\s)(\w+):$/)
|
||||
if (emptyFilterMatch) {
|
||||
const filterKey = emptyFilterMatch[1]
|
||||
const filterValueSuggestions = this.getFilterValueSuggestions(filterKey, '')
|
||||
return filterValueSuggestions.length > 0
|
||||
? {
|
||||
type: 'filter-values',
|
||||
filterKey,
|
||||
suggestions: filterValueSuggestions,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
switch (context.type) {
|
||||
case 'initial':
|
||||
case 'filter-key-partial': {
|
||||
const filterKeySuggestions = this.getFilterKeySuggestions(context.partialInput)
|
||||
return filterKeySuggestions.length > 0
|
||||
? {
|
||||
type: 'filter-keys',
|
||||
suggestions: filterKeySuggestions,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
case 'filter-value-context': {
|
||||
if (!context.filterKey) return null
|
||||
const filterValueSuggestions = this.getFilterValueSuggestions(
|
||||
context.filterKey,
|
||||
context.partialInput
|
||||
)
|
||||
return filterValueSuggestions.length > 0
|
||||
? {
|
||||
type: 'filter-values',
|
||||
filterKey: context.filterKey,
|
||||
suggestions: filterValueSuggestions,
|
||||
}
|
||||
: null
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview text for a suggestion - SIMPLE APPROACH
|
||||
* Show suggestion at the end of input, with proper spacing logic
|
||||
*/
|
||||
generatePreview(suggestion: Suggestion, currentValue: string, cursorPosition: number): string {
|
||||
// If input is empty, just show the suggestion
|
||||
if (!currentValue.trim()) {
|
||||
return suggestion.value
|
||||
}
|
||||
|
||||
// Check if we're doing a partial replacement (like "lev" -> "level:")
|
||||
const context = this.analyzeContext(currentValue, cursorPosition)
|
||||
|
||||
if (
|
||||
context.type === 'filter-key-partial' &&
|
||||
context.startPosition !== undefined &&
|
||||
context.endPosition !== undefined
|
||||
) {
|
||||
// Replace partial text: "lev" -> "level:"
|
||||
const before = currentValue.slice(0, context.startPosition)
|
||||
const after = currentValue.slice(context.endPosition)
|
||||
return `${before}${suggestion.value}${after}`
|
||||
}
|
||||
|
||||
if (
|
||||
context.type === 'filter-value-context' &&
|
||||
context.startPosition !== undefined &&
|
||||
context.endPosition !== undefined
|
||||
) {
|
||||
// Replace partial filter value: "level:err" -> "level:error"
|
||||
const before = currentValue.slice(0, context.startPosition)
|
||||
const after = currentValue.slice(context.endPosition)
|
||||
return `${before}${suggestion.value}${after}`
|
||||
}
|
||||
|
||||
// For all other cases, append at the end with smart spacing:
|
||||
let result = currentValue
|
||||
|
||||
if (currentValue.endsWith(':')) {
|
||||
// Direct append for filter values: "level:" + "error" = "level:error"
|
||||
result += suggestion.value
|
||||
} else if (currentValue.endsWith(' ')) {
|
||||
// Already has space, direct append: "level:error " + "trigger:" = "level:error trigger:"
|
||||
result += suggestion.value
|
||||
} else {
|
||||
// Need space: "level:error" + " " + "trigger:" = "level:error trigger:"
|
||||
result += ` ${suggestion.value}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a query is complete and should trigger backend calls
|
||||
*/
|
||||
validateQuery(query: string): boolean {
|
||||
const incompleteFilterMatch = query.match(/(\w+):$/)
|
||||
if (incompleteFilterMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
const openQuotes = (query.match(/"/g) || []).length
|
||||
if (openQuotes % 2 !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user