314 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|