diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts new file mode 100644 index 0000000000..645d861333 --- /dev/null +++ b/apps/sim/app/api/logs/export/route.ts @@ -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({ + 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 }) + } +} diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index a359a2f6db..f34b0ebe1a 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -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) 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 22ced167b1..d694dc6cc6 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 @@ -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([]) 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' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx index 051aa2db18..b019a63734 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx @@ -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) => { 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' )} > - + {state.pendingQuery ? ( + + ) : ( + + )} {/* 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' }} + 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() + }} > @@ -145,7 +196,10 @@ export function AutocompleteSearch({ {state.isOpen && state.suggestions.length > 0 && (
{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} >
@@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts index 1e821208f5..4a02831b98 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts @@ -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(null) const dropdownRef = useRef(null) const debounceRef = useRef(null) + const pointerDownInDropdownRef = useRef(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, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 595171743a..1b254904d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -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(null) const isInitialized = useRef(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([]) const [availableFolders, setAvailableFolders] = useState([]) @@ -106,29 +104,63 @@ export default function Logs() { const [isLive, setIsLive] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const liveIntervalRef = useRef(null) + const isSearchOpenRef = useRef(false) // Sync local search query with store search query useEffect(() => { setSearchQuery(storeSearchQuery) }, [storeSearchQuery]) + const { fetchFolders, getFolderTree } = useFolderStore() + useEffect(() => { - const workflowNames = new Set() - const folderNames = new Set() + let cancelled = false + + 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([]) + } - logs.forEach((log) => { - if (log.workflow?.name) { - workflowNames.add(log.workflow.name) + 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 - }) + } + + if (workspaceId) { + fetchSuggestions() + } - setAvailableWorkflows(Array.from(workflowNames).slice(0, 10)) // Limit to top 10 - setAvailableFolders([]) // TODO: Add folder data to logs response - }, [logs]) + 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 + }} /> -
+
+ + Export CSV + +