wulf-pulse/app/admin/workflow/history/page.tsx

314 lines
13 KiB
TypeScript

'use client';
import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ArrowLeft,
History,
CheckCircle2,
XCircle,
Clock,
Activity,
Loader2,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { WorkflowExecutionWithSteps } from '@/lib/types/workflow';
export default function ExecutionHistoryPage() {
return (
<Suspense>
<ExecutionHistoryContent />
</Suspense>
);
}
function ExecutionHistoryContent() {
const searchParams = useSearchParams();
const highlightId = searchParams.get('id');
const [executions, setExecutions] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [methodFilter, setMethodFilter] = useState<string>('all');
const [isLoading, setIsLoading] = useState(true);
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecutionWithSteps | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const pageSize = 25;
useEffect(() => { loadExecutions(); }, [page, statusFilter, methodFilter]);
const loadExecutions = async () => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: (page * pageSize).toString(),
});
if (statusFilter !== 'all') params.set('status', statusFilter);
if (methodFilter !== 'all') params.set('method', methodFilter);
const res = await fetch(`/api/workflow/executions?${params}`);
if (res.ok) {
const data = await res.json();
setExecutions(data.data || []);
setTotal(data.total || 0);
}
} catch (error) {
console.error('Failed to load executions:', error);
} finally {
setIsLoading(false);
}
};
const loadExecutionDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await fetch(`/api/workflow/executions/${id}`);
if (res.ok) {
setSelectedExecution(await res.json());
}
} catch (error) {
console.error('Failed to load execution detail:', error);
} finally {
setDetailLoading(false);
}
};
const statusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'failed': return <XCircle className="w-4 h-4 text-red-500" />;
case 'skipped': return <Clock className="w-4 h-4 text-gray-500" />;
case 'running': return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
default: return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const methodBadge = (method: string | null) => {
if (!method) return <Badge variant="outline" className="text-xs">n/a</Badge>;
const variant = method === 'robotic' ? 'default' : method === 'ai' ? 'secondary' : 'outline';
return <Badge variant={variant} className="text-xs">{method}</Badge>;
};
const totalPages = Math.ceil(total / pageSize);
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="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<History className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Execution History</h1>
<p className="text-sm text-muted-foreground">{total} total executions</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="failed">Failed</SelectItem>
<SelectItem value="skipped">Skipped</SelectItem>
<SelectItem value="running">Running</SelectItem>
</SelectContent>
</Select>
<Select value={methodFilter} onValueChange={setMethodFilter}>
<SelectTrigger className="w-40"><SelectValue placeholder="Method" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Methods</SelectItem>
<SelectItem value="robotic">Robotic</SelectItem>
<SelectItem value="ai">AI</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
</SelectContent>
</Select>
</div>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : executions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No executions found.</p>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">Status</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Branch</TableHead>
<TableHead>Method</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((exec) => (
<TableRow
key={exec.id}
className={`cursor-pointer hover:bg-muted/50 ${highlightId === String(exec.id) ? 'bg-muted' : ''}`}
onClick={() => loadExecutionDetail(exec.id)}
>
<TableCell>{statusIcon(exec.status)}</TableCell>
<TableCell className="font-medium">
{exec.ticket_number ? `#${exec.ticket_number}` : `ID ${exec.entity_id}`}
</TableCell>
<TableCell>
{exec.branch && <Badge variant="outline">{exec.branch}</Badge>}
</TableCell>
<TableCell>{methodBadge(exec.classification_method)}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{exec.duration_ms ? `${exec.duration_ms}ms` : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(exec.created_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Execution Detail Dialog */}
<Dialog open={!!selectedExecution} onOpenChange={(open) => !open && setSelectedExecution(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Execution #{selectedExecution?.id}
{selectedExecution?.ticket_number && ` — Ticket #${selectedExecution.ticket_number}`}
</DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : selectedExecution && (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-4 gap-4">
<div>
<p className="text-xs text-muted-foreground">Status</p>
<div className="flex items-center gap-1 mt-1">
{statusIcon(selectedExecution.status)}
<span className="font-medium text-sm">{selectedExecution.status}</span>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Method</p>
<div className="mt-1">{methodBadge(selectedExecution.classification_method)}</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Branch</p>
<p className="font-medium text-sm mt-1">{selectedExecution.branch || '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Duration</p>
<p className="font-medium text-sm mt-1">{selectedExecution.duration_ms}ms</p>
</div>
</div>
{selectedExecution.error_message && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded p-3">
<p className="text-sm text-red-600 dark:text-red-400">{selectedExecution.error_message}</p>
</div>
)}
{/* Steps */}
<div>
<h4 className="font-medium mb-2">Processing Steps</h4>
<div className="space-y-2">
{selectedExecution.steps?.map((step, i) => (
<div key={step.id || i} className="border rounded-lg p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{statusIcon(step.status)}
<span className="font-medium text-sm">{step.step_name}</span>
{step.method && (
<Badge variant={step.method === 'robotic' ? 'default' : 'secondary'} className="text-xs">
{step.method}
</Badge>
)}
{step.confidence && (
<Badge variant="outline" className="text-xs">{step.confidence}</Badge>
)}
</div>
{step.duration_ms != null && (
<span className="text-xs text-muted-foreground">{step.duration_ms}ms</span>
)}
</div>
{step.output_data && (
<pre className="text-xs text-muted-foreground mt-2 bg-muted p-2 rounded overflow-x-auto">
{JSON.stringify(step.output_data, null, 2)}
</pre>
)}
{step.field_changes && (
<div className="mt-2 text-xs">
{Object.entries(step.field_changes).map(([field, change]) => (
<div key={field}>
<span className="font-medium">{field}:</span>{' '}
<span className="text-red-500">{String((change as any).before)}</span>
{' → '}
<span className="text-green-500">{String((change as any).after)}</span>
</div>
))}
</div>
)}
{step.error_message && (
<p className="text-xs text-red-500 mt-1">{step.error_message}</p>
)}
</div>
))}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}