'use client' import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState, } from 'react' import { Button, Calendar, ChipCombobox, type ComboboxOption, cn, Library, Popover, PopoverAnchor, PopoverContent, RefreshCw, toast, } from '@sim/emcn' import { Download, Workflow } from '@sim/emcn/icons' import { formatDuration } from '@sim/utils/formatting' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import type { WorkflowLogDetail, WorkflowLogRow, WorkflowLogSummary, } from '@/lib/api/contracts/logs' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getEndDateFromTimeRange, getStartDateFromTimeRange, hasActiveFilters, } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { type ParsedFilter, parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { type FolderData, SearchSuggestions, type TriggerData, type WorkflowData, } from '@/lib/logs/search-suggestions' import type { FilterTag, ResourceAction, ResourceColumn, ResourceRow, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId]/components' import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { executionIdParam, logDetailsTabParam, logDetailsTabUrlKeys, } from '@/app/workspace/[workspaceId]/logs/search-params' import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getBlock } from '@/blocks/registry' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs' import { fetchLogDetail, logKeys, prefetchLogDetail, useCancelExecution, useDashboardStats, useLogByExecutionId, useLogDetail, useLogsList, useRetryExecution, } from '@/hooks/queries/logs' import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components' import { DELETED_WORKFLOW_LABEL, extractRetryInput, formatDate, formatDateShort, getDisplayStatus, type LogStatus, parseDuration, STATUS_CONFIG, StatusBadge, TriggerBadge, } from './utils' const LOGS_PER_PAGE = 50 as const const SORTABLE_COLUMNS: readonly LogSortBy[] = ['date', 'duration', 'cost', 'status'] as const const REFRESH_SPINNER_DURATION_MS = 1000 as const const LIVE_REFRESH_INTERVAL_MS = 10_000 as const const ACTIVE_RUN_DETAIL_REFRESH_MS = 3_000 as const const LOG_COLUMNS: ResourceColumn[] = [ { id: 'workflow', header: 'Workflow' }, { id: 'date', header: 'Date' }, { id: 'status', header: 'Status' }, { id: 'cost', header: 'Cost' }, { id: 'trigger', header: 'Trigger' }, { id: 'duration', header: 'Duration' }, ] interface LogSelectionState { selectedLogId: string | null isSidebarOpen: boolean } type LogSelectionAction = | { type: 'TOGGLE_LOG'; logId: string } | { type: 'SELECT_LOG'; logId: string } | { type: 'CLOSE_SIDEBAR' } | { type: 'TOGGLE_SIDEBAR' } function logSelectionReducer( state: LogSelectionState, action: LogSelectionAction ): LogSelectionState { switch (action.type) { case 'TOGGLE_LOG': if (state.selectedLogId === action.logId && state.isSidebarOpen) { return { selectedLogId: null, isSidebarOpen: false } } return { selectedLogId: action.logId, isSidebarOpen: true } case 'SELECT_LOG': return { ...state, selectedLogId: action.logId } case 'CLOSE_SIDEBAR': return { selectedLogId: null, isSidebarOpen: false } case 'TOGGLE_SIDEBAR': return state.selectedLogId ? { ...state, isSidebarOpen: !state.isSidebarOpen } : state default: return state } } const TIME_RANGE_OPTIONS: ComboboxOption[] = [ { value: 'All time', label: 'All time' }, { value: 'Past 30 minutes', label: 'Past 30 minutes' }, { value: 'Past hour', label: 'Past hour' }, { value: 'Past 6 hours', label: 'Past 6 hours' }, { value: 'Past 12 hours', label: 'Past 12 hours' }, { value: 'Past 24 hours', label: 'Past 24 hours' }, { value: 'Past 3 days', label: 'Past 3 days' }, { value: 'Past 7 days', label: 'Past 7 days' }, { value: 'Past 14 days', label: 'Past 14 days' }, { value: 'Past 30 days', label: 'Past 30 days' }, { value: 'Custom range', label: 'Custom range' }, ] as const const colorIconCache = new Map>() function getColorIcon(color: string): React.ComponentType<{ className?: string }> { const cached = colorIconCache.get(color) if (cached) return cached const ColorIcon = ({ className }: { className?: string }) => (
) ColorIcon.displayName = `ColorIcon(${color})` colorIconCache.set(color, ColorIcon) return ColorIcon } function WorkflowOptionIcon({ className }: { className?: string }) { return } function getTriggerIcon( triggerType: string ): React.ComponentType<{ className?: string }> | undefined { if ((CORE_TRIGGER_TYPES as readonly string[]).includes(triggerType)) return undefined const block = getBlock(triggerType) if (!block?.icon) return undefined const BlockIcon = block.icon const TriggerIcon = ({ className }: { className?: string }) => ( ) TriggerIcon.displayName = `TriggerIcon(${triggerType})` return TriggerIcon } function SpinningRefreshCw(props: React.SVGProps) { return } /** * Logs page component displaying workflow execution history. * Supports filtering, search, live updates, and detailed log inspection. * @returns The logs page view with table and sidebar details */ export default function Logs() { const params = useParams() const workspaceId = params.workspaceId as string const { timeRange, startDate, endDate, level, workflowIds, folderIds, setWorkflowIds, searchQuery: urlSearchQuery, setSearchQuery: setUrlSearchQuery, triggers, resetFilters, setLevel, setFolderIds, setTriggers, setTimeRange, setDateRange, clearDateRange, } = useLogFilters() const viewMode = useFilterStore((s) => s.viewMode) const setViewMode = useFilterStore((s) => s.setViewMode) const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { selectedLogId: null, isSidebarOpen: false, }) const [executionId] = useQueryState(executionIdParam.key, executionIdParam.parser) const [pendingExecutionId, setPendingExecutionId] = useState(() => executionId) /** * The log-details `tab` param is owned/written by the details panel, but the * orchestrator must clear it when the panel closes so a lingering `?tab=trace` * never carries over to the next log opened from the list. */ const [, setLogDetailsTab] = useQueryState(logDetailsTabParam.key, { ...logDetailsTabParam.parser, ...logDetailsTabUrlKeys, }) /** * `urlSearchQuery` is the instant nuqs value (its URL write is debounced inside * `useLogFilters`); the query/filtering still debounce off it to avoid * per-keystroke fetches. */ const debouncedSearchQuery = useDebounce(urlSearchQuery, 300) const isLive = true const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isExporting, setIsExporting] = useState(false) const refreshTimersRef = useRef(new Set()) const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) const selectedLogIdRef = useRef(null) const shouldScrollIntoViewRef = useRef(false) const resourceTableRef = useRef(null) const logsRefetchRef = useRef<() => void>(() => {}) const activeLogRefetchRef = useRef<() => void>(() => {}) const activeLogTabRef = useRef('overview') const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) const [activeSort, setActiveSort] = useState<{ column: string direction: 'asc' | 'desc' } | null>(null) const userPermissions = useUserPermissionsContext() const [contextMenuOpen, setContextMenuOpen] = useState(false) const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [contextMenuLog, setContextMenuLog] = useState(null) const [previewLogId, setPreviewLogId] = useState(null) const queryClient = useQueryClient() const refetchInterval = useCallback( (query: { state: { data?: WorkflowLogDetail } }) => { if (!isLive) return false const status = query.state.data?.status return status === 'running' || status === 'pending' ? ACTIVE_RUN_DETAIL_REFRESH_MS : false }, [isLive] ) const selectedDetailQuery = useLogDetail(selectedLogId ?? undefined, workspaceId, { refetchInterval, }) const previewDetailQuery = useLogDetail(previewLogId ?? undefined, workspaceId, { refetchInterval, }) const sortBy: LogSortBy = activeSort && SORTABLE_COLUMNS.includes(activeSort.column as LogSortBy) ? (activeSort.column as LogSortBy) : 'date' const sortOrder: LogSortOrder = activeSort?.direction ?? 'desc' const logFilters = useMemo( () => ({ timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery: debouncedSearchQuery, limit: LOGS_PER_PAGE, sortBy, sortOrder, }), [ timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery, sortBy, sortOrder, ] ) const logsQuery = useLogsList(workspaceId, logFilters, { refetchInterval: isLive ? LIVE_REFRESH_INTERVAL_MS : false, }) const dashboardFilters = useMemo( () => ({ timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery: debouncedSearchQuery, }), [timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery] ) const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, { refetchInterval: isLive ? LIVE_REFRESH_INTERVAL_MS : false, }) const logs = useMemo(() => { return logsQuery.data?.pages?.flatMap((page) => page.logs) ?? [] }, [logsQuery.data?.pages]) const selectedLogIndex = selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1 const selectedLogFromList = selectedLogIndex >= 0 ? logs[selectedLogIndex] : null const selectedLog = selectedDetailQuery.data ?? selectedLogFromList ?? null const handleLogHover = useCallback( (rowId: string) => { prefetchLogDetail(queryClient, rowId, workspaceId) }, [queryClient, workspaceId] ) useFolders(workspaceId) logsRef.current = logs selectedLogIndexRef.current = selectedLogIndex selectedLogIdRef.current = selectedLogId logsRefetchRef.current = logsQuery.refetch activeLogRefetchRef.current = selectedDetailQuery.refetch logsQueryRef.current = { isFetching: logsQuery.isFetching, hasNextPage: logsQuery.hasNextPage ?? false, fetchNextPage: logsQuery.fetchNextPage, } const deepLinkQuery = useLogByExecutionId(workspaceId, pendingExecutionId) useEffect(() => { if (!pendingExecutionId) return const resolvedId = deepLinkQuery.data?.id if (resolvedId) { dispatch({ type: 'TOGGLE_LOG', logId: resolvedId }) setPendingExecutionId(null) } else if (deepLinkQuery.isError) { setPendingExecutionId(null) } }, [pendingExecutionId, deepLinkQuery.data, deepLinkQuery.isError]) useEffect(() => { const timers = refreshTimersRef.current return () => { timers.forEach((id) => window.clearTimeout(id)) timers.clear() } }, []) const handleLogClick = useCallback((rowId: string) => { dispatch({ type: 'TOGGLE_LOG', logId: rowId }) }, []) const handleNavigateNext = useCallback(() => { const idx = selectedLogIndexRef.current const currentLogs = logsRef.current if (idx >= 0 && idx < currentLogs.length - 1) { shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id }) } }, []) const handleNavigatePrev = useCallback(() => { const idx = selectedLogIndexRef.current if (idx > 0) { shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id }) } }, []) const handleCloseSidebar = useCallback(() => { dispatch({ type: 'CLOSE_SIDEBAR' }) activeLogTabRef.current = 'overview' }, []) /** * Strip the `tab` param whenever the detail panel transitions from open to * closed — by the X button, toggling the same row, or the keyboard — so * reopening another log starts on overview rather than inheriting the closed * log's tab. Guarded on a prior-open ref so an initial deep-linked `?tab=` is * preserved (the panel isn't open yet on first mount). */ const wasSidebarOpenRef = useRef(false) useEffect(() => { if (isSidebarOpen) { wasSidebarOpenRef.current = true } else if (wasSidebarOpenRef.current) { wasSidebarOpenRef.current = false setLogDetailsTab(null) } }, [isSidebarOpen, setLogDetailsTab]) const handleActiveTabChange = useCallback((tab: string) => { activeLogTabRef.current = tab }, []) const handleLogContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { e.preventDefault() const log = logs.find((l) => l.id === rowId) ?? null setContextMenuPosition({ x: e.clientX, y: e.clientY }) setContextMenuLog(log) setContextMenuOpen(true) }, [logs] ) const handleCopyExecutionId = useCallback(() => { if (contextMenuLog?.executionId) { navigator.clipboard.writeText(contextMenuLog.executionId).catch(() => {}) } }, [contextMenuLog]) const handleCopyLink = useCallback(() => { if (contextMenuLog?.executionId) { const url = `${window.location.origin}/workspace/${workspaceId}/logs?executionId=${contextMenuLog.executionId}` navigator.clipboard.writeText(url).catch(() => {}) } }, [contextMenuLog, workspaceId]) const handleOpenWorkflow = useCallback(() => { const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId if (wfId) { window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank') } }, [contextMenuLog, workspaceId]) const handleToggleWorkflowFilter = useCallback(() => { const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId if (!wfId) return if (workflowIds.length === 1 && workflowIds[0] === wfId) { setWorkflowIds([]) } else { setWorkflowIds([wfId]) } }, [contextMenuLog, workflowIds, setWorkflowIds]) /** * `resetFilters()` already clears `search` (sets it to null), so no separate * search reset is needed here. */ const handleClearAllFilters = useCallback(() => { resetFilters() }, [resetFilters]) const handleOpenPreview = useCallback(() => { if (contextMenuLog?.id) { setPreviewLogId(contextMenuLog.id) } }, [contextMenuLog]) const cancelExecution = useCancelExecution(workspaceId) const retryExecution = useRetryExecution() const handleCancelExecution = useCallback(() => { const workflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId const executionId = contextMenuLog?.executionId if (workflowId && executionId) { cancelExecution.mutate({ workflowId, executionId }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [contextMenuLog]) const retryLog = useCallback( async (log: WorkflowLogRow | null) => { const workflowId = log?.workflow?.id || log?.workflowId const logId = log?.id if (!workflowId || !logId) return try { const detailLog = await queryClient.fetchQuery({ queryKey: logKeys.detail(workspaceId, logId), queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal), staleTime: 30 * 1000, }) const input = extractRetryInput(detailLog) await retryExecution.mutateAsync({ workflowId, input }) toast.success('Retry started') } catch { toast.error('Failed to retry execution') } }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ) const handleRetryExecution = useCallback(() => { retryLog(contextMenuLog) }, [contextMenuLog, retryLog]) const handleRetrySidebarExecution = useCallback(() => { retryLog(selectedLog) }, [selectedLog, retryLog]) const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId const isFilteredByThisWorkflow = Boolean( contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId ) const filtersActive = hasActiveFilters({ timeRange, level, workflowIds, folderIds, triggers, searchQuery: debouncedSearchQuery, }) useEffect(() => { if (!selectedLogId || !shouldScrollIntoViewRef.current) return shouldScrollIntoViewRef.current = false // Route through the virtualizer; a querySelector would miss windowed-out rows. resourceTableRef.current?.scrollToRow(selectedLogId) }, [selectedLogId, selectedLogIndex]) const effectiveSidebarOpen = isSidebarOpen && (selectedLogIndex !== -1 || !!selectedDetailQuery.data) const triggerVisualRefresh = useCallback(() => { setIsVisuallyRefreshing(true) const timerId = window.setTimeout(() => { setIsVisuallyRefreshing(false) refreshTimersRef.current.delete(timerId) }, REFRESH_SPINNER_DURATION_MS) refreshTimersRef.current.add(timerId) }, []) const handleRefresh = useCallback(() => { triggerVisualRefresh() logsRefetchRef.current() if (selectedLogIdRef.current) { activeLogRefetchRef.current() } }, [triggerVisualRefresh]) const prevIsFetchingRef = useRef(logsQuery.isFetching) useEffect(() => { const wasFetching = prevIsFetchingRef.current const isFetching = logsQuery.isFetching prevIsFetchingRef.current = isFetching if (isLive && !wasFetching && isFetching) { triggerVisualRefresh() } }, [logsQuery.isFetching, isLive, triggerVisualRefresh]) const handleExport = useCallback(async () => { setIsExporting(true) try { 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 computedStartDate = getStartDateFromTimeRange(timeRange, startDate) if (computedStartDate) { params.set('startDate', computedStartDate.toISOString()) } const computedEndDate = getEndDateFromTimeRange(timeRange, endDate) if (computedEndDate) { params.set('endDate', computedEndDate.toISOString()) } 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() } finally { setIsExporting(false) } }, [ workspaceId, level, triggers, workflowIds, folderIds, timeRange, startDate, endDate, debouncedSearchQuery, ]) const loadMoreLogs = useCallback(() => { const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!isFetching && hasNextPage) { fetchNextPage() } }, []) const handleNavigateNextEvent = useEffectEvent(handleNavigateNext) const handleNavigatePrevEvent = useEffectEvent(handleNavigatePrev) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return if (activeLogTabRef.current === 'trace') return const currentLogs = logsRef.current const currentIndex = selectedLogIndexRef.current if (currentLogs.length === 0) return if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault() shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id }) return } if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && currentIndex > 0) { e.preventDefault() handleNavigatePrevEvent() } if ( e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey && currentIndex < currentLogs.length - 1 ) { e.preventDefault() handleNavigateNextEvent() } if (e.key === 'Enter' && selectedLogIdRef.current) { e.preventDefault() dispatch({ type: 'TOGGLE_SIDEBAR' }) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, []) const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), []) function handleClosePreview() { setPreviewLogId(null) } const isDashboardView = viewMode === 'dashboard' const rows: ResourceRow[] = useMemo( () => logs.map((log) => { const formattedDate = formatDate(log.createdAt) const displayStatus = getDisplayStatus(log.status) const isMothershipJob = log.trigger === 'mothership' const isDeletedWorkflow = !isMothershipJob && !log.workflow?.id && !log.workflowId const workflowName = isMothershipJob ? log.jobTitle || 'Untitled Job' : isDeletedWorkflow ? DELETED_WORKFLOW_LABEL : log.workflow?.name || 'Unknown' const durationMs = parseDuration({ duration: log.duration ?? undefined }) const durationText = durationMs != null ? (formatDuration(durationMs, { precision: 2 }) ?? '—') : '—' const costCredits = typeof log.cost?.total === 'number' ? dollarsToCredits(log.cost.total) : null const costText = costCredits !== null ? `${costCredits.toLocaleString()} ${costCredits === 1 ? 'credit' : 'credits'}` : '—' return { id: log.id, cells: { workflow: { label: workflowName, }, date: { label: `${formattedDate.compactDate} ${formattedDate.compactTime}` }, status: { content: }, cost: { label: costText }, trigger: { content: }, duration: { label: durationText }, }, } }), [logs] ) const sidebarOverlay = ( = 0 && selectedLogIndex < logs.length - 1} hasPrev={selectedLogIndex > 0} onRetryExecution={handleRetrySidebarExecution} isRetryPending={retryExecution.isPending} onActiveTabChange={handleActiveTabChange} /> ) const { data: allWorkflows = {} } = useWorkflowMap(workspaceId) const { data: folders = {} } = useFolderMap(workspaceId) const filterTags = useMemo(() => { const tags: FilterTag[] = [] if (level && level !== 'all') { const statuses = level.split(',').filter(Boolean) const labels = statuses.map((s) => STATUS_CONFIG[s as LogStatus]?.label ?? s) tags.push({ label: `Status: ${labels.join(', ')}`, onRemove: () => setLevel('all'), }) } if (workflowIds.length > 0) { const names = workflowIds.map((id) => allWorkflows[id]?.name ?? id.slice(0, 8)) tags.push({ label: `Workflow: ${names.join(', ')}`, onRemove: () => setWorkflowIds([]), }) } if (folderIds.length > 0) { const names = folderIds.map((id) => folders[id]?.name ?? id.slice(0, 8)) tags.push({ label: `Folder: ${names.join(', ')}`, onRemove: () => setFolderIds([]), }) } if (triggers.length > 0) { tags.push({ label: `Trigger: ${triggers.join(', ')}`, onRemove: () => setTriggers([]), }) } if (timeRange !== 'All time') { tags.push({ label: timeRange === 'Custom range' && startDate && endDate ? `${formatDateShort(startDate)} – ${formatDateShort(endDate)}` : timeRange, onRemove: () => { clearDateRange() setTimeRange('All time') }, }) } return tags }, [ level, setLevel, workflowIds, setWorkflowIds, allWorkflows, folderIds, setFolderIds, folders, triggers, setTriggers, timeRange, startDate, endDate, clearDateRange, setTimeRange, ]) const workflowsData = useMemo( () => Object.values(allWorkflows).map((w) => ({ id: w.id, name: w.name, description: w.description, })), [allWorkflows] ) const foldersData = useMemo( () => Object.values(folders).map((f) => ({ id: f.id, name: f.name })), [folders] ) const triggersData = useMemo( () => getTriggerOptions().map((t) => ({ value: t.value, label: t.label, color: t.color })), [] ) const suggestionEngine = useMemo( () => new SearchSuggestions(workflowsData, foldersData, triggersData), [workflowsData, foldersData, triggersData] ) const handleFiltersChange = useCallback( (filters: ParsedFilter[], textSearch: string) => { const filterStrings = filters.map( (f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}` ) const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ') setUrlSearchQuery(fullQuery) }, [setUrlSearchQuery] ) const getSuggestions = useCallback( (input: string) => suggestionEngine.getSuggestions(input), [suggestionEngine] ) const { appliedFilters, currentInput, textSearch, isOpen: isSuggestionsOpen, suggestions, sections, highlightedIndex, highlightedBadgeIndex, inputRef: searchInputRef, dropdownRef: searchDropdownRef, handleInputChange: handleSearchInputChange, handleSuggestionSelect, handleKeyDown: handleSearchKeyDown, handleFocus: handleSearchFocus, handleBlur: handleSearchBlur, removeBadge, clearAll: clearSearch, setHighlightedIndex, initializeFromQuery, } = useSearchState({ onFiltersChange: handleFiltersChange, getSuggestions, }) const lastExternalSearchValue = useRef(undefined) useEffect(() => { if (urlSearchQuery === lastExternalSearchValue.current) return const isMount = lastExternalSearchValue.current === undefined lastExternalSearchValue.current = urlSearchQuery // On mount with no initial query, skip the no-op parse if (isMount && !urlSearchQuery) return const parsed = parseQuery(urlSearchQuery) initializeFromQuery(parsed.textSearch, parsed.filters) }, [urlSearchQuery, initializeFromQuery]) useEffect(() => { if (!isSuggestionsOpen || highlightedIndex < 0) return const container = searchDropdownRef.current const el = container?.querySelector(`[data-index="${highlightedIndex}"]`) if (container && el) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } }, [isSuggestionsOpen, highlightedIndex]) const suggestionsDropdown = useMemo(() => { if (!isSuggestionsOpen || suggestions.length === 0) return undefined const suggestionType = sections.length > 0 ? 'multi-section' : (suggestions[0]?.category ?? null) return (
{sections.length > 0 ? (
{suggestions[0]?.category === 'show-all' && ( )} {sections.map((section) => (
{section.title}
{section.suggestions.map((suggestion) => { if (suggestion.category === 'show-all') return null const index = suggestions.indexOf(suggestion) return ( ) })}
))}
) : (
{suggestionType === 'filters' && (
SUGGESTED FILTERS
)} {suggestions.map((suggestion, index) => ( ))}
)}
) }, [ isSuggestionsOpen, suggestions, sections, highlightedIndex, setHighlightedIndex, handleSuggestionSelect, ]) const searchTags = useMemo( () => [ ...appliedFilters.map((f, i) => ({ label: f.field, value: `${f.operator !== '=' ? f.operator : ''}${f.originalValue}`, onRemove: () => removeBadge(i), })), ...(textSearch ? [ { label: 'search', value: textSearch, onRemove: () => handleFiltersChange(appliedFilters, ''), }, ] : []), ], [appliedFilters, textSearch, removeBadge, handleFiltersChange] ) const sortConfig = useMemo( () => ({ options: [ { id: 'date', label: 'Date' }, { id: 'duration', label: 'Duration' }, { id: 'cost', label: 'Cost' }, { id: 'status', label: 'Status' }, ], active: activeSort, onSort: (column, direction) => setActiveSort({ column, direction }), onClear: () => setActiveSort(null), }), [activeSort] ) const searchConfig = useMemo( () => ({ value: currentInput, onChange: handleSearchInputChange, placeholder: 'Search logs...', inputRef: searchInputRef, onKeyDown: handleSearchKeyDown, onFocus: handleSearchFocus, onBlur: handleSearchBlur, tags: searchTags.length > 0 ? searchTags : undefined, highlightedTagIndex: highlightedBadgeIndex, onClearAll: clearSearch, dropdown: suggestionsDropdown, dropdownRef: searchDropdownRef, }), [ currentInput, handleSearchInputChange, searchInputRef, handleSearchKeyDown, handleSearchFocus, handleSearchBlur, searchTags, highlightedBadgeIndex, clearSearch, suggestionsDropdown, searchDropdownRef, ] ) const refreshIcon = isVisuallyRefreshing ? SpinningRefreshCw : RefreshCw const headerActions = useMemo( () => [ { text: 'Export', icon: Download, onSelect: handleExport, disabled: !userPermissions.canEdit || isExporting || logs.length === 0, }, { text: 'Refresh', icon: refreshIcon, onSelect: handleRefresh, disabled: isVisuallyRefreshing, }, { text: 'Logs', onSelect: () => setViewMode('logs'), active: !isDashboardView, }, { text: 'Dashboard', onSelect: () => setViewMode('dashboard'), active: isDashboardView, }, ], [ isDashboardView, setViewMode, refreshIcon, isVisuallyRefreshing, handleRefresh, handleExport, userPermissions.canEdit, isExporting, logs.length, ] ) return ( <> ), }} filterTags={filterTags} /> {isDashboardView ? (
{sidebarOverlay}
) : ( )}
{previewLogId !== null && previewDetailQuery.data?.executionId && ( )} ) } interface LogsFilterPanelProps { searchQuery: string onSearchQueryChange: (query: string) => void } function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelProps) { const params = useParams() const workspaceId = params.workspaceId as string const { level, setLevel, workflowIds, setWorkflowIds, folderIds, setFolderIds, triggers, setTriggers, timeRange, setTimeRange, startDate, endDate, setDateRange, clearDateRange, resetFilters, } = useLogFilters() const [datePickerOpen, setDatePickerOpen] = useState(false) const previousTimeRangeRef = useRef(timeRange) const dateRangeAppliedRef = useRef(false) const { data: folders = {} } = useFolderMap(workspaceId) const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const workflows = allWorkflowList.map((w) => ({ id: w.id, name: w.name })) const folderList = Object.values(folders).filter((f) => f.workspaceId === workspaceId) const selectedStatuses = level === 'all' || !level ? [] : level.split(',').filter(Boolean) const statusOptions: ComboboxOption[] = useMemo( () => (Object.keys(STATUS_CONFIG) as LogStatus[]) .filter((status) => STATUS_CONFIG[status].filterable) .map((status) => ({ value: status, label: STATUS_CONFIG[status].label, icon: getColorIcon(STATUS_CONFIG[status].color), })), [] ) const handleStatusChange = (values: string[]) => { setLevel(values.length === 0 ? 'all' : values.join(',')) } const statusDisplayLabel = selectedStatuses.length === 0 ? 'Status' : selectedStatuses.length === 1 ? statusOptions.find((s) => s.value === selectedStatuses[0])?.label || '1 selected' : `${selectedStatuses.length} selected` const selectedStatusColor = selectedStatuses.length === 1 ? (STATUS_CONFIG[selectedStatuses[0] as LogStatus]?.color ?? null) : null const workflowOptions: ComboboxOption[] = workflows.map((w) => ({ value: w.id, label: w.name, icon: WorkflowOptionIcon, })) const workflowDisplayLabel = workflowIds.length === 0 ? 'Workflow' : workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0])?.name || '1 selected' : `${workflowIds.length} workflows` const selectedWorkflow = workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null const folderOptions: ComboboxOption[] = folderList.map((f) => ({ value: f.id, label: f.name })) const folderDisplayLabel = folderIds.length === 0 ? 'Folder' : folderIds.length === 1 ? folderList.find((f) => f.id === folderIds[0])?.name || '1 selected' : `${folderIds.length} folders` const triggerOptions: ComboboxOption[] = useMemo( () => getTriggerOptions().map((t) => ({ value: t.value, label: t.label, icon: getTriggerIcon(t.value), })), [] ) const triggerDisplayLabel = triggers.length === 0 ? 'Trigger' : triggers.length === 1 ? triggerOptions.find((t) => t.value === triggers[0])?.label || '1 selected' : `${triggers.length} triggers` const timeDisplayLabel = timeRange === 'All time' ? 'Time' : timeRange === 'Custom range' && startDate && endDate ? `${formatDateShort(startDate)} - ${formatDateShort(endDate)}` : timeRange === 'Custom range' ? 'Custom range' : timeRange const handleTimeRangeChange = (val: string) => { if (val === 'Custom range') { previousTimeRangeRef.current = timeRange setDatePickerOpen(true) } else { clearDateRange() setTimeRange(val as typeof timeRange) } } const handleDateRangeApply = (start: string, end: string) => { dateRangeAppliedRef.current = true setDateRange(start, end) setDatePickerOpen(false) } const handleDatePickerCancel = () => { if (timeRange === 'Custom range' && !startDate) { setTimeRange(previousTimeRangeRef.current) } setDatePickerOpen(false) } const filtersActive = hasActiveFilters({ timeRange, level, workflowIds, folderIds, triggers, searchQuery, }) const handleClearFilters = () => { resetFilters() onSearchQueryChange('') } return (
Status {selectedStatusColor && (
)} {statusDisplayLabel} } showAllOption allOptionLabel='All statuses' className='w-full' />
Workflow {selectedWorkflow && ( )} {workflowDisplayLabel} } searchable searchPlaceholder='Search workflows...' showAllOption allOptionLabel='All workflows' className='w-full' />
Folder {folderDisplayLabel} } searchable searchPlaceholder='Search folders...' showAllOption allOptionLabel='All folders' className='w-full' />
Trigger {triggerDisplayLabel} } searchable searchPlaceholder='Search triggers...' showAllOption allOptionLabel='All triggers' className='w-full' />
Time Range
{timeDisplayLabel} } className='w-full' maxHeight={320} /> { if (!isOpen) { if (dateRangeAppliedRef.current) { dateRangeAppliedRef.current = false } else { handleDatePickerCancel() } } }} >
{filtersActive && ( )}
) } function SuggestionButton({ suggestion, index, highlighted, onHover, onSelect, showCategory, }: { suggestion: Suggestion index: number highlighted: boolean onHover: (i: number) => void onSelect: (s: Suggestion) => void showCategory?: boolean }) { return ( ) }