From 4d48437b21605e5d6a00b3af12fd8a6059c3f60a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:16:58 +0000 Subject: [PATCH] feat: Add TanStack table for pipeline executions with debug modal - Create ExecutionsView component with TanStack Table - Add status filter buttons with count badges - Add action buttons: Analytics, Metrics, Debug - Add debug modal with AI copy-paste button for failed executions - Generate detailed debug report with stage metrics and error context - Update executions page to use new component Co-Authored-By: Claude Opus 4.5 --- .../[pipelineId]/executions/page.tsx | 232 +------ web/components/ExecutionsView.tsx | 578 ++++++++++++++++++ 2 files changed, 608 insertions(+), 202 deletions(-) create mode 100644 web/components/ExecutionsView.tsx diff --git a/web/app/pipelines/[pipelineId]/executions/page.tsx b/web/app/pipelines/[pipelineId]/executions/page.tsx index f098f89..0c57d74 100644 --- a/web/app/pipelines/[pipelineId]/executions/page.tsx +++ b/web/app/pipelines/[pipelineId]/executions/page.tsx @@ -3,75 +3,14 @@ import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; -import { - ArrowLeft, - CheckCircle, - XCircle, - Clock, - PlayCircle, - Loader, - RefreshCw, - AlertCircle, -} from 'lucide-react'; +import { ArrowLeft, PlayCircle, AlertCircle } from 'lucide-react'; import type { ExecutionStatus, PipelineDetail } from '@/lib/pipeline-types'; import { getPipeline, listExecutions } from '@/lib/pipeline-api'; - -// Status badge component -function StatusBadge({ status }: { status: ExecutionStatus['status'] }) { - const config = { - pending: { - icon: Clock, - color: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400', - }, - running: { - icon: Loader, - color: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', - }, - completed: { - icon: CheckCircle, - color: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400', - }, - failed: { - icon: XCircle, - color: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400', - }, - cancelled: { - icon: AlertCircle, - color: 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400', - }, - }; - - const { icon: Icon, color } = config[status] || config.pending; - - return ( - - - {status.charAt(0).toUpperCase() + status.slice(1)} - - ); -} - -// Format date string -function formatDate(dateStr: string | undefined): string { - if (!dateStr) return '-'; - const date = new Date(dateStr); - return date.toLocaleString(); -} - -// Calculate duration -function formatDuration(start?: string, end?: string): string { - if (!start) return '-'; - const startDate = new Date(start); - const endDate = end ? new Date(end) : new Date(); - const ms = endDate.getTime() - startDate.getTime(); - - if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; - return `${(ms / 60000).toFixed(1)}m`; -} +import ExecutionsView from '@/components/ExecutionsView'; /** * Execution history page for a pipeline. + * Uses TanStack Table for filtering, sorting, and pagination. */ export default function ExecutionsPage() { const params = useParams(); @@ -81,7 +20,6 @@ export default function ExecutionsPage() { const [executions, setExecutions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [statusFilter, setStatusFilter] = useState(''); const fetchData = async () => { setLoading(true); @@ -90,10 +28,7 @@ export default function ExecutionsPage() { try { const [pipelineData, executionsData] = await Promise.all([ getPipeline(pipelineId), - listExecutions(pipelineId, { - status: statusFilter || undefined, - limit: 50, - }), + listExecutions(pipelineId, { limit: 100 }), ]); setPipeline(pipelineData); setExecutions(executionsData); @@ -108,66 +43,44 @@ export default function ExecutionsPage() { if (pipelineId) { fetchData(); } - }, [pipelineId, statusFilter]); + }, [pipelineId]); return ( -
+
{/* Navigation */}
- Back to Dashboard + Back to Pipeline - + + Run Pipeline +
{/* Header */}
-

+

Execution History

- {pipeline?.name || pipelineId} + {pipeline?.name || pipelineId} - View and analyze pipeline executions

- {/* Filters */} -
-
- - -
-
- {/* Content */} {error ? (
-

{error}

+

{error}

) : loading ? ( -
- {[1, 2, 3, 4, 5].map((i) => ( -
-
-
-
-
-
-
-
-
- ))} -
- ) : executions.length === 0 ? ( -
- -

No executions found

+
+
+
+
+
+
+
+
) : ( -
- - - - - - - - - - - - - {executions.map((execution) => ( - - - - - - - - - ))} - -
- Status - - Execution ID - - Job / Business - - Stages - - Started - - Duration -
- - - - {execution.id.slice(0, 8)}... - - - {execution.job_id ? ( - - {execution.job_id.slice(0, 8)}... - - ) : execution.business_id ? ( - - {execution.business_id} - - ) : ( - - - )} - - - {execution.stages_completed.length} / {execution.stages_requested.length} - - {execution.error_message && ( - - (error) - - )} - - {formatDate(execution.started_at)} - - {formatDuration(execution.started_at, execution.completed_at)} -
-
+ )}
); diff --git a/web/components/ExecutionsView.tsx b/web/components/ExecutionsView.tsx new file mode 100644 index 0000000..501b4c0 --- /dev/null +++ b/web/components/ExecutionsView.tsx @@ -0,0 +1,578 @@ +'use client'; + +import React, { useState, useMemo, useCallback } from 'react'; +import Link from 'next/link'; +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + ColumnDef, + flexRender, + SortingState, + ColumnFiltersState, +} from '@tanstack/react-table'; +import { ExecutionStatus } from '@/lib/pipeline-types'; + +interface ExecutionsViewProps { + executions: ExecutionStatus[]; + pipelineId: string; + onRefresh?: () => void; +} + +// Helper to format duration +function formatDuration(ms: number | null | undefined): string { + if (!ms) return '-'; + if (ms < 1000) return `${ms}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return `${mins}m ${secs}s`; +} + +// Helper to format date +function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +// Sort icon component +function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) { + if (!sorted) { + return ( + + + + ); + } + return ( + + + + ); +} + +export default function ExecutionsView({ executions, pipelineId, onRefresh }: ExecutionsViewProps) { + const [sorting, setSorting] = useState([{ id: 'created_at', desc: true }]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + // Debug modal state + const [debugExecution, setDebugExecution] = useState(null); + const [copyingReport, setCopyingReport] = useState(null); + + // Generate debug report for AI + const generateDebugReport = useCallback((execution: ExecutionStatus): string => { + const now = new Date().toISOString(); + const duration = execution.total_duration_ms + ? formatDuration(execution.total_duration_ms) + : execution.started_at && execution.completed_at + ? formatDuration(new Date(execution.completed_at).getTime() - new Date(execution.started_at).getTime()) + : 'Unknown'; + + const stageMetricsFormatted = execution.stage_metrics + ? Object.entries(execution.stage_metrics) + .map(([stage, metrics]) => ` - ${stage}: ${metrics.duration_ms}ms, success=${metrics.success}, in=${metrics.records_in}, out=${metrics.records_out}${metrics.error ? `, error="${metrics.error}"` : ''}`) + .join('\n') + : ' No stage metrics available'; + + return `## Pipeline Execution Debug Report + +**Generated**: ${now} +**Execution ID**: ${execution.id} +**Pipeline**: ${execution.pipeline_id} +**Status**: ${execution.status.toUpperCase()} + +### Timeline +- **Created**: ${execution.created_at || 'N/A'} +- **Started**: ${execution.started_at || 'N/A'} +- **Completed**: ${execution.completed_at || 'N/A'} +- **Duration**: ${duration} + +### Stages +- **Requested**: ${execution.stages_requested?.join(' → ') || 'N/A'} +- **Completed**: ${execution.stages_completed?.join(' → ') || 'None'} +- **Progress**: ${execution.progress}% + +### Stage Metrics +${stageMetricsFormatted} + +### Error +\`\`\` +${execution.error_message || 'No error message captured'} +\`\`\` + +### Input Summary +\`\`\`json +${JSON.stringify(execution.input_summary || {}, null, 2)} +\`\`\` + +### Result Summary +\`\`\`json +${JSON.stringify(execution.result_summary || {}, null, 2)} +\`\`\` + +### Context for Debugging +- This is a ReviewIQ pipeline execution +- Pipeline stages: normalize → classify → route → aggregate +- The classify stage uses OpenAI/Anthropic for URT classification +- Common failure points: API rate limits, database connection, invalid input data + +### Suggested Investigation +1. Check if error is related to LLM API (rate limiting, auth, timeout) +2. Check database connectivity issues +3. Review input data validity +4. Check stage metrics for which stage failed and why +`; + }, []); + + const copyDebugReport = useCallback(async (execution: ExecutionStatus) => { + setCopyingReport(execution.id); + try { + const report = generateDebugReport(execution); + await navigator.clipboard.writeText(report); + setTimeout(() => setCopyingReport(null), 1500); + } catch (err) { + console.error('Failed to copy debug report:', err); + setCopyingReport(null); + } + }, [generateDebugReport]); + + // Filter executions by status + const filteredExecutions = useMemo(() => { + if (statusFilter === 'all') return executions; + return executions.filter(e => e.status === statusFilter); + }, [executions, statusFilter]); + + // Status counts for filter badges + const statusCounts = useMemo(() => { + const counts: Record = { all: executions.length }; + executions.forEach(e => { + counts[e.status] = (counts[e.status] || 0) + 1; + }); + return counts; + }, [executions]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'id', + header: 'Execution ID', + cell: ({ row }) => ( + + {row.original.id.slice(0, 8)}... + + ), + }, + { + accessorKey: 'job_id', + header: 'Job', + cell: ({ row }) => { + const jobId = row.original.job_id; + if (!jobId) return -; + return ( + e.stopPropagation()} + > + {jobId.slice(0, 8)}... + + ); + }, + }, + { + accessorKey: 'status', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.original.status; + return ( + + {status === 'running' && ( +
+ )} + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + }, + { + accessorKey: 'stages_completed', + header: 'Stages', + cell: ({ row }) => { + const requested = row.original.stages_requested || []; + const completed = row.original.stages_completed || []; + return ( +
+ {requested.map((stage, i) => ( + + {stage.slice(0, 3)} + + ))} +
+ ); + }, + }, + { + accessorKey: 'total_duration_ms', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatDuration(row.original.total_duration_ms)} + + ), + }, + { + accessorKey: 'created_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatDate(row.original.created_at)} + + ), + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => { + const execution = row.original; + const isFailed = execution.status === 'failed'; + const isCompleted = execution.status === 'completed'; + + return ( +
+ {/* Analytics button - only for completed */} + {isCompleted && ( + e.stopPropagation()} + title="View Analytics" + > + + + + Analytics + + )} + + {/* Metrics button - for completed or failed */} + e.stopPropagation()} + title="View Execution Metrics" + > + + + + Metrics + + + {/* Debug button - only for failed */} + {isFailed && ( + + )} +
+ ); + }, + }, + ], + [pipelineId] + ); + + const table = useReactTable({ + data: filteredExecutions, + columns, + state: { + sorting, + columnFilters, + globalFilter, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ {/* Filters */} +
+
+ {['all', 'completed', 'running', 'failed', 'pending'].map((status) => ( + + ))} +
+ + {onRefresh && ( + + )} +
+ + {/* Table */} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ No executions found +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + {/* Pagination */} +
+
+ Showing {table.getRowModel().rows.length} of {filteredExecutions.length} executions +
+
+ + + Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + + +
+
+
+ + {/* Debug Modal */} + {debugExecution && ( +
+
+
+
+

Execution Failed

+

{debugExecution.id}

+
+ +
+ +
+ {/* Error Message */} +
+

Error Message

+
+
+                    {debugExecution.error_message || 'No error message captured'}
+                  
+
+
+ + {/* Stage Metrics */} + {debugExecution.stage_metrics && ( +
+

Stage Metrics

+
+ {Object.entries(debugExecution.stage_metrics).map(([stage, metrics]) => ( +
+ {stage} +
+ {metrics.duration_ms}ms + in: {metrics.records_in} + out: {metrics.records_out} + + {metrics.success ? '✓' : '✗'} + +
+
+ ))} +
+
+ )} + + {/* Execution Details */} +
+

Execution Details

+
+

Started: {formatDate(debugExecution.started_at)}

+

Duration: {formatDuration(debugExecution.total_duration_ms)}

+

Stages Completed: {debugExecution.stages_completed?.join(' → ') || 'None'}

+
+
+
+ +
+ + View Full Details → + + +
+
+
+ )} +
+ ); +}