diff --git a/src/app/admin/runs/page.tsx b/src/app/admin/runs/page.tsx new file mode 100644 index 0000000..1014ced --- /dev/null +++ b/src/app/admin/runs/page.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, AlertCircle, CheckCircle, XCircle, Clock } from 'lucide-react'; +import Link from 'next/link'; + +type Run = { + id: string; + status: 'RUNNING' | 'SUCCESS' | 'ERROR'; + stage: string | null; + progress: string | null; + articlesFound: number; + error: string | null; + startedAt: string; + endedAt: string | null; + rule: { name: string }; +}; + +const statusIcon = { + RUNNING: , + SUCCESS: , + ERROR: , +}; + +const statusClass = { + RUNNING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300', + SUCCESS: 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300', + ERROR: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300', +}; + +export default function RunsPage() { + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<'ALL' | 'RUNNING' | 'SUCCESS' | 'ERROR'>('ALL'); + + async function fetchRuns() { + setLoading(true); + const params = new URLSearchParams(); + if (filter !== 'ALL') params.set('status', filter); + params.set('limit', '100'); + const res = await fetch(`/api/admin/runs?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + setRuns(data.runs); + } + setLoading(false); + } + + useEffect(() => { + fetchRuns(); + }, [filter]); + + return ( +
+
+

Run History

+
+ {(['ALL', 'RUNNING', 'SUCCESS', 'ERROR'] as const).map((f) => ( + + ))} +
+
+ + {loading ? ( +

Loading runs...

+ ) : runs.length === 0 ? ( +
+

No runs found.

+
+ ) : ( +
+
+ + + + + + + + + + + + + {runs.map((run) => { + const started = new Date(run.startedAt); + const ended = run.endedAt ? new Date(run.endedAt) : null; + const duration = ended + ? Math.round((ended.getTime() - started.getTime()) / 1000) + : Math.round((Date.now() - started.getTime()) / 1000); + const durationText = duration < 60 + ? `${duration}s` + : `${Math.floor(duration / 60)}m ${duration % 60}s`; + + return ( + + + + + + + + + ); + })} + +
StatusRuleStage / ProgressArticlesStartedDuration
+ + {statusIcon[run.status]} + {run.status} + + + + {run.rule.name} + + + {run.status === 'RUNNING' && run.stage ? ( + + {run.stage} + {run.progress ? ` • ${run.progress}` : ''} + + ) : ( + + )} + {run.articlesFound}{started.toLocaleString()}{durationText}
+
+ + {runs.some((r) => r.error) && ( +
+

+ + Errors +

+
+ {runs + .filter((r) => r.error) + .map((run) => ( +
+
+ {run.rule.name} — {new Date(run.startedAt).toLocaleString()} +
+
+                        {run.error}
+                      
+
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/app/api/admin/runs/route.ts b/src/app/api/admin/runs/route.ts new file mode 100644 index 0000000..391f5d0 --- /dev/null +++ b/src/app/api/admin/runs/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { RunStatus } from '@prisma/client'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const status = searchParams.get('status') as RunStatus | null; + const ruleId = searchParams.get('ruleId'); + const limit = Number(searchParams.get('limit') || '100'); + const offset = Number(searchParams.get('offset') || '0'); + + const where: { + status?: RunStatus; + ruleId?: string; + } = {}; + if (status) where.status = status; + if (ruleId) where.ruleId = ruleId; + + const [runs, total] = await Promise.all([ + prisma.searchRun.findMany({ + where, + orderBy: { startedAt: 'desc' }, + take: limit, + skip: offset, + include: { rule: { select: { name: true } } }, + }), + prisma.searchRun.count({ where }), + ]); + + return NextResponse.json({ runs, total }); +}