feat: Add extensible multi-pipeline integration system

This commit implements a plugin-like pipeline architecture with:

Pipeline Core Package (packages/pipeline-core/):
- BasePipeline abstract class all pipelines implement
- PipelineRegistry for database-backed discovery/management
- PipelineRunner for execution with status tracking
- DashboardConfig contracts for dynamic widget definitions

Database Migration (006_pipeline_registry.sql):
- pipeline.registry table for registered pipelines
- pipeline.executions table for execution history
- Views for execution stats and monitoring

ReviewIQ Pipeline Refactor:
- Implements BasePipeline interface
- Adds get_dashboard_config() with widget definitions
- Adds get_widget_data() methods for all dashboard widgets
- Maintains backward compatibility with Pipeline alias

Generic Pipeline API (api/routes/pipelines.py):
- GET /api/pipelines - List all registered pipelines
- GET /api/pipelines/{id} - Pipeline details
- POST /api/pipelines/{id}/execute - Execute pipeline
- GET /api/pipelines/{id}/dashboard - Dashboard config
- GET /api/pipelines/{id}/widgets/{w} - Widget data
- GET /api/pipelines/{id}/executions - Execution history

Frontend Dynamic Dashboard System:
- DynamicDashboard component renders from config
- WidgetRegistry maps types to components
- Widget components: StatCard, LineChart, BarChart,
  PieChart, DataTable, Heatmap
- Pipeline API client library

Frontend Pipeline Pages:
- /pipelines - List all registered pipelines
- /pipelines/[id] - Dynamic dashboard for pipeline
- /pipelines/[id]/executions - Execution history
- Pipelines nav item in Sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-24 19:05:38 +00:00
parent d64f06ba9e
commit 824634aa76
30 changed files with 5697 additions and 95 deletions

View File

@@ -0,0 +1,134 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { WidgetConfig, TableData, TableWidgetConfig } from '@/lib/pipeline-types';
import { WidgetWrapper } from './WidgetWrapper';
interface DataTableWidgetProps {
config: WidgetConfig;
data: TableData | null;
loading: boolean;
error?: string;
onRefresh?: () => void;
onPageChange?: (page: number) => void;
currentPage?: number;
}
/**
* Data table widget with pagination.
*/
export function DataTableWidget({
config,
data,
loading,
error,
onRefresh,
onPageChange,
currentPage = 1,
}: DataTableWidgetProps) {
const tableConfig = config.config as TableWidgetConfig;
const rows = data?.data || [];
const total = data?.total || 0;
const pageSize = tableConfig.page_size || 10;
const totalPages = Math.ceil(total / pageSize);
return (
<WidgetWrapper config={config} loading={loading} error={error} onRefresh={onRefresh}>
{rows.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
No data available
</div>
) : (
<div className="flex flex-col h-full">
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800 sticky top-0">
<tr>
{tableConfig.columns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${
col.align === 'right'
? 'text-right'
: col.align === 'center'
? 'text-center'
: 'text-left'
}`}
style={{ width: col.width ? `${col.width}px` : undefined }}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{rows.map((row, rowIndex) => (
<tr
key={row[tableConfig.row_key] as string || rowIndex}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
{tableConfig.columns.map((col) => (
<td
key={col.key}
className={`px-4 py-3 text-sm text-gray-900 dark:text-gray-100 whitespace-nowrap ${
col.align === 'right'
? 'text-right'
: col.align === 'center'
? 'text-center'
: 'text-left'
}`}
>
{formatCellValue(row[col.key], col.format)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{tableConfig.show_pagination !== false && totalPages > 1 && onPageChange && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-500">
Showing {(currentPage - 1) * pageSize + 1} to{' '}
{Math.min(currentPage * pageSize, total)} of {total}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-gray-700 dark:text-gray-300">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
)}
</WidgetWrapper>
);
}
function formatCellValue(value: unknown, format?: string): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'number') {
if (format?.includes('%')) {
return `${value.toFixed(1)}%`;
}
return value.toLocaleString();
}
return String(value);
}