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:
Waleed
2025-09-13 17:32:41 -07:00
committed by GitHub
parent 3e5d3735dc
commit f2ec43e4f9
8 changed files with 1463 additions and 21 deletions

View File

@@ -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(() => {

View File

@@ -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([])
}

View File

@@ -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>
)
}

View File

@@ -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' }),
}
}

View File

@@ -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>

View 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
}

View 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')
})
})
})

View 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
}
}