diff --git a/app/admin/workflow/executions/page.tsx b/app/admin/workflow/executions/page.tsx new file mode 100644 index 0000000..b0cf45c --- /dev/null +++ b/app/admin/workflow/executions/page.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + ArrowLeft, + Activity, +} from 'lucide-react'; + +interface PipelineExecution { + id: number; + pipeline_id: number; + trigger_source: string; + status: string; + started_at: string | null; + duration_ms: number | null; + error_message: string | null; + created_at: string; + has_fallback: boolean; +} + +interface Pipeline { + id: number; + name: string; +} + +const STATUS_COLORS: Record = { + completed: 'bg-green-100 text-green-700', + failed: 'bg-red-100 text-red-700', + running: 'bg-blue-100 text-blue-700', + pending: 'bg-gray-100 text-gray-700', + skipped: 'bg-slate-100 text-slate-500', + waiting: 'bg-yellow-100 text-yellow-700', +}; + +function formatDuration(ms: number | null): string { + if (ms == null) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleString(); +} + +export default function PipelineExecutionsPage() { + const [executions, setExecutions] = useState([]); + const [pipelines, setPipelines] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [fallbacksOnly, setFallbacksOnly] = useState(false); + const [pipelineFilter, setPipelineFilter] = useState('all'); + + useEffect(() => { + fetchPipelines(); + }, []); + + useEffect(() => { + fetchExecutions(); + }, [fallbacksOnly, pipelineFilter]); // eslint-disable-line react-hooks/exhaustive-deps + + const fetchPipelines = async () => { + try { + const res = await fetch('/api/pipelines'); + if (res.ok) { + const data = await res.json(); + setPipelines(data.data || []); + } + } catch (err) { + console.error('Failed to fetch pipelines:', err); + } + }; + + const fetchExecutions = async () => { + setIsLoading(true); + try { + const params = new URLSearchParams({ limit: '100' }); + if (fallbacksOnly) params.set('fallbacks_only', '1'); + if (pipelineFilter !== 'all') params.set('pipeline_id', pipelineFilter); + + const res = await fetch(`/api/admin/pipeline-executions?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + setExecutions(data.data || []); + } + } catch (err) { + console.error('Failed to fetch executions:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ + + +
+

+ Pipeline Executions +

+

+ Pipeline-engine execution history across all pipelines +

+
+
+ + {/* Filters */} +
+
+ + +
+ +
+ + +
+
+ + {isLoading ? ( +
+ {[1, 2, 3].map(i => ( + + ))} +
+ ) : executions.length === 0 ? ( + + +

No executions match the current filter.

+
+
+ ) : ( +
+ {executions.map(exec => ( + + + +
+
+ #{exec.id} + + Pipeline {exec.pipeline_id} + + + {exec.status} + + {exec.has_fallback && ( + + fallback + + )} + + {exec.trigger_source} + +
+
+ {formatDuration(exec.duration_ms)} + {formatDate(exec.started_at ?? exec.created_at)} +
+
+ {exec.error_message && ( +

{exec.error_message}

+ )} +
+
+ + ))} +
+ )} +
+ ); +} diff --git a/app/api/admin/pipeline-executions/route.ts b/app/api/admin/pipeline-executions/route.ts new file mode 100644 index 0000000..b95d39b --- /dev/null +++ b/app/api/admin/pipeline-executions/route.ts @@ -0,0 +1,99 @@ +/** + * GET /api/admin/pipeline-executions?limit=N&fallbacks_only=1&pipeline_id=NN + * + * Lists pipeline-engine executions across ALL pipelines (or one pipeline + * when pipeline_id is provided). Joins a `has_fallback` boolean computed + * from pipeline_execution_steps.output_data ? 'user_route_fallback'. + * ROUTE-07 / D-11 — admin filter for user_route_fallback events. + * + * Uses FOUR complete parameterized SQL strings — never references a + * SELECT-list alias inside the same SELECT's WHERE clause (HIGH 4 fix). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-utils'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET(req: NextRequest): Promise { + const { error } = await requireAdmin(); + if (error) return error; + + const { searchParams } = new URL(req.url); + const fallbacksOnly = searchParams.get('fallbacks_only') === '1'; + const pipelineIdRaw = searchParams.get('pipeline_id'); + const pipelineId = pipelineIdRaw && /^\d+$/.test(pipelineIdRaw) + ? Number(pipelineIdRaw) : null; + const limitRaw = searchParams.get('limit'); + const limit = limitRaw && /^\d+$/.test(limitRaw) + ? Math.min(Number(limitRaw), 500) : 100; + + try { + const params: (number | string)[] = []; + let sql: string; + + if (fallbacksOnly && pipelineId !== null) { + params.push(pipelineId, limit); + sql = ` + SELECT pe.*, true AS has_fallback + FROM pipeline_executions pe + WHERE pe.pipeline_id = $1 + AND EXISTS ( + SELECT 1 FROM pipeline_execution_steps pes + WHERE pes.execution_id = pe.id + AND pes.output_data ? 'user_route_fallback' + ) + ORDER BY pe.created_at DESC + LIMIT $2 + `; + } else if (fallbacksOnly) { + params.push(limit); + sql = ` + SELECT pe.*, true AS has_fallback + FROM pipeline_executions pe + WHERE EXISTS ( + SELECT 1 FROM pipeline_execution_steps pes + WHERE pes.execution_id = pe.id + AND pes.output_data ? 'user_route_fallback' + ) + ORDER BY pe.created_at DESC + LIMIT $1 + `; + } else if (pipelineId !== null) { + params.push(pipelineId, limit); + sql = ` + SELECT pe.*, + EXISTS ( + SELECT 1 FROM pipeline_execution_steps pes + WHERE pes.execution_id = pe.id + AND pes.output_data ? 'user_route_fallback' + ) AS has_fallback + FROM pipeline_executions pe + WHERE pe.pipeline_id = $1 + ORDER BY pe.created_at DESC + LIMIT $2 + `; + } else { + params.push(limit); + sql = ` + SELECT pe.*, + EXISTS ( + SELECT 1 FROM pipeline_execution_steps pes + WHERE pes.execution_id = pe.id + AND pes.output_data ? 'user_route_fallback' + ) AS has_fallback + FROM pipeline_executions pe + ORDER BY pe.created_at DESC + LIMIT $1 + `; + } + + const result = await postgresClient.query>(sql, params); + return NextResponse.json({ data: result.rows }); + } catch (e) { + console.error('GET /api/admin/pipeline-executions failed:', e); + return NextResponse.json( + { error: 'Failed to read executions', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +}