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>
This commit is contained in:
Alejandro Gutiérrez
2026-01-24 21:16:58 +00:00
parent 796f587c57
commit 4d48437b21
2 changed files with 608 additions and 202 deletions

View File

@@ -3,75 +3,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { import { ArrowLeft, PlayCircle, AlertCircle } from 'lucide-react';
ArrowLeft,
CheckCircle,
XCircle,
Clock,
PlayCircle,
Loader,
RefreshCw,
AlertCircle,
} from 'lucide-react';
import type { ExecutionStatus, PipelineDetail } from '@/lib/pipeline-types'; import type { ExecutionStatus, PipelineDetail } from '@/lib/pipeline-types';
import { getPipeline, listExecutions } from '@/lib/pipeline-api'; import { getPipeline, listExecutions } from '@/lib/pipeline-api';
import ExecutionsView from '@/components/ExecutionsView';
// 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 (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${color}`}>
<Icon className={`w-3 h-3 mr-1 ${status === 'running' ? 'animate-spin' : ''}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
// 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`;
}
/** /**
* Execution history page for a pipeline. * Execution history page for a pipeline.
* Uses TanStack Table for filtering, sorting, and pagination.
*/ */
export default function ExecutionsPage() { export default function ExecutionsPage() {
const params = useParams(); const params = useParams();
@@ -81,7 +20,6 @@ export default function ExecutionsPage() {
const [executions, setExecutions] = useState<ExecutionStatus[]>([]); const [executions, setExecutions] = useState<ExecutionStatus[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
@@ -90,10 +28,7 @@ export default function ExecutionsPage() {
try { try {
const [pipelineData, executionsData] = await Promise.all([ const [pipelineData, executionsData] = await Promise.all([
getPipeline(pipelineId), getPipeline(pipelineId),
listExecutions(pipelineId, { listExecutions(pipelineId, { limit: 100 }),
status: statusFilter || undefined,
limit: 50,
}),
]); ]);
setPipeline(pipelineData); setPipeline(pipelineData);
setExecutions(executionsData); setExecutions(executionsData);
@@ -108,66 +43,44 @@ export default function ExecutionsPage() {
if (pipelineId) { if (pipelineId) {
fetchData(); fetchData();
} }
}, [pipelineId, statusFilter]); }, [pipelineId]);
return ( return (
<div className="p-6"> <div className="h-full overflow-y-auto p-6">
{/* Navigation */} {/* Navigation */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<Link <Link
href={`/pipelines/${pipelineId}`} href={`/pipelines/${pipelineId}`}
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
> >
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
Back to Dashboard Back to Pipeline
</Link> </Link>
<button <Link
onClick={fetchData} href={`/pipelines/${pipelineId}/run`}
disabled={loading} className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
className="inline-flex items-center px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md disabled:opacity-50"
> >
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <PlayCircle className="w-4 h-4 mr-2" />
Refresh Run Pipeline
</button> </Link>
</div> </div>
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> <h1 className="text-2xl font-bold text-gray-900">
Execution History Execution History
</h1> </h1>
<p className="text-gray-500 mt-1"> <p className="text-gray-500 mt-1">
{pipeline?.name || pipelineId} {pipeline?.name || pipelineId} - View and analyze pipeline executions
</p> </p>
</div> </div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
<div className="flex items-center space-x-4">
<label className="text-sm text-gray-600 dark:text-gray-400">
Status:
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="running">Running</option>
<option value="pending">Pending</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Content */} {/* Content */}
{error ? ( {error ? (
<div className="text-center py-12"> <div className="text-center py-12">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> <AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400">{error}</p> <p className="text-red-600">{error}</p>
<button <button
onClick={fetchData} onClick={fetchData}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
@@ -176,106 +89,21 @@ export default function ExecutionsPage() {
</button> </button>
</div> </div>
) : loading ? ( ) : loading ? (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"> <div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
{[1, 2, 3, 4, 5].map((i) => ( <div className="animate-pulse space-y-4">
<div <div className="h-10 bg-gray-200 rounded w-full" />
key={i} <div className="h-12 bg-gray-100 rounded w-full" />
className="p-4 border-b border-gray-200 dark:border-gray-700 last:border-b-0 animate-pulse" <div className="h-12 bg-gray-100 rounded w-full" />
> <div className="h-12 bg-gray-100 rounded w-full" />
<div className="flex items-center justify-between"> <div className="h-12 bg-gray-100 rounded w-full" />
<div className="flex items-center space-x-4">
<div className="w-20 h-6 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded" />
</div> </div>
<div className="w-24 h-4 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
))}
</div>
) : executions.length === 0 ? (
<div className="text-center py-12">
<PlayCircle className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">No executions found</p>
</div> </div>
) : ( ) : (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> <ExecutionsView
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> executions={executions}
<thead className="bg-gray-50 dark:bg-gray-900"> pipelineId={pipelineId}
<tr> onRefresh={fetchData}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> />
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Execution ID
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Job / Business
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Stages
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{executions.map((execution) => (
<tr
key={execution.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="px-4 py-3">
<StatusBadge status={execution.status} />
</td>
<td className="px-4 py-3">
<code className="text-xs text-gray-600 dark:text-gray-400">
{execution.id.slice(0, 8)}...
</code>
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{execution.job_id ? (
<Link
href={`/jobs/${execution.job_id}`}
className="text-blue-600 hover:underline"
>
{execution.job_id.slice(0, 8)}...
</Link>
) : execution.business_id ? (
<span className="truncate max-w-[150px] inline-block">
{execution.business_id}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<span className="text-gray-500">
{execution.stages_completed.length} / {execution.stages_requested.length}
</span>
{execution.error_message && (
<span
className="ml-2 text-red-500 cursor-help"
title={execution.error_message}
>
(error)
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{formatDate(execution.started_at)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{formatDuration(execution.started_at, execution.completed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </div>
); );

View File

@@ -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 (
<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>
);
}