feat: add rule Run History page with status and error details
- Add /admin/runs page listing all rule executions with filters for All/Running/Success/Error. - Add /api/admin/runs endpoint returning run status, stage, progress, duration, and error details. - Add Run History link to admin sidebar.
This commit is contained in:
170
src/app/admin/runs/page.tsx
Normal file
170
src/app/admin/runs/page.tsx
Normal file
@@ -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: <Loader2 size={16} className="animate-spin text-yellow-600" />,
|
||||||
|
SUCCESS: <CheckCircle size={16} className="text-green-600" />,
|
||||||
|
ERROR: <XCircle size={16} className="text-red-600" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
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<Run[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Run History</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(['ALL', 'RUNNING', 'SUCCESS', 'ERROR'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-lg ${
|
||||||
|
filter === f
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white dark:bg-neutral-900 border dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === 'ALL' ? 'All' : f.charAt(0) + f.slice(1).toLowerCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-neutral-500">Loading runs...</p>
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 p-8 text-center">
|
||||||
|
<p className="text-neutral-500 dark:text-neutral-400">No runs found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-sm border dark:border-neutral-800 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-50 dark:bg-neutral-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Rule</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Stage / Progress</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Articles</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Started</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y dark:divide-neutral-800">
|
||||||
|
{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 (
|
||||||
|
<tr key={run.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${statusClass[run.status]}`}>
|
||||||
|
{statusIcon[run.status]}
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link
|
||||||
|
href="/admin/rules"
|
||||||
|
className="font-medium hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
{run.rule.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{run.status === 'RUNNING' && run.stage ? (
|
||||||
|
<span className="text-yellow-700 dark:text-yellow-300">
|
||||||
|
{run.stage}
|
||||||
|
{run.progress ? ` • ${run.progress}` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{run.articlesFound}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-500">{started.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-500">{durationText}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runs.some((r) => r.error) && (
|
||||||
|
<div className="border-t dark:border-neutral-800 p-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<AlertCircle size={16} className="text-red-600" />
|
||||||
|
Errors
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{runs
|
||||||
|
.filter((r) => r.error)
|
||||||
|
.map((run) => (
|
||||||
|
<div
|
||||||
|
key={`${run.id}-error`}
|
||||||
|
className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-lg p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-red-800 dark:text-red-300 mb-1">
|
||||||
|
{run.rule.name} — {new Date(run.startedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap text-red-700 dark:text-red-200 font-mono text-xs">
|
||||||
|
{run.error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/api/admin/runs/route.ts
Normal file
31
src/app/api/admin/runs/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user