wulf-pulse/app/admin/workflow/executions/page.tsx
lorentz 23a8c7c5d9 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
2026-05-10 07:43:05 -04:00

205 lines
6.7 KiB
TypeScript

'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>
);
}