diff --git a/apps/sim/app/api/workspaces/[id]/execution-history/[workflowId]/route.ts b/apps/sim/app/api/workspaces/[id]/execution-history/[workflowId]/route.ts new file mode 100644 index 0000000000..6deedd304b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/execution-history/[workflowId]/route.ts @@ -0,0 +1,305 @@ +import { db } from '@sim/db' +import { permissions, workflowExecutionLogs } from '@sim/db/schema' +import { and, desc, eq, gte, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { generateRequestId } from '@/lib/utils' + +const logger = createLogger('WorkflowExecutionDetailsAPI') + +const QueryParamsSchema = z.object({ + timeFilter: z.enum(['1h', '12h', '24h', '1w']).optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + triggers: z.string().optional(), +}) + +function getTimeRangeMs(filter: string): number { + switch (filter) { + case '1h': + return 60 * 60 * 1000 + case '12h': + return 12 * 60 * 60 * 1000 + case '24h': + return 24 * 60 * 60 * 1000 + case '1w': + return 7 * 24 * 60 * 60 * 1000 + default: + return 24 * 60 * 60 * 1000 + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; workflowId: string }> } +) { + const requestId = generateRequestId() + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workflow details access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const { id: workspaceId, workflowId } = await params + const { searchParams } = new URL(request.url) + const queryParams = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + + // Calculate time range - use custom times if provided, otherwise use timeFilter + let endTime: Date + let startTime: Date + + if (queryParams.startTime && queryParams.endTime) { + startTime = new Date(queryParams.startTime) + endTime = new Date(queryParams.endTime) + } else { + endTime = new Date() + const timeRangeMs = getTimeRangeMs(queryParams.timeFilter || '24h') + startTime = new Date(endTime.getTime() - timeRangeMs) + } + + const timeRangeMs = endTime.getTime() - startTime.getTime() + + // Number of data points for the line charts + const dataPoints = 30 + const segmentDurationMs = timeRangeMs / dataPoints + + logger.debug(`[${requestId}] Fetching workflow details for ${workflowId}`) + + // Check permissions + const [permission] = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.userId, userId) + ) + ) + .limit(1) + + if (!permission) { + logger.warn(`[${requestId}] User ${userId} has no permission for workspace ${workspaceId}`) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Build conditions for log filtering + const logConditions = [ + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.startedAt, startTime), + ] + + // Add trigger filter if specified + if (queryParams.triggers) { + const triggerList = queryParams.triggers.split(',') + logConditions.push(inArray(workflowExecutionLogs.trigger, triggerList)) + } + + // Fetch all logs for this workflow in the time range + const logs = await db + .select({ + id: workflowExecutionLogs.id, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + }) + .from(workflowExecutionLogs) + .where(and(...logConditions)) + .orderBy(desc(workflowExecutionLogs.startedAt)) + .limit(50) + + // Calculate metrics for each time segment + const errorRates: { timestamp: string; value: number }[] = [] + const durations: { timestamp: string; value: number }[] = [] + const executionCounts: { timestamp: string; value: number }[] = [] + + for (let i = 0; i < dataPoints; i++) { + const segmentStart = new Date(startTime.getTime() + i * segmentDurationMs) + const segmentEnd = new Date(startTime.getTime() + (i + 1) * segmentDurationMs) + + // Filter logs for this segment + const segmentLogs = logs.filter((log) => { + const logTime = log.startedAt.getTime() + return logTime >= segmentStart.getTime() && logTime < segmentEnd.getTime() + }) + + const totalExecutions = segmentLogs.length + const errorExecutions = segmentLogs.filter((log) => log.level === 'error').length + const errorRate = totalExecutions > 0 ? (errorExecutions / totalExecutions) * 100 : 0 + + // Calculate average duration for this segment + const durationsInSegment = segmentLogs + .filter((log) => log.totalDurationMs !== null) + .map((log) => log.totalDurationMs!) + const avgDuration = + durationsInSegment.length > 0 + ? durationsInSegment.reduce((sum, d) => sum + d, 0) / durationsInSegment.length + : 0 + + errorRates.push({ + timestamp: segmentStart.toISOString(), + value: errorRate, + }) + + durations.push({ + timestamp: segmentStart.toISOString(), + value: avgDuration, + }) + + executionCounts.push({ + timestamp: segmentStart.toISOString(), + value: totalExecutions, + }) + } + + // Helper function to recursively search for error in trace spans + const findErrorInSpans = (spans: any[]): string | null => { + for (const span of spans) { + if (span.status === 'error' && span.output?.error) { + return span.output.error + } + if (span.children && Array.isArray(span.children)) { + const childError = findErrorInSpans(span.children) + if (childError) return childError + } + } + return null + } + + // Helper function to get all blocks from trace spans (flattened) + const flattenTraceSpans = (spans: any[]): any[] => { + const flattened: any[] = [] + for (const span of spans) { + if (span.type !== 'workflow') { + flattened.push(span) + } + if (span.children && Array.isArray(span.children)) { + flattened.push(...flattenTraceSpans(span.children)) + } + } + return flattened + } + + // Format logs for response + const formattedLogs = logs.map((log) => { + const executionData = log.executionData as any + const triggerData = executionData?.trigger || {} + const traceSpans = executionData?.traceSpans || [] + + // Extract error message from trace spans + let errorMessage = null + if (log.level === 'error') { + errorMessage = findErrorInSpans(traceSpans) + // Fallback to executionData.errorDetails + if (!errorMessage) { + errorMessage = executionData?.errorDetails?.error || null + } + } + + // Extract outputs from the last block in trace spans + let outputs = null + let cost = null + + if (traceSpans.length > 0) { + // Flatten all blocks from trace spans + const allBlocks = flattenTraceSpans(traceSpans) + + // Find the last successful block execution + const successBlocks = allBlocks.filter( + (span: any) => + span.status !== 'error' && span.output && Object.keys(span.output).length > 0 + ) + + if (successBlocks.length > 0) { + const lastBlock = successBlocks[successBlocks.length - 1] + const blockOutput = lastBlock.output || {} + + // Clean up the output to show meaningful data + // Priority: content > result > data > the whole output object + if (blockOutput.content) { + outputs = { content: blockOutput.content } + } else if (blockOutput.result !== undefined) { + outputs = { result: blockOutput.result } + } else if (blockOutput.data !== undefined) { + outputs = { data: blockOutput.data } + } else { + // Filter out internal/metadata fields for cleaner display + const cleanOutput: any = {} + for (const [key, value] of Object.entries(blockOutput)) { + if ( + ![ + 'executionTime', + 'tokens', + 'model', + 'cost', + 'childTraceSpans', + 'error', + 'stackTrace', + ].includes(key) + ) { + cleanOutput[key] = value + } + } + if (Object.keys(cleanOutput).length > 0) { + outputs = cleanOutput + } + } + + // Extract cost from the block output + if (blockOutput.cost) { + cost = blockOutput.cost + } + } + } + + // Use the cost stored at the top-level in workflowExecutionLogs table + // This is the same cost shown in the logs page + const logCost = log.cost as any + + return { + id: log.id, + executionId: log.executionId, + startedAt: log.startedAt.toISOString(), + level: log.level, + trigger: log.trigger, + triggerUserId: triggerData.userId || null, + triggerInputs: triggerData.inputs || triggerData.data || null, + outputs, + errorMessage, + duration: log.totalDurationMs, + cost: logCost + ? { + input: logCost.input || 0, + output: logCost.output || 0, + total: logCost.total || 0, + } + : null, + } + }) + + logger.debug(`[${requestId}] Successfully calculated workflow details`) + + logger.debug(`[${requestId}] Returning ${formattedLogs.length} execution logs`) + + return NextResponse.json({ + errorRates, + durations, + executionCounts, + logs: formattedLogs, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching workflow details:`, error) + return NextResponse.json({ error: 'Failed to fetch workflow details' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workspaces/[id]/execution-history/route.ts b/apps/sim/app/api/workspaces/[id]/execution-history/route.ts new file mode 100644 index 0000000000..3716d3225c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/execution-history/route.ts @@ -0,0 +1,223 @@ +import { db } from '@sim/db' +import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq, gte, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { generateRequestId } from '@/lib/utils' + +const logger = createLogger('ExecutionHistoryAPI') + +const QueryParamsSchema = z.object({ + timeFilter: z.enum(['1h', '12h', '24h', '1w']).optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), + segments: z.coerce.number().min(1).max(200).default(120), + workflowIds: z.string().optional(), + folderIds: z.string().optional(), + triggers: z.string().optional(), +}) + +interface TimeSegment { + successRate: number + timestamp: string + hasExecutions: boolean + totalExecutions: number + successfulExecutions: number +} + +interface WorkflowExecution { + workflowId: string + workflowName: string + segments: TimeSegment[] + overallSuccessRate: number +} + +function getTimeRangeMs(filter: string): number { + switch (filter) { + case '1h': + return 60 * 60 * 1000 + case '12h': + return 12 * 60 * 60 * 1000 + case '24h': + return 24 * 60 * 60 * 1000 + case '1w': + return 7 * 24 * 60 * 60 * 1000 + default: + return 24 * 60 * 60 * 1000 + } +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = generateRequestId() + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized execution history access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const { id: workspaceId } = await params + const { searchParams } = new URL(request.url) + const queryParams = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + + // Calculate time range - use custom times if provided, otherwise use timeFilter + let endTime: Date + let startTime: Date + + if (queryParams.startTime && queryParams.endTime) { + startTime = new Date(queryParams.startTime) + endTime = new Date(queryParams.endTime) + } else { + endTime = new Date() + const timeRangeMs = getTimeRangeMs(queryParams.timeFilter || '24h') + startTime = new Date(endTime.getTime() - timeRangeMs) + } + + const timeRangeMs = endTime.getTime() - startTime.getTime() + const segmentDurationMs = timeRangeMs / queryParams.segments + + logger.debug(`[${requestId}] Fetching execution history for workspace ${workspaceId}`) + logger.debug( + `[${requestId}] Time range: ${startTime.toISOString()} to ${endTime.toISOString()}` + ) + logger.debug( + `[${requestId}] Segments: ${queryParams.segments}, duration: ${segmentDurationMs}ms` + ) + + // Check permissions + const [permission] = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.userId, userId) + ) + ) + .limit(1) + + if (!permission) { + logger.warn(`[${requestId}] User ${userId} has no permission for workspace ${workspaceId}`) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Build workflow query conditions + const workflowConditions = [eq(workflow.workspaceId, workspaceId)] + + // Apply workflow ID filter + if (queryParams.workflowIds) { + const workflowIdList = queryParams.workflowIds.split(',') + workflowConditions.push(inArray(workflow.id, workflowIdList)) + } + + // Apply folder ID filter + if (queryParams.folderIds) { + const folderIdList = queryParams.folderIds.split(',') + workflowConditions.push(inArray(workflow.folderId, folderIdList)) + } + + // Get all workflows in the workspace with optional filters + const workflows = await db + .select({ + id: workflow.id, + name: workflow.name, + }) + .from(workflow) + .where(and(...workflowConditions)) + + logger.debug(`[${requestId}] Found ${workflows.length} workflows`) + + // Use Promise.all to fetch logs in parallel per workflow + // This is better than single query when workflows have 10k+ logs each + const workflowExecutions: WorkflowExecution[] = await Promise.all( + workflows.map(async (wf) => { + // Build conditions for log filtering + const logConditions = [ + eq(workflowExecutionLogs.workflowId, wf.id), + gte(workflowExecutionLogs.startedAt, startTime), + ] + + // Add trigger filter if specified + if (queryParams.triggers) { + const triggerList = queryParams.triggers.split(',') + logConditions.push(inArray(workflowExecutionLogs.trigger, triggerList)) + } + + // Fetch logs for this workflow - runs in parallel with others + const logs = await db + .select({ + id: workflowExecutionLogs.id, + level: workflowExecutionLogs.level, + startedAt: workflowExecutionLogs.startedAt, + }) + .from(workflowExecutionLogs) + .where(and(...logConditions)) + + // Initialize segments with timestamps + const segments: TimeSegment[] = [] + let totalSuccess = 0 + let totalExecutions = 0 + + for (let i = 0; i < queryParams.segments; i++) { + const segmentStart = new Date(startTime.getTime() + i * segmentDurationMs) + const segmentEnd = new Date(startTime.getTime() + (i + 1) * segmentDurationMs) + + // Count executions in this segment + const segmentLogs = logs.filter((log) => { + const logTime = log.startedAt.getTime() + return logTime >= segmentStart.getTime() && logTime < segmentEnd.getTime() + }) + + const segmentTotal = segmentLogs.length + const segmentErrors = segmentLogs.filter((log) => log.level === 'error').length + const segmentSuccess = segmentTotal - segmentErrors + + // Calculate success rate (default to 100% if no executions in this segment) + const hasExecutions = segmentTotal > 0 + const successRate = hasExecutions ? (segmentSuccess / segmentTotal) * 100 : 100 + + segments.push({ + successRate, + timestamp: segmentStart.toISOString(), + hasExecutions, + totalExecutions: segmentTotal, + successfulExecutions: segmentSuccess, + }) + + totalExecutions += segmentTotal + totalSuccess += segmentSuccess + } + + // Calculate overall success rate (percentage of non-errored executions) + const overallSuccessRate = + totalExecutions > 0 ? (totalSuccess / totalExecutions) * 100 : 100 + + return { + workflowId: wf.id, + workflowName: wf.name, + segments, + overallSuccessRate, + } + }) + ) + + logger.debug( + `[${requestId}] Successfully calculated execution history for ${workflowExecutions.length} workflows` + ) + + return NextResponse.json({ + workflows: workflowExecutions, + segments: queryParams.segments, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching execution history:`, error) + return NextResponse.json({ error: 'Failed to fetch execution history' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/timeline.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/timeline.tsx index 6345ed8819..5ed34e243f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/timeline.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/timeline.tsx @@ -12,7 +12,12 @@ import type { TimeRange } from '@/stores/logs/filters/types' export default function Timeline() { const { timeRange, setTimeRange } = useFilterStore() - const specificTimeRanges: TimeRange[] = ['Past 30 minutes', 'Past hour', 'Past 24 hours'] + const specificTimeRanges: TimeRange[] = [ + 'Past 30 minutes', + 'Past hour', + 'Past 12 hours', + 'Past 24 hours', + ] return ( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/executions-dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/executions-dashboard.tsx new file mode 100644 index 0000000000..11ea703ed5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/executions-dashboard.tsx @@ -0,0 +1,1155 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { + ChevronLeft, + ChevronRight, + ExternalLink, + Info, + Loader2, + RotateCcw, + Search, +} from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Switch } from '@/components/ui/switch' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date' +import { formatCost } from '@/providers/utils' +import { useFilterStore } from '@/stores/logs/filters/store' + +type TimeFilter = '1w' | '24h' | '12h' | '1h' + +const getTriggerColor = (trigger: string | null | undefined): string => { + if (!trigger) return '#9ca3af' + + switch (trigger.toLowerCase()) { + case 'manual': + return '#9ca3af' // gray-400 (matches secondary styling better) + case 'schedule': + return '#10b981' // green (emerald-500) + case 'webhook': + return '#f97316' // orange (orange-500) + case 'chat': + return '#8b5cf6' // purple (violet-500) + case 'api': + return '#3b82f6' // blue (blue-500) + default: + return '#9ca3af' // gray-400 + } +} + +interface WorkflowExecution { + workflowId: string + workflowName: string + segments: { + successRate: number // 0-100 + timestamp: string + hasExecutions: boolean + totalExecutions: number + successfulExecutions: number + }[] + overallSuccessRate: number +} + +const BAR_COUNT = 120 + +function StatusBar({ + segments, + selectedSegmentIndex, + onSegmentClick, + workflowId, +}: { + segments: { + successRate: number + hasExecutions: boolean + totalExecutions: number + successfulExecutions: number + timestamp: string + }[] + selectedSegmentIndex: number | null + onSegmentClick: (workflowId: string, index: number, timestamp: string) => void + workflowId: string +}) { + return ( + +
+ {segments.map((segment, i) => { + let color: string + let tooltipContent: React.ReactNode + const isSelected = selectedSegmentIndex === i + + if (!segment.hasExecutions) { + color = 'bg-gray-300 dark:bg-gray-600' + tooltipContent = ( +
+
No executions
+
+ ) + } else { + if (segment.successRate === 100) { + color = 'bg-emerald-500' + } else if (segment.successRate >= 95) { + color = 'bg-amber-500' + } else { + color = 'bg-red-500' + } + + tooltipContent = ( +
+
{segment.successRate.toFixed(1)}%
+
+ {segment.successfulExecutions ?? 0}/{segment.totalExecutions ?? 0} executions + succeeded +
+
Click to filter
+
+ ) + } + + return ( + + +
{ + e.stopPropagation() + onSegmentClick(workflowId, i, segment.timestamp) + }} + /> + + + {tooltipContent} + + + ) + })} +
+ + ) +} + +interface ExecutionLog { + id: string + executionId: string + startedAt: string + level: string + trigger: string + triggerUserId: string | null + triggerInputs: any + outputs: any + errorMessage: string | null + duration: number | null + cost: { + input: number + output: number + total: number + } | null +} + +interface WorkflowDetails { + errorRates: { timestamp: string; value: number }[] + durations: { timestamp: string; value: number }[] + executionCounts: { timestamp: string; value: number }[] + logs: ExecutionLog[] + allLogs: ExecutionLog[] // Unfiltered logs for time filtering +} + +function LineChart({ + data, + label, + color, + unit, +}: { + data: { timestamp: string; value: number }[] + label: string + color: string + unit?: string +}) { + const width = 400 + const height = 180 + const padding = { top: 20, right: 20, bottom: 25, left: 50 } + const chartWidth = width - padding.left - padding.right + const chartHeight = height - padding.top - padding.bottom + + if (data.length === 0) { + return ( +
+

No data

+
+ ) + } + + const maxValue = Math.max(...data.map((d) => d.value), 1) + const minValue = Math.min(...data.map((d) => d.value), 0) + const valueRange = maxValue - minValue || 1 + + const points = data + .map((d, i) => { + const x = padding.left + (i / (data.length - 1 || 1)) * chartWidth + const y = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight + return `${x},${y}` + }) + .join(' ') + + return ( +
+

{label}

+ + + {/* Y-axis */} + + {/* X-axis */} + + + {/* Line */} + + + {/* Points */} + {data.map((d, i) => { + const x = padding.left + (i / (data.length - 1 || 1)) * chartWidth + const y = padding.top + chartHeight - ((d.value - minValue) / valueRange) * chartHeight + const timestamp = new Date(d.timestamp) + const timeStr = timestamp.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) + return ( + + + + + +
+
{timeStr}
+
+ {d.value.toFixed(2)} + {unit || ''} +
+
+ + + ) + })} + + {/* Y-axis labels */} + + {maxValue.toFixed(1)} + {unit} + + + {minValue.toFixed(1)} + {unit} + + +
+
+ ) +} + +export default function ExecutionsDashboard() { + const params = useParams() + const router = useRouter() + const workspaceId = params.workspaceId as string + + // Map sidebar timeRange to our timeFilter + const getTimeFilterFromRange = (range: string): TimeFilter => { + switch (range) { + case 'Past 30 minutes': + case 'Past hour': + return '1h' + case 'Past 12 hours': + return '12h' + case 'Past 24 hours': + return '24h' + default: + return '24h' + } + } + const [endTime, setEndTime] = useState(new Date()) + const [executions, setExecutions] = useState([]) + const [loading, setLoading] = useState(true) + const [isRefetching, setIsRefetching] = useState(false) + const [error, setError] = useState(null) + const [expandedWorkflowId, setExpandedWorkflowId] = useState(null) + const [workflowDetails, setWorkflowDetails] = useState>({}) + const [selectedSegmentIndex, setSelectedSegmentIndex] = useState(null) + const [selectedSegmentTimestamp, setSelectedSegmentTimestamp] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + + const { + workflowIds, + folderIds, + triggers, + viewMode, + setViewMode, + timeRange: sidebarTimeRange, + } = useFilterStore() + + const timeFilter = getTimeFilterFromRange(sidebarTimeRange) + + // Filter executions based on search query + const filteredExecutions = searchQuery.trim() + ? executions.filter((workflow) => + workflow.workflowName.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : executions + + const getStartTime = useCallback(() => { + const start = new Date(endTime) + + switch (timeFilter) { + case '1w': + start.setDate(endTime.getDate() - 7) + break + case '24h': + start.setHours(endTime.getHours() - 24) + break + case '12h': + start.setHours(endTime.getHours() - 12) + break + case '1h': + start.setHours(endTime.getHours() - 1) + break + default: + start.setHours(endTime.getHours() - 24) // Default to 24h + } + + return start + }, [endTime, timeFilter]) + + const fetchExecutions = useCallback( + async (isInitialLoad = false) => { + try { + if (isInitialLoad) { + setLoading(true) + } else { + setIsRefetching(true) + } + setError(null) + + const startTime = getStartTime() + const params = new URLSearchParams({ + segments: BAR_COUNT.toString(), + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }) + + // Add workflow filters if any + if (workflowIds.length > 0) { + params.set('workflowIds', workflowIds.join(',')) + } + + // Add folder filters if any + if (folderIds.length > 0) { + params.set('folderIds', folderIds.join(',')) + } + + // Add trigger filters if any + if (triggers.length > 0) { + params.set('triggers', triggers.join(',')) + } + + const response = await fetch( + `/api/workspaces/${workspaceId}/execution-history?${params.toString()}` + ) + + if (!response.ok) { + throw new Error('Failed to fetch execution history') + } + + const data = await response.json() + // Sort workflows by error rate (highest first) + const sortedWorkflows = [...data.workflows].sort((a, b) => { + const errorRateA = 100 - a.overallSuccessRate + const errorRateB = 100 - b.overallSuccessRate + return errorRateB - errorRateA + }) + setExecutions(sortedWorkflows) + } catch (err) { + console.error('Error fetching executions:', err) + setError(err instanceof Error ? err.message : 'An error occurred') + } finally { + setLoading(false) + setIsRefetching(false) + } + }, + [workspaceId, timeFilter, endTime, getStartTime, workflowIds, folderIds, triggers] + ) + + const fetchWorkflowDetails = useCallback( + async (workflowId: string, silent = false) => { + try { + const startTime = getStartTime() + const params = new URLSearchParams({ + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }) + + // Add trigger filters if any + if (triggers.length > 0) { + params.set('triggers', triggers.join(',')) + } + + const response = await fetch( + `/api/workspaces/${workspaceId}/execution-history/${workflowId}?${params.toString()}` + ) + + if (!response.ok) { + throw new Error('Failed to fetch workflow details') + } + + const data = await response.json() + // Store both filtered and all logs - update smoothly without clearing + setWorkflowDetails((prev) => ({ + ...prev, + [workflowId]: { + ...data, + allLogs: data.logs, // Keep a copy of all logs for filtering + }, + })) + } catch (err) { + console.error('Error fetching workflow details:', err) + } + }, + [workspaceId, endTime, getStartTime, triggers] + ) + + const toggleWorkflow = useCallback( + (workflowId: string) => { + if (expandedWorkflowId === workflowId) { + setExpandedWorkflowId(null) + setSelectedSegmentIndex(null) + setSelectedSegmentTimestamp(null) + } else { + setExpandedWorkflowId(workflowId) + setSelectedSegmentIndex(null) + setSelectedSegmentTimestamp(null) + if (!workflowDetails[workflowId]) { + fetchWorkflowDetails(workflowId) + } + } + }, + [expandedWorkflowId, workflowDetails, fetchWorkflowDetails] + ) + + const handleSegmentClick = useCallback( + (workflowId: string, segmentIndex: number, timestamp: string) => { + // Open the workflow details if not already open + if (expandedWorkflowId !== workflowId) { + setExpandedWorkflowId(workflowId) + if (!workflowDetails[workflowId]) { + fetchWorkflowDetails(workflowId) + } + // Select the segment when opening a new workflow + setSelectedSegmentIndex(segmentIndex) + setSelectedSegmentTimestamp(timestamp) + } else { + // If clicking the same segment again, deselect it + if (selectedSegmentIndex === segmentIndex) { + setSelectedSegmentIndex(null) + setSelectedSegmentTimestamp(null) + } else { + // Select the new segment + setSelectedSegmentIndex(segmentIndex) + setSelectedSegmentTimestamp(timestamp) + } + } + }, + [expandedWorkflowId, workflowDetails, fetchWorkflowDetails, selectedSegmentIndex] + ) + + // Initial load and refetch on dependencies change + const isInitialMount = useRef(true) + useEffect(() => { + const isInitial = isInitialMount.current + if (isInitial) { + isInitialMount.current = false + } + fetchExecutions(isInitial) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId, timeFilter, endTime, workflowIds, folderIds, triggers]) + + // Refetch workflow details when time, filters, or expanded workflow changes + useEffect(() => { + if (expandedWorkflowId) { + fetchWorkflowDetails(expandedWorkflowId) + } + }, [expandedWorkflowId, timeFilter, endTime, workflowIds, folderIds, fetchWorkflowDetails]) + + // Clear segment selection when time or filters change + useEffect(() => { + setSelectedSegmentIndex(null) + setSelectedSegmentTimestamp(null) + }, [timeFilter, endTime, workflowIds, folderIds, triggers]) + + const getShiftLabel = () => { + switch (sidebarTimeRange) { + case 'Past 30 minutes': + return '30 minutes' + case 'Past hour': + return 'hour' + case 'Past 12 hours': + return '12 hours' + case 'Past 24 hours': + return '24 hours' + default: + return 'period' + } + } + + const getDateRange = () => { + const start = getStartTime() + return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} - ${endTime.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', year: 'numeric' })}` + } + + const shiftTimeWindow = (direction: 'back' | 'forward') => { + let shift: number + switch (timeFilter) { + case '1h': + shift = 60 * 60 * 1000 + break + case '12h': + shift = 12 * 60 * 60 * 1000 + break + case '24h': + shift = 24 * 60 * 60 * 1000 + break + case '1w': + shift = 7 * 24 * 60 * 60 * 1000 + break + default: + shift = 24 * 60 * 60 * 1000 + } + + setEndTime((prev) => new Date(prev.getTime() + (direction === 'forward' ? shift : -shift))) + } + + const resetToNow = () => { + setEndTime(new Date()) + } + + const isLive = endTime.getTime() > Date.now() - 60000 // Within last minute + + const { timeRange } = useFilterStore() + + return ( +
+
+
+ {/* Controls */} +
+ {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className='h-9 w-full rounded-[11px] border-[#E5E5E5] bg-[#FFFFFF] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]' + /> + {searchQuery && ( + + )} +
+ +
+ {/* View Mode Toggle */} + + +
+ setViewMode(checked ? 'dashboard' : 'logs')} + className='data-[state=checked]:bg-primary' + /> +
+
+ + {(viewMode as string) === 'dashboard' + ? 'Switch to logs view' + : 'Switch to executions dashboard'} + +
+
+
+ + {/* Content */} + {loading ? ( +
+
+ + Loading execution history... +
+
+ ) : error ? ( +
+
+

Error loading data

+

{error}

+
+
+ ) : executions.length === 0 ? ( +
+
+

No execution history

+

Execute some workflows to see their history here

+
+
+ ) : ( + <> + {/* Time Range Display */} +
+
+ + {getDateRange()} + + {!isLive && ( + + Historical + + )} + {(workflowIds.length > 0 || folderIds.length > 0 || triggers.length > 0) && ( +
+ Filters: + {workflowIds.length > 0 && ( + + {workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''} + + )} + {folderIds.length > 0 && ( + + {folderIds.length} folder{folderIds.length !== 1 ? 's' : ''} + + )} + {triggers.length > 0 && ( + + {triggers.length} trigger{triggers.length !== 1 ? 's' : ''} + + )} +
+ )} +
+ + {/* Time Navigation Controls - Far Right */} +
+ + + + + Previous {getShiftLabel()} + + + + + + + Jump to now + + + + + + + Next {getShiftLabel()} + +
+
+ +
+
+
+

Workflows

+ + {filteredExecutions.length} workflow + {filteredExecutions.length !== 1 ? 's' : ''} + {searchQuery && ` (filtered from ${executions.length})`} + +
+
+ +
+ {filteredExecutions.length === 0 ? ( +
+ No workflows found matching "{searchQuery}" +
+ ) : ( + filteredExecutions.map((workflow) => { + const isSelected = expandedWorkflowId === workflow.workflowId + + return ( +
toggleWorkflow(workflow.workflowId)} + > +
+

+ {workflow.workflowName} +

+
+ +
+ +
+ +
+ + {workflow.overallSuccessRate.toFixed(1)}% + +
+
+ ) + }) + )} +
+
+
+ + {/* Details section below the entire bars component */} + {expandedWorkflowId && ( +
+
+
+

+ {executions.find((w) => w.workflowId === expandedWorkflowId)?.workflowName} +

+ + + + + Open workflow + +
+
+
+ {workflowDetails[expandedWorkflowId] ? ( + <> + {/* Filter info banner */} + {selectedSegmentIndex !== null && + (() => { + const workflow = executions.find( + (w) => w.workflowId === expandedWorkflowId + ) + const segment = workflow?.segments[selectedSegmentIndex] + if (!segment) return null + + const segmentStart = new Date(segment.timestamp) + const timeRangeMs = + timeFilter === '1h' + ? 60 * 60 * 1000 + : timeFilter === '12h' + ? 12 * 60 * 60 * 1000 + : timeFilter === '24h' + ? 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000 + const segmentDurationMs = timeRangeMs / BAR_COUNT + const segmentEnd = new Date(segmentStart.getTime() + segmentDurationMs) + + const formatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + } + + const startStr = segmentStart.toLocaleString('en-US', formatOptions) + const endStr = segmentEnd.toLocaleString('en-US', formatOptions) + + return ( +
+
+
+ + Filtered to {startStr} — {endStr} ({segment.totalExecutions}{' '} + execution{segment.totalExecutions !== 1 ? 's' : ''}) + +
+ +
+ ) + })()} + +
+ + + +
+ + {/* Logs Table */} + +
+ {/* Table header */} +
+
+
+
+
+ Time +
+
+ Status +
+
+ Trigger +
+
+ Cost +
+
+ User +
+
+ Output +
+
+ Duration +
+
+
+
+
+ + {/* Table body - scrollable */} +
+
+ {(() => { + const details = workflowDetails[expandedWorkflowId] + let logsToDisplay = details.logs + + // Filter logs if a segment is selected + if (selectedSegmentIndex !== null && selectedSegmentTimestamp) { + const workflow = executions.find( + (w) => w.workflowId === expandedWorkflowId + ) + if (workflow?.segments[selectedSegmentIndex]) { + const segment = workflow.segments[selectedSegmentIndex] + const segmentStart = new Date(segment.timestamp) + + // Calculate segment duration based on time filter + const timeRangeMs = + timeFilter === '1h' + ? 60 * 60 * 1000 + : timeFilter === '12h' + ? 12 * 60 * 60 * 1000 + : timeFilter === '24h' + ? 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000 // 1w + const segmentDurationMs = timeRangeMs / BAR_COUNT + const segmentEnd = new Date( + segmentStart.getTime() + segmentDurationMs + ) + + // Filter logs to only those within this segment + logsToDisplay = details.allLogs.filter((log) => { + const logTime = new Date(log.startedAt).getTime() + return ( + logTime >= segmentStart.getTime() && + logTime < segmentEnd.getTime() + ) + }) + } + } + + if (logsToDisplay.length === 0) { + return ( +
+
+ + + No executions found in this time segment + +
+
+ ) + } + + return logsToDisplay.map((log) => { + const logDate = new Date(log.startedAt) + const formattedDate = formatDate(logDate.toISOString()) + const outputsStr = log.outputs + ? JSON.stringify(log.outputs) + : '—' + const errorStr = log.errorMessage || '' + + return ( +
+
+ {/* Time */} +
+
+ + {formattedDate.compactDate} + + + {formattedDate.compactTime} + +
+
+ + {/* Status */} +
+
+ {log.level} +
+
+ + {/* Trigger */} +
+ {log.trigger ? ( +
+ {log.trigger} +
+ ) : ( +
+ )} +
+ + {/* Cost */} +
+
+ {log.cost && log.cost.total > 0 + ? formatCost(log.cost.total) + : '—'} +
+
+ + {/* User */} +
+
+ {log.triggerUserId || '—'} +
+
+ + {/* Output */} +
+ + +
+ {log.level === 'error' && errorStr + ? errorStr + : outputsStr} +
+
+ +
+                                                  {log.level === 'error' && errorStr
+                                                    ? errorStr
+                                                    : outputsStr}
+                                                
+
+
+
+ + {/* Duration */} +
+
+ {log.duration ? `${log.duration}ms` : '—'} +
+
+
+
+ ) + }) + })()} +
+
+
+
+ + ) : ( +
+ +
+ )} +
+
+ )} + + )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 1b254904d2..ac2e2d5d12 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -4,12 +4,14 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { AlertCircle, Info, Loader2, Play, RefreshCw, Square } from 'lucide-react' import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console/logger' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { cn } from '@/lib/utils' import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/components/search/search' import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar' +import ExecutionsDashboard from '@/app/workspace/[workspaceId]/logs/executions-dashboard' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date' import { useDebounce } from '@/hooks/use-debounce' import { useFolderStore } from '@/stores/folders/store' @@ -76,6 +78,8 @@ export default function Logs() { searchQuery: storeSearchQuery, setSearchQuery: setStoreSearchQuery, triggers, + viewMode, + setViewMode, } = useFilterStore() useEffect(() => { @@ -661,8 +665,13 @@ export default function Logs() { return () => window.removeEventListener('keydown', handleKeyDown) }, [logs, selectedLogIndex, isSidebarOpen, selectedLog, handleNavigateNext, handleNavigatePrev]) + // If in dashboard mode, show the dashboard + if (viewMode === 'dashboard') { + return + } + return ( -
+
{/* Add the animation styles */}