improvement(search): added more granular logs search, added logs export, improved overall search experience (#1378)

* improvement(search): added more granular logs search, added logs export, improved overall search experience

* updated tests
This commit is contained in:
Waleed
2025-09-18 13:58:44 -07:00
committed by GitHub
parent 3905d1cb81
commit eb1e90bb7f
9 changed files with 584 additions and 82 deletions

View File

@@ -0,0 +1,200 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('LogsExportAPI')
export const revalidate = 0
const ExportParamsSchema = z.object({
level: z.string().optional(),
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
workspaceId: z.string(),
})
function escapeCsv(value: any): string {
if (value === null || value === undefined) return ''
const str = String(value)
if (/[",\n]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const { searchParams } = new URL(request.url)
const params = ExportParamsSchema.parse(Object.fromEntries(searchParams.entries()))
const selectColumns = {
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData,
workflowName: workflow.name,
}
let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)
if (params.level && params.level !== 'all') {
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
}
if (params.workflowIds) {
const workflowIds = params.workflowIds.split(',').filter(Boolean)
if (workflowIds.length > 0) conditions = and(conditions, inArray(workflow.id, workflowIds))
}
if (params.folderIds) {
const folderIds = params.folderIds.split(',').filter(Boolean)
if (folderIds.length > 0) conditions = and(conditions, inArray(workflow.folderId, folderIds))
}
if (params.triggers) {
const triggers = params.triggers.split(',').filter(Boolean)
if (triggers.length > 0 && !triggers.includes('all')) {
conditions = and(conditions, inArray(workflowExecutionLogs.trigger, triggers))
}
}
if (params.startDate) {
conditions = and(conditions, gte(workflowExecutionLogs.startedAt, new Date(params.startDate)))
}
if (params.endDate) {
conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate)))
}
if (params.search) {
const term = `%${params.search}%`
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${term}`)
}
if (params.workflowName) {
const nameTerm = `%${params.workflowName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
}
if (params.folderName) {
const folderTerm = `%${params.folderName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
}
const header = [
'startedAt',
'level',
'workflow',
'trigger',
'durationMs',
'costTotal',
'workflowId',
'executionId',
'message',
'traceSpans',
].join(',')
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start: async (controller) => {
controller.enqueue(encoder.encode(`${header}\n`))
const pageSize = 1000
let offset = 0
try {
while (true) {
const rows = await db
.select(selectColumns)
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId)
)
)
.where(conditions)
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(pageSize)
.offset(offset)
if (!rows.length) break
for (const r of rows as any[]) {
let message = ''
let traces: any = null
try {
const ed = (r as any).executionData
if (ed) {
if (ed.finalOutput)
message =
typeof ed.finalOutput === 'string'
? ed.finalOutput
: JSON.stringify(ed.finalOutput)
if (ed.message) message = ed.message
if (ed.traceSpans) traces = ed.traceSpans
}
} catch {}
const line = [
escapeCsv(r.startedAt?.toISOString?.() || r.startedAt),
escapeCsv(r.level),
escapeCsv(r.workflowName),
escapeCsv(r.trigger),
escapeCsv(r.totalDurationMs ?? ''),
escapeCsv(r.cost?.total ?? r.cost?.value?.total ?? ''),
escapeCsv(r.workflowId ?? ''),
escapeCsv(r.executionId ?? ''),
escapeCsv(message),
escapeCsv(traces ? JSON.stringify(traces) : ''),
].join(',')
controller.enqueue(encoder.encode(`${line}\n`))
}
offset += pageSize
}
controller.close()
} catch (e: any) {
logger.error('Export stream error', { error: e?.message })
try {
controller.error(e)
} catch {}
}
},
})
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `logs-${ts}.csv`
return new NextResponse(stream as any, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-cache',
},
})
} catch (error: any) {
logger.error('Export error', { error: error?.message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -22,6 +22,8 @@ const QueryParamsSchema = z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
workspaceId: z.string(),
})
@@ -155,6 +157,18 @@ export async function GET(request: NextRequest) {
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
}
// Filter by workflow name (from advanced search input)
if (params.workflowName) {
const nameTerm = `%${params.workflowName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
}
// Filter by folder name (best-effort text match when present on workflows)
if (params.folderName) {
const folderTerm = `%${params.folderName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
}
// Execute the query using the optimized join
const logs = await baseQuery
.where(conditions)

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -26,20 +27,27 @@ interface WorkflowOption {
}
export default function Workflow() {
const { workflowIds, toggleWorkflowId, setWorkflowIds } = useFilterStore()
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
// Fetch all available workflows from the API
useEffect(() => {
const fetchWorkflows = async () => {
try {
setLoading(true)
const response = await fetch('/api/workflows')
const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''
const response = await fetch(`/api/workflows${query}`)
if (response.ok) {
const { data } = await response.json()
const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({
const scoped = Array.isArray(data)
? folderIds.length > 0
? data.filter((w: any) => (w.folderId ? folderIds.includes(w.folderId) : false))
: data
: []
const workflowOptions: WorkflowOption[] = scoped.map((workflow: any) => ({
id: workflow.id,
name: workflow.name,
color: workflow.color || '#3972F6',
@@ -54,7 +62,7 @@ export default function Workflow() {
}
fetchWorkflows()
}, [])
}, [workspaceId, folderIds])
const getSelectedWorkflowsText = () => {
if (workflowIds.length === 0) return 'All workflows'

View File

@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
import { Search, X } from 'lucide-react'
import { useEffect, useMemo } from 'react'
import { Loader2, Search, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -17,6 +17,7 @@ interface AutocompleteSearchProps {
availableWorkflows?: string[]
availableFolders?: string[]
className?: string
onOpenChange?: (open: boolean) => void
}
export function AutocompleteSearch({
@@ -26,6 +27,7 @@ export function AutocompleteSearch({
availableWorkflows = [],
availableFolders = [],
className,
onOpenChange,
}: AutocompleteSearchProps) {
const suggestionEngine = useMemo(() => {
return new SearchSuggestions(availableWorkflows, availableFolders)
@@ -42,6 +44,8 @@ export function AutocompleteSearch({
handleKeyDown,
handleFocus,
handleBlur,
reset: resetAutocomplete,
closeDropdown,
} = useAutocomplete({
getSuggestions: (inputValue, cursorPos) =>
suggestionEngine.getSuggestions(inputValue, cursorPos),
@@ -52,10 +56,39 @@ export function AutocompleteSearch({
debounceMs: 100,
})
const clearAll = () => {
resetAutocomplete()
closeDropdown()
onChange('')
if (inputRef.current) {
inputRef.current.focus()
}
}
const parsedQuery = parseQuery(value)
const hasFilters = parsedQuery.filters.length > 0
const hasTextSearch = parsedQuery.textSearch.length > 0
const listboxId = 'logs-search-listbox'
const inputId = 'logs-search-input'
useEffect(() => {
onOpenChange?.(state.isOpen)
}, [state.isOpen, onOpenChange])
useEffect(() => {
if (!state.isOpen || state.highlightedIndex < 0) return
const container = dropdownRef.current
const optionEl = document.getElementById(`${listboxId}-option-${state.highlightedIndex}`)
if (container && optionEl) {
try {
optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
} catch {
optionEl.scrollIntoView({ block: 'nearest' })
}
}
}, [state.isOpen, state.highlightedIndex])
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const cursorPos = e.target.selectionStart || 0
@@ -77,8 +110,10 @@ export function AutocompleteSearch({
)
const newQuery = [...filterStrings, parsedQuery.textSearch].filter(Boolean).join(' ')
onChange(newQuery)
handleInputChange(newQuery, newQuery.length)
if (inputRef.current) {
inputRef.current.focus()
}
}
return (
@@ -91,24 +126,37 @@ export function AutocompleteSearch({
state.isOpen && 'ring-1 ring-ring'
)}
>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
{state.pendingQuery ? (
<Loader2 className='h-4 w-4 flex-shrink-0 animate-spin text-muted-foreground' />
) : (
<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}
id={inputId}
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' }}
role='combobox'
aria-expanded={state.isOpen}
aria-controls={state.isOpen ? listboxId : undefined}
aria-autocomplete='list'
aria-activedescendant={
state.isOpen && state.highlightedIndex >= 0
? `${listboxId}-option-${state.highlightedIndex}`
: undefined
}
/>
{/* Always-visible text overlay */}
@@ -134,7 +182,10 @@ export function AutocompleteSearch({
variant='ghost'
size='sm'
className='h-6 w-6 p-0 hover:bg-muted/50'
onClick={() => onChange('')}
onMouseDown={(e) => {
e.preventDefault()
clearAll()
}}
>
<X className='h-3 w-3' />
</Button>
@@ -145,7 +196,10 @@ export function AutocompleteSearch({
{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'
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-md'
id={listboxId}
role='listbox'
aria-labelledby={inputId}
>
<div className='max-h-96 overflow-y-auto py-1'>
{state.suggestionType === 'filter-keys' && (
@@ -168,12 +222,20 @@ export function AutocompleteSearch({
'transition-colors hover:bg-accent hover:text-accent-foreground',
index === state.highlightedIndex && 'bg-accent text-accent-foreground'
)}
onMouseEnter={() => handleSuggestionHover(index)}
onMouseEnter={() => {
if (typeof window !== 'undefined' && (window as any).__logsKeyboardNavActive) {
return
}
handleSuggestionHover(index)
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleSuggestionSelect(suggestion)
}}
id={`${listboxId}-option-${index}`}
role='option'
aria-selected={index === state.highlightedIndex}
>
<div className='flex items-center justify-between'>
<div className='flex-1'>
@@ -226,7 +288,14 @@ export function AutocompleteSearch({
variant='ghost'
size='sm'
className='h-6 text-muted-foreground text-xs hover:text-foreground'
onClick={() => onChange(parsedQuery.textSearch)}
onMouseDown={(e) => {
e.preventDefault()
const newQuery = parsedQuery.textSearch
handleInputChange(newQuery, newQuery.length)
if (inputRef.current) {
inputRef.current.focus()
}
}}
>
Clear all
</Button>

View File

@@ -1,11 +1,21 @@
import { useCallback, useMemo, useReducer, useRef } from 'react'
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
export interface Suggestion {
id: string
value: string
label: string
description?: string
category?: string
category?:
| 'filters'
| 'level'
| 'trigger'
| 'cost'
| 'date'
| 'duration'
| 'workflow'
| 'folder'
| 'workflowId'
| 'executionId'
}
export interface SuggestionGroup {
@@ -43,6 +53,7 @@ type AutocompleteAction =
| { type: 'SET_PREVIEW'; payload: { value: string; show: boolean } }
| { type: 'CLEAR_PREVIEW' }
| { type: 'SET_QUERY_VALIDITY'; payload: boolean }
| { type: 'SET_PENDING'; payload: string | null }
| { type: 'RESET' }
const initialState: AutocompleteState = {
@@ -126,6 +137,12 @@ function autocompleteReducer(
isValidQuery: action.payload,
}
case 'SET_PENDING':
return {
...state,
pendingQuery: action.payload,
}
case 'RESET':
return initialState
@@ -153,6 +170,16 @@ export function useAutocomplete({
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
const pointerDownInDropdownRef = useRef<boolean>(false)
const latestRef = useRef<{ inputValue: string; cursorPosition: number }>({
inputValue: '',
cursorPosition: 0,
})
useEffect(() => {
latestRef.current.inputValue = state.inputValue
latestRef.current.cursorPosition = state.cursorPosition
}, [state.inputValue, state.cursorPosition])
const currentSuggestion = useMemo(() => {
if (state.highlightedIndex >= 0 && state.suggestions[state.highlightedIndex]) {
@@ -162,13 +189,14 @@ export function useAutocomplete({
}, [state.highlightedIndex, state.suggestions])
const updateSuggestions = useCallback(() => {
const suggestionGroup = getSuggestions(state.inputValue, state.cursorPosition)
const { inputValue, cursorPosition } = latestRef.current
const suggestionGroup = getSuggestions(inputValue, 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)
const preview = generatePreview(firstSuggestion, inputValue, cursorPosition)
dispatch({
type: 'HIGHLIGHT_SUGGESTION',
payload: { index: 0, preview },
@@ -176,7 +204,7 @@ export function useAutocomplete({
} else {
dispatch({ type: 'CLOSE_DROPDOWN' })
}
}, [state.inputValue, state.cursorPosition, getSuggestions, generatePreview])
}, [getSuggestions, generatePreview])
const handleInputChange = useCallback(
(value: string, cursorPosition: number) => {
@@ -193,7 +221,11 @@ export function useAutocomplete({
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(updateSuggestions, debounceMs)
dispatch({ type: 'SET_PENDING', payload: value })
debounceRef.current = setTimeout(() => {
dispatch({ type: 'SET_PENDING', payload: null })
updateSuggestions()
}, debounceMs)
},
[updateSuggestions, onQueryChange, validateQuery, debounceMs]
)
@@ -257,6 +289,11 @@ export function useAutocomplete({
})
}
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
dispatch({ type: 'SET_PENDING', payload: null })
setTimeout(updateSuggestions, 0)
},
[
@@ -273,6 +310,16 @@ export function useAutocomplete({
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
if (state.isOpen) {
handleSuggestionSelect()
} else if (state.isValidQuery) {
updateSuggestions()
}
return
}
if (!state.isOpen) return
switch (event.key) {
@@ -290,11 +337,6 @@ export function useAutocomplete({
break
}
case 'Enter':
event.preventDefault()
handleSuggestionSelect()
break
case 'Escape':
event.preventDefault()
dispatch({ type: 'CLOSE_DROPDOWN' })
@@ -324,12 +366,37 @@ export function useAutocomplete({
updateSuggestions()
}, [updateSuggestions])
const handleBlur = useCallback(() => {
const handleBlur = useCallback((e?: React.FocusEvent) => {
const related = (e?.relatedTarget as Node) || document.activeElement
const isInsideDropdown = related && dropdownRef.current?.contains(related)
const isInsideInput = related && inputRef.current === related
if (pointerDownInDropdownRef.current || isInsideDropdown || isInsideInput) {
return
}
setTimeout(() => {
dispatch({ type: 'CLOSE_DROPDOWN' })
}, 150)
}, [])
useEffect(() => {
const dropdownEl = dropdownRef.current
if (!dropdownEl) return
const onPointerDown = () => {
pointerDownInDropdownRef.current = true
}
const onPointerUp = () => {
setTimeout(() => {
pointerDownInDropdownRef.current = false
}, 0)
}
dropdownEl.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointerup', onPointerUp)
return () => {
dropdownEl.removeEventListener('pointerdown', onPointerDown)
window.removeEventListener('pointerup', onPointerUp)
}
}, [])
return {
// State
state,

View File

@@ -12,6 +12,7 @@ import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/component
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'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types'
@@ -77,7 +78,6 @@ export default function Logs() {
triggers,
} = useFilterStore()
// Set workspace ID in store when component mounts or workspaceId changes
useEffect(() => {
setWorkspaceId(workspaceId)
}, [workspaceId])
@@ -94,11 +94,9 @@ export default function Logs() {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isInitialized = useRef<boolean>(false)
// Local search state with debouncing for the header
const [searchQuery, setSearchQuery] = useState(storeSearchQuery)
const debouncedSearchQuery = useDebounce(searchQuery, 300)
// Available data for suggestions
const [availableWorkflows, setAvailableWorkflows] = useState<string[]>([])
const [availableFolders, setAvailableFolders] = useState<string[]>([])
@@ -106,29 +104,63 @@ export default function Logs() {
const [isLive, setIsLive] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const liveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const isSearchOpenRef = useRef<boolean>(false)
// Sync local search query with store search query
useEffect(() => {
setSearchQuery(storeSearchQuery)
}, [storeSearchQuery])
const { fetchFolders, getFolderTree } = useFolderStore()
useEffect(() => {
const workflowNames = new Set<string>()
const folderNames = new Set<string>()
let cancelled = false
logs.forEach((log) => {
if (log.workflow?.name) {
workflowNames.add(log.workflow.name)
const fetchSuggestions = async () => {
try {
const res = await fetch(`/api/workflows?workspaceId=${encodeURIComponent(workspaceId)}`)
if (res.ok) {
const body = await res.json()
const names: string[] = Array.isArray(body?.data)
? body.data.map((w: any) => w?.name).filter(Boolean)
: []
if (!cancelled) setAvailableWorkflows(names)
} else {
if (!cancelled) setAvailableWorkflows([])
}
await fetchFolders(workspaceId)
const tree = getFolderTree(workspaceId)
const flatten = (nodes: any[], parentPath = ''): string[] => {
const out: string[] = []
for (const n of nodes) {
const path = parentPath ? `${parentPath} / ${n.name}` : n.name
out.push(path)
if (n.children?.length) out.push(...flatten(n.children, path))
}
return out
}
const folderPaths: string[] = Array.isArray(tree) ? flatten(tree) : []
if (!cancelled) setAvailableFolders(folderPaths)
} catch {
if (!cancelled) {
setAvailableWorkflows([])
setAvailableFolders([])
}
}
// 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])
if (workspaceId) {
fetchSuggestions()
}
return () => {
cancelled = true
}
}, [workspaceId, fetchFolders, getFolderTree])
// Update store when debounced search query changes
useEffect(() => {
if (isInitialized.current && debouncedSearchQuery !== storeSearchQuery) {
setStoreSearchQuery(debouncedSearchQuery)
@@ -142,12 +174,10 @@ export default function Logs() {
setIsSidebarOpen(true)
setIsDetailsLoading(true)
// Fetch details for current, previous, and next concurrently with cache
const currentId = log.id
const prevId = index > 0 ? logs[index - 1]?.id : undefined
const nextId = index < logs.length - 1 ? logs[index + 1]?.id : undefined
// Abort any previous details fetch batch
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
@@ -167,7 +197,6 @@ export default function Logs() {
if (nextId && !detailsCacheRef.current.has(nextId))
idsToFetch.push({ id: nextId, merge: false })
// Merge cached current immediately
if (cachedCurrent) {
setSelectedLog((prev) =>
prev && prev.id === currentId
@@ -207,7 +236,6 @@ export default function Logs() {
setSelectedLogIndex(nextIndex)
const nextLog = logs[nextIndex]
setSelectedLog(nextLog)
// Abort any previous details fetch batch
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
@@ -265,7 +293,6 @@ export default function Logs() {
setSelectedLogIndex(prevIndex)
const prevLog = logs[prevIndex]
setSelectedLog(prevLog)
// Abort any previous details fetch batch
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
@@ -340,19 +367,16 @@ export default function Logs() {
setIsFetchingMore(true)
}
// Get fresh query params by calling buildQueryParams from store
const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState()
const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE)
// Parse the current search query for enhanced filtering
const parsedQuery = parseQuery(searchQuery)
const { searchQuery: currentSearchQuery } = useFilterStore.getState()
const parsedQuery = parseQuery(currentSearchQuery)
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])]
@@ -429,7 +453,27 @@ export default function Logs() {
setIsLive(!isLive)
}
// Initialize filters from URL on mount
const handleExport = async () => {
const params = new URLSearchParams()
params.set('workspaceId', workspaceId)
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(','))
const parsed = parseQuery(debouncedSearchQuery)
const extra = queryToApiParams(parsed)
Object.entries(extra).forEach(([k, v]) => params.set(k, v))
const url = `/api/logs/export?${params.toString()}`
const a = document.createElement('a')
a.href = url
a.download = 'logs_export.csv'
document.body.appendChild(a)
a.click()
a.remove()
}
useEffect(() => {
if (!isInitialized.current) {
isInitialized.current = true
@@ -437,7 +481,6 @@ export default function Logs() {
}
}, [initializeFromURL])
// Handle browser navigation events (back/forward)
useEffect(() => {
const handlePopState = () => {
initializeFromURL()
@@ -447,43 +490,34 @@ export default function Logs() {
return () => window.removeEventListener('popstate', handlePopState)
}, [initializeFromURL])
// Single useEffect to handle both initial load and filter changes
useEffect(() => {
// Only fetch logs after initialization
if (!isInitialized.current) {
return
}
// Reset pagination and fetch from beginning
setPage(1)
setHasMore(true)
// Inline fetch logic to avoid circular dependency
const fetchWithFilters = async () => {
try {
setLoading(true)
// Build query params inline to avoid dependency issues
const params = new URLSearchParams()
params.set('details', 'basic')
params.set('limit', LOGS_PER_PAGE.toString())
params.set('offset', '0') // Always start from page 1
params.set('workspaceId', workspaceId)
// Parse the search query for enhanced filtering
const parsedQuery = parseQuery(searchQuery)
const parsedQuery = parseQuery(debouncedSearchQuery)
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(','))
// 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])]
@@ -493,7 +527,6 @@ export default function Logs() {
}
})
// Add time range filter
if (timeRange !== 'All time') {
const now = new Date()
let startDate: Date
@@ -532,7 +565,7 @@ export default function Logs() {
}
fetchWithFilters()
}, [workspaceId, timeRange, level, workflowIds, folderIds, searchQuery, triggers])
}, [workspaceId, timeRange, level, workflowIds, folderIds, debouncedSearchQuery, triggers])
const loadMoreLogs = useCallback(() => {
if (!isFetchingMore && hasMore) {
@@ -598,6 +631,7 @@ export default function Logs() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isSearchOpenRef.current) return
if (logs.length === 0) return
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
@@ -651,9 +685,12 @@ export default function Logs() {
placeholder='Search logs...'
availableWorkflows={availableWorkflows}
availableFolders={availableFolders}
onOpenChange={(open) => {
isSearchOpenRef.current = open
}}
/>
<div className='flex flex-shrink-0 items-center gap-3'>
<div className='ml-auto flex flex-shrink-0 items-center gap-3'>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -674,6 +711,34 @@ export default function Logs() {
<TooltipContent>{isRefreshing ? 'Refreshing...' : 'Refresh'}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleExport}
className='h-9 rounded-[11px] hover:bg-secondary'
aria-label='Export CSV'
>
{/* Download icon */}
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='h-5 w-5'
>
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<polyline points='7 10 12 15 17 10' />
<line x1='12' y1='15' x2='12' y2='3' />
</svg>
<span className='sr-only'>Export CSV</span>
</Button>
</TooltipTrigger>
<TooltipContent>Export CSV</TooltipContent>
</Tooltip>
<Button
className={`group h-9 gap-2 rounded-[11px] border bg-card text-card-foreground shadow-xs transition-all duration-200 hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white ${
isLive

View File

@@ -169,6 +169,12 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
}
break
case 'folder':
if (filter.operator === '=') {
params.folderName = filter.value as string
}
break
case 'execution':
if (filter.operator === '=' && parsedQuery.textSearch) {
params.search = `${parsedQuery.textSearch} ${filter.value}`.trim()
@@ -177,6 +183,18 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
}
break
case 'workflowId':
if (filter.operator === '=') {
params.workflowIds = String(filter.value)
}
break
case 'executionId':
if (filter.operator === '=') {
params.executionId = String(filter.value)
}
break
case 'date':
if (filter.operator === '=' && filter.value === 'today') {
const today = new Date()

View File

@@ -43,10 +43,10 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
})
it.concurrent('should return filter key suggestions for partial matches', () => {
it.concurrent('should return value suggestions for uniquely identified partial keys', () => {
const result = engine.getSuggestions('lev', 3)
expect(result?.type).toBe('filter-keys')
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'error' || s.value === 'info')).toBe(true)
})
it.concurrent('should return filter value suggestions after colon', () => {
@@ -87,11 +87,16 @@ describe('SearchSuggestions', () => {
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 surface value suggestions for uniquely matched partial keys after existing filters',
() => {
const result = engine.getSuggestions('level:error lev', 15)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'error' || s.value === 'info')).toBe(
true
)
}
)
it.concurrent('should handle filter values after existing filters', () => {
const result = engine.getSuggestions('level:error level:', 18)

View File

@@ -62,6 +62,10 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
{ 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" },
// Friendly relative range shortcuts like Stripe
{ value: '"> 2 days ago"', label: '> 2 days ago', description: 'Newer than 2 days' },
{ value: '"> last week"', label: '> last week', description: 'Newer than last week' },
{ value: '">=2025/08/31"', label: '>= YYYY/MM/DD', description: 'Start date (YYYY/MM/DD)' },
],
},
{
@@ -228,6 +232,27 @@ export class SearchSuggestions {
}
}
// Always include id-based keys (workflowId, executionId)
const idKeys: Array<{ key: string; label: string; description: string }> = [
{ key: 'workflowId', label: 'Workflow ID', description: 'Filter by workflowId' },
{ key: 'executionId', label: 'Execution ID', description: 'Filter by executionId' },
]
for (const idDef of idKeys) {
const matchesIdKey =
!partialInput ||
idDef.key.toLowerCase().startsWith(partialInput.toLowerCase()) ||
idDef.label.toLowerCase().startsWith(partialInput.toLowerCase())
if (matchesIdKey) {
suggestions.push({
id: `filter-key-${idDef.key}`,
value: `${idDef.key}:`,
label: idDef.label,
description: idDef.description,
category: 'filters',
})
}
}
return suggestions
}
@@ -251,7 +276,7 @@ export class SearchSuggestions {
value: option.value,
label: option.label,
description: option.description,
category: filterKey,
category: filterKey as any,
})
}
}
@@ -294,6 +319,18 @@ export class SearchSuggestions {
return suggestions.slice(0, 8)
}
if (filterKey === 'workflowId' || filterKey === 'executionId') {
const example = partialInput || '"1234..."'
suggestions.push({
id: `filter-value-${filterKey}-example`,
value: example,
label: 'Enter exact ID',
description: 'Use quotes for the full ID',
category: filterKey,
})
return suggestions
}
return suggestions
}
@@ -321,6 +358,26 @@ export class SearchSuggestions {
switch (context.type) {
case 'initial':
case 'filter-key-partial': {
if (context.type === 'filter-key-partial' && context.partialInput) {
const matches = FILTER_DEFINITIONS.filter(
(f) =>
f.key.toLowerCase().startsWith(context.partialInput!.toLowerCase()) ||
f.label.toLowerCase().startsWith(context.partialInput!.toLowerCase())
)
if (matches.length === 1) {
const key = matches[0].key
const filterValueSuggestions = this.getFilterValueSuggestions(key, '')
if (filterValueSuggestions.length > 0) {
return {
type: 'filter-values',
filterKey: key,
suggestions: filterValueSuggestions,
}
}
}
}
const filterKeySuggestions = this.getFilterKeySuggestions(context.partialInput)
return filterKeySuggestions.length > 0
? {
@@ -367,9 +424,13 @@ export class SearchSuggestions {
context.startPosition !== undefined &&
context.endPosition !== undefined
) {
// Replace partial text: "lev" -> "level:"
const before = currentValue.slice(0, context.startPosition)
const after = currentValue.slice(context.endPosition)
const isFilterValue =
!!suggestion.category && FILTER_DEFINITIONS.some((f) => f.key === suggestion.category)
if (isFilterValue) {
return `${before}${suggestion.category}:${suggestion.value}${after}`
}
return `${before}${suggestion.value}${after}`
}
@@ -378,23 +439,18 @@ export class SearchSuggestions {
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}`
}