Files
whyrating-engine-legacy/web/components/ExecutionsView.tsx
Alejandro Gutiérrez 4d48437b21 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 <noreply@anthropic.com>
2026-01-24 21:16:58 +00:00

579 lines
22 KiB
TypeScript

'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 (
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
return (
<svg className={`w-4 h-4 ${sorted === 'asc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
export default function ExecutionsView({ executions, pipelineId, onRefresh }: ExecutionsViewProps) {
const [sorting, setSorting] = useState<SortingState>([{ id: 'created_at', desc: true }]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
// Debug modal state
const [debugExecution, setDebugExecution] = useState<ExecutionStatus | null>(null);
const [copyingReport, setCopyingReport] = useState<string | null>(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<string, number> = { all: executions.length };
executions.forEach(e => {
counts[e.status] = (counts[e.status] || 0) + 1;
});
return counts;
}, [executions]);
const columns = useMemo<ColumnDef<ExecutionStatus>[]>(
() => [
{
accessorKey: 'id',
header: 'Execution ID',
cell: ({ row }) => (
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono">
{row.original.id.slice(0, 8)}...
</code>
),
},
{
accessorKey: 'job_id',
header: 'Job',
cell: ({ row }) => {
const jobId = row.original.job_id;
if (!jobId) return <span className="text-gray-400">-</span>;
return (
<Link
href={`/jobs/${jobId}`}
className="text-blue-600 hover:text-blue-800 text-xs font-mono"
onClick={(e) => e.stopPropagation()}
>
{jobId.slice(0, 8)}...
</Link>
);
},
},
{
accessorKey: 'status',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Status
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => {
const status = row.original.status;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
status === 'completed' ? 'bg-green-100 text-green-800' :
status === 'running' ? 'bg-blue-100 text-blue-800' :
status === 'failed' ? 'bg-red-100 text-red-800' :
status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{status === 'running' && (
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
)}
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
},
{
accessorKey: 'stages_completed',
header: 'Stages',
cell: ({ row }) => {
const requested = row.original.stages_requested || [];
const completed = row.original.stages_completed || [];
return (
<div className="flex items-center gap-1">
{requested.map((stage, i) => (
<span
key={stage}
className={`px-1.5 py-0.5 text-xs rounded ${
completed.includes(stage)
? 'bg-green-100 text-green-700'
: row.original.current_stage === stage
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500'
}`}
title={stage}
>
{stage.slice(0, 3)}
</span>
))}
</div>
);
},
},
{
accessorKey: 'total_duration_ms',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Duration
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => (
<span className="text-sm text-gray-600">
{formatDuration(row.original.total_duration_ms)}
</span>
),
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
>
Created
<SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => (
<span className="text-sm text-gray-600">
{formatDate(row.original.created_at)}
</span>
),
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const execution = row.original;
const isFailed = execution.status === 'failed';
const isCompleted = execution.status === 'completed';
return (
<div className="flex items-center gap-2">
{/* Analytics button - only for completed */}
{isCompleted && (
<Link
href={`/pipelines/${pipelineId}?execution_id=${execution.id}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 hover:bg-purple-200 text-purple-700 text-xs font-medium rounded transition-colors"
onClick={(e) => e.stopPropagation()}
title="View Analytics"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Analytics
</Link>
)}
{/* Metrics button - for completed or failed */}
<Link
href={`/pipelines/${pipelineId}/executions/${execution.id}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs font-medium rounded transition-colors"
onClick={(e) => e.stopPropagation()}
title="View Execution Metrics"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Metrics
</Link>
{/* Debug button - only for failed */}
{isFailed && (
<button
onClick={(e) => {
e.stopPropagation();
setDebugExecution(execution);
}}
className="inline-flex items-center gap-1 px-2 py-1 bg-red-100 hover:bg-red-200 text-red-700 text-xs font-medium rounded transition-colors"
title="Debug Failure"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Debug
</button>
)}
</div>
);
},
},
],
[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 (
<div className="space-y-4">
{/* Filters */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
{['all', 'completed', 'running', 'failed', 'pending'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
statusFilter === status
? status === 'completed' ? 'bg-green-600 text-white' :
status === 'running' ? 'bg-blue-600 text-white' :
status === 'failed' ? 'bg-red-600 text-white' :
'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
{statusCounts[status] !== undefined && (
<span className="ml-1.5 text-xs opacity-80">({statusCounts[status]})</span>
)}
</button>
))}
</div>
{onRefresh && (
<button
onClick={onRefresh}
className="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
)}
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-100">
{table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center text-gray-500">
No executions found
</td>
</tr>
) : (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="hover:bg-gray-50 transition-colors"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing {table.getRowModel().rows.length} of {filteredExecutions.length} executions
</div>
<div className="flex items-center gap-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
{/* Debug Modal */}
{debugExecution && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Execution Failed</h3>
<p className="text-sm text-gray-500 font-mono">{debugExecution.id}</p>
</div>
<button
onClick={() => setDebugExecution(null)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Error Message */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Error Message</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<pre className="text-sm text-red-800 whitespace-pre-wrap font-mono">
{debugExecution.error_message || 'No error message captured'}
</pre>
</div>
</div>
{/* Stage Metrics */}
{debugExecution.stage_metrics && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Stage Metrics</h4>
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
{Object.entries(debugExecution.stage_metrics).map(([stage, metrics]) => (
<div
key={stage}
className={`flex items-center justify-between p-2 rounded ${
metrics.success ? 'bg-green-50' : 'bg-red-50'
}`}
>
<span className="font-medium">{stage}</span>
<div className="flex items-center gap-4 text-sm">
<span>{metrics.duration_ms}ms</span>
<span>in: {metrics.records_in}</span>
<span>out: {metrics.records_out}</span>
<span className={metrics.success ? 'text-green-600' : 'text-red-600'}>
{metrics.success ? '✓' : '✗'}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Execution Details */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Execution Details</h4>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-1">
<p><span className="font-medium">Started:</span> {formatDate(debugExecution.started_at)}</p>
<p><span className="font-medium">Duration:</span> {formatDuration(debugExecution.total_duration_ms)}</p>
<p><span className="font-medium">Stages Completed:</span> {debugExecution.stages_completed?.join(' → ') || 'None'}</p>
</div>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<Link
href={`/pipelines/${pipelineId}/executions/${debugExecution.id}`}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
View Full Details
</Link>
<button
onClick={() => copyDebugReport(debugExecution)}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
copyingReport === debugExecution.id
? 'bg-green-600 text-white'
: 'bg-gray-900 hover:bg-gray-800 text-white'
}`}
>
{copyingReport === debugExecution.id ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy for AI Debug
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}