feat(09-06): NEW /admin/workflow/executions page + pipeline-executions API (ROUTE-07)

- GET /api/admin/pipeline-executions: requireAdmin(), accepts fallbacks_only/pipeline_id/limit params
- Four complete parameterized SQL strings — no alias-in-WHERE bug (HIGH 4 fix)
- JSONB predicate: output_data ? 'user_route_fallback' inlined in EXISTS subquery in WHERE
- has_fallback boolean on every row (true constant in fallbacks-only branches, EXISTS in unfiltered)
- pipeline_id validated against /^\d+$/ before binding; limit capped at 500
- app/admin/workflow/executions/page.tsx: Switch 'Show only fallbacks', pipeline Select filter, per-row fallback badge, links to pipeline detail page
- Locked URL /admin/workflow/executions honored — fresh page over pipeline-engine tables only
This commit is contained in:
lorentz 2026-05-10 07:43:05 -04:00
parent 5a9ec0f5df
commit 23a8c7c5d9
2 changed files with 304 additions and 0 deletions

View file

@ -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<string, string> = {
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<PipelineExecution[]>([]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [fallbacksOnly, setFallbacksOnly] = useState(false);
const [pipelineFilter, setPipelineFilter] = useState<string>('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 (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="icon"><ArrowLeft className="h-4 w-4" /></Button>
</Link>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Activity className="h-6 w-6" /> Pipeline Executions
</h1>
<p className="text-muted-foreground text-sm">
Pipeline-engine execution history across all pipelines
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<Switch
id="fallbacks-only"
checked={fallbacksOnly}
onCheckedChange={setFallbacksOnly}
/>
<Label htmlFor="fallbacks-only">Show only fallbacks</Label>
</div>
<div className="flex items-center gap-2">
<Label className="text-sm text-muted-foreground">Pipeline:</Label>
<Select value={pipelineFilter} onValueChange={setPipelineFilter}>
<SelectTrigger className="w-52">
<SelectValue placeholder="All pipelines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All pipelines</SelectItem>
{pipelines.map(p => (
<SelectItem key={p.id} value={String(p.id)}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : executions.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">No executions match the current filter.</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{executions.map(exec => (
<Link
key={exec.id}
href={`/admin/workflow/pipelines/${exec.pipeline_id}`}
className="block"
>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<span className="text-xs text-muted-foreground font-mono">#{exec.id}</span>
<Badge variant="outline" className="text-xs shrink-0">
Pipeline {exec.pipeline_id}
</Badge>
<Badge className={`text-xs shrink-0 ${STATUS_COLORS[exec.status] || ''}`}>
{exec.status}
</Badge>
{exec.has_fallback && (
<Badge variant="outline" className="text-xs shrink-0 border-amber-400 text-amber-700">
fallback
</Badge>
)}
<span className="text-xs text-muted-foreground truncate">
{exec.trigger_source}
</span>
</div>
<div className="flex items-center gap-4 shrink-0 text-xs text-muted-foreground">
<span>{formatDuration(exec.duration_ms)}</span>
<span>{formatDate(exec.started_at ?? exec.created_at)}</span>
</div>
</div>
{exec.error_message && (
<p className="text-xs text-red-500 mt-1 truncate">{exec.error_message}</p>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}

View file

@ -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<NextResponse> {
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<Record<string, unknown>>(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 },
);
}
}