← 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>
  );
}