diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx index 02d943c47b..e5cb8d7fbc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') - const logger = useMemo(() => createLogger('LogsFolderFilter'), []) // Fetch all available folders from the API useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx index 289ca6e593..22ced167b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx @@ -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([]) 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([]) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx new file mode 100644 index 0000000000..051aa2db18 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx @@ -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) => { + 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 ( +
+ {/* Search Input */} +
+ + + {/* Text display with ghost text */} +
+ {/* Invisible input for cursor and interactions */} + 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 */} +
+ + {state.inputValue} + {state.showPreview && + state.previewValue && + state.previewValue !== state.inputValue && + state.inputValue && ( + + {state.previewValue.slice(state.inputValue.length)} + + )} + +
+
+ + {/* Clear all button */} + {(hasFilters || hasTextSearch) && ( + + )} +
+ + {/* Suggestions Dropdown */} + {state.isOpen && state.suggestions.length > 0 && ( +
+
+ {state.suggestionType === 'filter-keys' && ( +
+ SUGGESTED FILTERS +
+ )} + {state.suggestionType === 'filter-values' && ( +
+ {state.suggestions[0]?.category?.toUpperCase() || 'VALUES'} +
+ )} + + {state.suggestions.map((suggestion, index) => ( + + ))} +
+
+ )} + + {/* Active filters as chips */} + {hasFilters && ( +
+ ACTIVE FILTERS: + {parsedQuery.filters.map((filter, index) => ( + + {filter.field}: + + {filter.operator !== '=' && filter.operator} + {filter.originalValue} + + + + ))} + {parsedQuery.filters.length > 1 && ( + + )} +
+ )} + + {/* Text search indicator */} + {hasTextSearch && ( +
+ TEXT SEARCH: + + "{parsedQuery.textSearch}" + +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts new file mode 100644 index 0000000000..1e821208f5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts @@ -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(null) + const dropdownRef = useRef(null) + const debounceRef = useRef(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' }), + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index aa51ac7ef0..595171743a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -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([]) + const [availableFolders, setAvailableFolders] = useState([]) + // 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() + const folderNames = new Set() + + 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() { {/* Search and Controls */} -
-
- - 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' - /> -
+
+
diff --git a/apps/sim/lib/logs/query-parser.ts b/apps/sim/lib/logs/query-parser.ts new file mode 100644 index 0000000000..9f809b8e05 --- /dev/null +++ b/apps/sim/lib/logs/query-parser.ts @@ -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+):((?:[>=')) { + 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 { + const params: Record = {} + + 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 +} diff --git a/apps/sim/lib/logs/search-suggestions.test.ts b/apps/sim/lib/logs/search-suggestions.test.ts new file mode 100644 index 0000000000..2bafdd7a25 --- /dev/null +++ b/apps/sim/lib/logs/search-suggestions.test.ts @@ -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') + }) + }) +}) diff --git a/apps/sim/lib/logs/search-suggestions.ts b/apps/sim/lib/logs/search-suggestions.ts new file mode 100644 index 0000000000..20d8bec2a0 --- /dev/null +++ b/apps/sim/lib/logs/search-suggestions.ts @@ -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 + } +}