Files
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1517 lines
46 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string, React.ComponentType<{ className?: string }>>()
function getColorIcon(color: string): React.ComponentType<{ className?: string }> {
const cached = colorIconCache.get(color)
if (cached) return cached
const ColorIcon = ({ className }: { className?: string }) => (
<div
className={cn(className, 'flex-shrink-0 rounded-[3px]')}
style={{
backgroundColor: color,
width: 10,
height: 10,
}}
/>
)
ColorIcon.displayName = `ColorIcon(${color})`
colorIconCache.set(color, ColorIcon)
return ColorIcon
}
function WorkflowOptionIcon({ className }: { className?: string }) {
return <Workflow className={cn(className, 'flex-shrink-0 text-[var(--text-icon)]')} />
}
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 }) => (
<BlockIcon className={cn(className, 'flex-shrink-0')} style={{ width: 12, height: 12 }} />
)
TriggerIcon.displayName = `TriggerIcon(${triggerType})`
return TriggerIcon
}
function SpinningRefreshCw(props: React.SVGProps<SVGSVGElement>) {
return <RefreshCw {...props} animate />
}
/**
* 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<string | null>(() => 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<number>())
const logsRef = useRef<WorkflowLogSummary[]>([])
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const shouldScrollIntoViewRef = useRef(false)
const resourceTableRef = useRef<ResourceTableHandle>(null)
const logsRefetchRef = useRef<() => void>(() => {})
const activeLogRefetchRef = useRef<() => void>(() => {})
const activeLogTabRef = useRef<string>('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<WorkflowLogSummary | null>(null)
const [previewLogId, setPreviewLogId] = useState<string | null>(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: <StatusBadge status={displayStatus} /> },
cost: { label: costText },
trigger: { content: <TriggerBadge trigger={log.trigger || 'manual'} /> },
duration: { label: durationText },
},
}
}),
[logs]
)
const sidebarOverlay = (
<LogDetails
log={selectedLog}
isOpen={effectiveSidebarOpen}
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
hasNext={selectedLogIndex >= 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<FilterTag[]>(() => {
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<WorkflowData[]>(
() =>
Object.values(allWorkflows).map((w) => ({
id: w.id,
name: w.name,
description: w.description,
})),
[allWorkflows]
)
const foldersData = useMemo<FolderData[]>(
() => Object.values(folders).map((f) => ({ id: f.id, name: f.name })),
[folders]
)
const triggersData = useMemo<TriggerData[]>(
() => 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<string | undefined>(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 (
<div className='max-h-96 overflow-y-auto px-1'>
{sections.length > 0 ? (
<div className='py-1'>
{suggestions[0]?.category === 'show-all' && (
<SuggestionButton
suggestion={suggestions[0]}
index={0}
highlighted={highlightedIndex === 0}
onHover={setHighlightedIndex}
onSelect={handleSuggestionSelect}
/>
)}
{sections.map((section) => (
<div key={section.title}>
<div className='px-3 py-1.5 font-medium text-[var(--text-tertiary)] text-caption uppercase tracking-wide'>
{section.title}
</div>
{section.suggestions.map((suggestion) => {
if (suggestion.category === 'show-all') return null
const index = suggestions.indexOf(suggestion)
return (
<SuggestionButton
key={suggestion.id}
suggestion={suggestion}
index={index}
highlighted={index === highlightedIndex}
onHover={setHighlightedIndex}
onSelect={handleSuggestionSelect}
showCategory
/>
)
})}
</div>
))}
</div>
) : (
<div className='py-1'>
{suggestionType === 'filters' && (
<div className='px-3 py-1.5 font-medium text-[var(--text-tertiary)] text-caption uppercase tracking-wide'>
SUGGESTED FILTERS
</div>
)}
{suggestions.map((suggestion, index) => (
<SuggestionButton
key={suggestion.id}
suggestion={suggestion}
index={index}
highlighted={index === highlightedIndex}
onHover={setHighlightedIndex}
onSelect={handleSuggestionSelect}
/>
))}
</div>
)}
</div>
)
}, [
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<SortConfig>(
() => ({
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<SearchConfig>(
() => ({
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<ResourceAction[]>(
() => [
{
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 (
<>
<Resource>
<Resource.Header icon={Library} title='Logs' actions={headerActions} />
<Resource.Options
search={searchConfig}
sort={sortConfig}
filter={{
content: (
<LogsFilterPanel
searchQuery={urlSearchQuery}
onSearchQueryChange={setUrlSearchQuery}
/>
),
}}
filterTags={filterTags}
/>
{isDashboardView ? (
<div className='relative flex min-h-0 flex-1 flex-col overflow-auto'>
<div className='flex min-h-0 flex-1 flex-col px-6'>
<Dashboard
stats={dashboardStatsQuery.data}
isLoading={dashboardStatsQuery.isLoading}
error={dashboardStatsQuery.error}
searchQuery={debouncedSearchQuery}
/>
</div>
{sidebarOverlay}
</div>
) : (
<Resource.Table
apiRef={resourceTableRef}
virtualized
columns={LOG_COLUMNS}
rows={rows}
selectedRowId={selectedLogId}
onRowClick={handleLogClick}
onRowHover={handleLogHover}
onRowContextMenu={handleLogContextMenu}
onLoadMore={loadMoreLogs}
hasMore={logsQuery.hasNextPage ?? false}
isLoadingMore={logsQuery.isFetchingNextPage}
overlay={sidebarOverlay}
/>
)}
</Resource>
<LogRowContextMenu
isOpen={contextMenuOpen}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onCopyLink={handleCopyLink}
onOpenWorkflow={handleOpenWorkflow}
onOpenPreview={handleOpenPreview}
onCancelExecution={handleCancelExecution}
onRetryExecution={handleRetryExecution}
isRetryPending={retryExecution.isPending}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
onClearAllFilters={handleClearAllFilters}
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
hasActiveFilters={filtersActive}
/>
{previewLogId !== null && previewDetailQuery.data?.executionId && (
<ExecutionSnapshot
executionId={previewDetailQuery.data.executionId}
traceSpans={previewDetailQuery.data.executionData?.traceSpans}
isModal
isOpen={previewLogId !== null}
onClose={handleClosePreview}
/>
)}
</>
)
}
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 (
<div className='flex w-[240px] flex-col gap-4 p-3'>
<div className='flex flex-col gap-[9px]'>
<span className='text-[var(--text-muted)] text-small'>Status</span>
<ChipCombobox
options={statusOptions}
multiSelect
multiSelectValues={selectedStatuses}
onMultiSelectChange={handleStatusChange}
placeholder='All statuses'
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
{selectedStatusColor && (
<div
className='flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
/>
)}
<span className='truncate'>{statusDisplayLabel}</span>
</span>
}
showAllOption
allOptionLabel='All statuses'
className='w-full'
/>
</div>
<div className='flex flex-col gap-[9px]'>
<span className='text-[var(--text-muted)] text-small'>Workflow</span>
<ChipCombobox
options={workflowOptions}
multiSelect
multiSelectValues={workflowIds}
onMultiSelectChange={setWorkflowIds}
placeholder='All workflows'
overlayContent={
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
{selectedWorkflow && (
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
)}
<span className='truncate'>{workflowDisplayLabel}</span>
</span>
}
searchable
searchPlaceholder='Search workflows...'
showAllOption
allOptionLabel='All workflows'
className='w-full'
/>
</div>
<div className='flex flex-col gap-[9px]'>
<span className='text-[var(--text-muted)] text-small'>Folder</span>
<ChipCombobox
options={folderOptions}
multiSelect
multiSelectValues={folderIds}
onMultiSelectChange={setFolderIds}
placeholder='All folders'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
}
searchable
searchPlaceholder='Search folders...'
showAllOption
allOptionLabel='All folders'
className='w-full'
/>
</div>
<div className='flex flex-col gap-[9px]'>
<span className='text-[var(--text-muted)] text-small'>Trigger</span>
<ChipCombobox
options={triggerOptions}
multiSelect
multiSelectValues={triggers}
onMultiSelectChange={setTriggers}
placeholder='All triggers'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
}
searchable
searchPlaceholder='Search triggers...'
showAllOption
allOptionLabel='All triggers'
className='w-full'
/>
</div>
<div className='flex flex-col gap-[9px]'>
<span className='text-[var(--text-muted)] text-small'>Time Range</span>
<div className='relative'>
<ChipCombobox
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='All time'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
}
className='w-full'
maxHeight={320}
/>
<Popover
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
if (dateRangeAppliedRef.current) {
dateRangeAppliedRef.current = false
} else {
handleDatePickerCancel()
}
}
}}
>
<PopoverAnchor className='pointer-events-none absolute inset-0' />
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
<Calendar
mode='range'
showTime
startDate={startDate}
endDate={endDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</PopoverContent>
</Popover>
</div>
</div>
{filtersActive && (
<Button
variant='active'
onClick={handleClearFilters}
className='h-[32px] w-full rounded-md'
>
Clear All Filters
</Button>
)}
</div>
)
}
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 (
<Button
type='button'
variant='ghost'
data-index={index}
className={cn(
'h-auto w-full justify-start rounded-md px-3 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]',
highlighted && 'bg-[var(--surface-5)]'
)}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault()
onSelect(suggestion)
}}
>
<div className='flex w-full items-center justify-between gap-3'>
<div className='min-w-0 flex-1 truncate text-small'>{suggestion.label}</div>
{showCategory && suggestion.value !== suggestion.label && (
<div className='shrink-0 font-mono text-[var(--text-muted)] text-xs'>
{suggestion.category === 'workflow' || suggestion.category === 'folder'
? `${suggestion.category}:`
: ''}
</div>
)}
{!showCategory && suggestion.description && (
<div className='shrink-0 text-[var(--text-muted)] text-xs'>{suggestion.value}</div>
)}
</div>
</Button>
)
}