← back to Govarbitrage
src/app/agent-ops/page.tsx
172 lines
import Link from "next/link";
import { prisma } from "@/lib/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export const dynamic = "force-dynamic";
// Ops console for the agent layer: one card per agent (last run, status,
// items, summary) + the latest findings across all agents.
const AGENTS = ["appraiser", "skeptic", "scout"] as const;
const AGENT_BLURBS: Record<(typeof AGENTS)[number], string> = {
appraiser: "Re-researches weakly-identified listings with the local AI so valuations are item-specific.",
skeptic: "Adversarial digest gate — flags clone valuations, low confidence, stale closing data in the top-ranked set.",
scout: "Hot deals closing within 48h at score ≥ 75; CRITICAL under 6h.",
};
const fmtWhen = (d: Date) =>
d.toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
function statusVariant(status: string) {
if (status === "OK") return "positive" as const;
if (status === "FAILED") return "negative" as const;
return "warning" as const; // RUNNING
}
function severityVariant(severity: string) {
if (severity === "CRITICAL") return "negative" as const;
if (severity === "WARN") return "warning" as const;
return "primary" as const; // INFO
}
export default async function AgentOpsPage() {
const [lastRuns, findings] = await Promise.all([
Promise.all(
AGENTS.map((agent) =>
prisma.agentRun.findFirst({
where: { agent },
orderBy: { startedAt: "desc" },
include: { _count: { select: { findings: true } } },
}),
),
),
prisma.agentFinding.findMany({ orderBy: { createdAt: "desc" }, take: 100 }),
]);
return (
<main className="mx-auto max-w-[1400px] space-y-5 p-5">
<header className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">
Agent <span className="text-primary">Ops</span>
</h1>
<p className="text-sm text-muted-foreground">
Appraiser · Skeptic · Scout — run history and findings for the AI agent layer.
</p>
</div>
<nav className="flex items-center gap-4 text-sm">
<Link href="/" className="text-primary underline-offset-4 hover:underline">
← Dashboard
</Link>
</nav>
</header>
<section className="grid gap-4 md:grid-cols-3">
{AGENTS.map((agent, i) => {
const run = lastRuns[i];
return (
<Card key={agent}>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="capitalize text-foreground">{agent}</CardTitle>
{run ? (
<Badge variant={statusVariant(run.status)}>{run.status}</Badge>
) : (
<Badge variant="outline">never run</Badge>
)}
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p className="text-xs text-muted-foreground">{AGENT_BLURBS[agent]}</p>
{run ? (
<>
<div className="text-muted-foreground" title={run.startedAt.toISOString()}>
🕓 {fmtWhen(run.startedAt)}
{run.finishedAt
? ` · ${Math.max(1, Math.round((run.finishedAt.getTime() - run.startedAt.getTime()) / 1000))}s`
: " · running"}
</div>
<div>
{run.itemsProcessed} items · {run._count.findings} finding{run._count.findings === 1 ? "" : "s"}
</div>
{run.summary && <p className="text-muted-foreground">{run.summary}</p>}
</>
) : (
<p className="text-muted-foreground">
Run with <code className="rounded bg-accent px-1">npm run agents -- {agent}</code>
</p>
)}
</CardContent>
</Card>
);
})}
</section>
<Card>
<CardHeader>
<CardTitle>Latest findings ({findings.length})</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
{findings.length === 0 ? (
<p className="text-sm text-muted-foreground">No findings yet — run an agent to populate this table.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="py-2 pr-3 font-medium">Time</th>
<th className="py-2 pr-3 font-medium">Agent</th>
<th className="py-2 pr-3 font-medium">Severity</th>
<th className="py-2 pr-3 font-medium">Kind</th>
<th className="py-2 pr-3 font-medium">Finding</th>
<th className="py-2 font-medium">Listing</th>
</tr>
</thead>
<tbody>
{findings.map((f) => (
<tr key={f.id} className="border-b align-top last:border-0">
<td
className="whitespace-nowrap py-2 pr-3 text-muted-foreground"
title={f.createdAt.toISOString()}
>
{fmtWhen(f.createdAt)}
</td>
<td className="py-2 pr-3 capitalize">{f.agent}</td>
<td className="py-2 pr-3">
<Badge variant={severityVariant(f.severity)}>{f.severity}</Badge>
</td>
<td className="whitespace-nowrap py-2 pr-3 text-xs text-muted-foreground">{f.kind}</td>
<td className="max-w-[560px] py-2 pr-3">
<div>{f.title}</div>
{f.detail && (
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{f.detail}</div>
)}
</td>
<td className="py-2">
{f.listingId ? (
<Link
href={`/listings/${f.listingId}`}
className="text-primary underline-offset-4 hover:underline"
>
{(f.listingTitle || "listing").slice(0, 40)} →
</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</main>
);
}