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
1517 lines
46 KiB
TypeScript
1517 lines
46 KiB
TypeScript
'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>
|
||
)
|
||
}
|