Add MODE=pcb layout exam job with AI and deterministic checks.

Parallel pcb_status pipeline: parse board, classify domains/groups,
inventory traces, PE-LAY/PLC/SI/DRT checks, per-IC datasheet review.
Findings merge into the report UI. No auto-place or pcbnew write-back.
This commit is contained in:
2026-09-19 22:54:17 +02:00
parent a2011dad91
commit 16c606ae3d
28 changed files with 1906 additions and 32 deletions
+63 -1
View File
@@ -17,6 +17,7 @@ import {
makeCollaboratorOwner,
startPipeline,
startPlacementPipeline,
startPcbPipeline,
reprocessPipeline,
resumePipeline,
fetchPipelineEstimate,
@@ -40,6 +41,7 @@ import {
Check,
Upload,
LayoutGrid,
CircuitBoard,
} from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { ImpedancePanel } from "@/components/project/impedance-panel";
@@ -132,10 +134,20 @@ export default function ProjectDetailPage({
}
}, [project?.placementStatus, id, router]);
useEffect(() => {
if (
project?.pcbStatus === "running" ||
project?.pcbStatus === "queued"
) {
router.replace(`/project/${id}/pcb`);
}
}, [project?.pcbStatus, id, router]);
const searchParams = useSearchParams();
const tab = searchParams.get("tab") ?? "bom";
const [starting, setStarting] = useState(false);
const [startingPlacement, setStartingPlacement] = useState(false);
const [startingPcb, setStartingPcb] = useState(false);
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
const [rerunProject, setRerunProject] = useState<Project | null>(null);
@@ -215,8 +227,13 @@ export default function ProjectDetailPage({
const placementBusy =
project?.placementStatus === "running" ||
project?.placementStatus === "queued";
const pcbBusy =
project?.pcbStatus === "running" ||
project?.pcbStatus === "queued";
const canStartPlacement =
Boolean(canRun) && !analysisBusy && !placementBusy;
Boolean(canRun) && !analysisBusy && !placementBusy && !pcbBusy;
const canStartPcb =
Boolean(project?.hasPcb && canRun) && !analysisBusy && !placementBusy && !pcbBusy;
const handlePlacement = async () => {
if (!canStartPlacement) return;
@@ -230,6 +247,18 @@ export default function ProjectDetailPage({
}
};
const handlePcbReview = async () => {
if (!canStartPcb) return;
setStartingPcb(true);
try {
await startPcbPipeline(id);
router.push(`/project/${id}/pcb`);
} catch (e) {
setStartingPcb(false);
alert(e instanceof Error ? e.message : "Failed to start PCB review");
}
};
const hasFailedReviews = Boolean(hasSkipped);
return (
@@ -338,6 +367,26 @@ export default function ProjectDetailPage({
</Button>
</Link>
)}
<Button
size="sm"
variant="outline"
disabled={!canStartPcb || startingPcb}
onClick={handlePcbReview}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{startingPcb
? "Starting…"
: project.pcbStatus === "complete"
? "Re-run PCB review"
: "Run PCB review"}
</Button>
{project.pcbStatus === "complete" && (
<Link href={`/project/${id}/pcb`}>
<Button size="sm" variant="ghost">
View PCB review
</Button>
</Link>
)}
</div>
</div>
) : isPaused ? (
@@ -385,6 +434,19 @@ export default function ProjectDetailPage({
? "Rebuild placement"
: "Build placement plan"}
</Button>
<Button
size="sm"
variant="outline"
disabled={!canStartPcb || startingPcb}
onClick={handlePcbReview}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{startingPcb
? "Starting…"
: project.pcbStatus === "complete"
? "Re-run PCB review"
: "Run PCB review"}
</Button>
{!canRun && (
<span className="text-xs text-muted-foreground">
Upload BOM and netlist to enable
@@ -0,0 +1,209 @@
"use client";
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { usePcbProgress } from "@/hooks/use-pcb-progress";
import {
cancelPcbPipeline,
fetchPcbInventory,
fetchProject,
startPcbPipeline,
} from "@/lib/api";
import {
ArrowLeft,
CheckCircle2,
Loader2,
OctagonX,
CircuitBoard,
} from "lucide-react";
export default function PcbReviewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [projectName, setProjectName] = useState("");
const [pcbStatus, setPcbStatus] = useState<string>("draft");
const [inventory, setInventory] = useState<{
nets: Array<Record<string, unknown>>;
domains: string[];
group_count: number;
} | null>(null);
const [cancelling, setCancelling] = useState(false);
const [starting, setStarting] = useState(false);
const [statusLoaded, setStatusLoaded] = useState(false);
const active = pcbStatus === "queued" || pcbStatus === "running";
const alreadyDone = pcbStatus === "complete";
const { steps, done, cancelled, error, summary, started } = usePcbProgress(
id,
statusLoaded && active,
);
useEffect(() => {
fetchProject(id)
.then((p) => {
setProjectName(p.name);
setPcbStatus(p.pcbStatus ?? "draft");
setStatusLoaded(true);
})
.catch(() => setStatusLoaded(true));
}, [id]);
useEffect(() => {
if (done) setPcbStatus("complete");
}, [done]);
useEffect(() => {
if (!statusLoaded) return;
if (!alreadyDone && !done) return;
fetchPcbInventory(id)
.then(setInventory)
.catch(() => setInventory(null));
}, [id, alreadyDone, done, statusLoaded]);
const handleCancel = async () => {
setCancelling(true);
try {
await cancelPcbPipeline(id);
} catch {
// may already be finished
} finally {
setCancelling(false);
}
};
const handleStart = async () => {
setStarting(true);
try {
await startPcbPipeline(id);
setPcbStatus("queued");
window.location.reload();
} catch (e) {
setStarting(false);
alert(e instanceof Error ? e.message : "Failed to start PCB review");
}
};
const finished = alreadyDone || done;
const isRunning = statusLoaded && active && !done && !cancelled && !error;
const isQueued = isRunning && !started;
return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">PCB review</h1>
<p className="text-sm text-muted-foreground">
{projectName ? `${projectName} · ` : ""}
Exam of the existing board not auto-placement
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/project/${id}/report?domain=layout`}>
<Button size="sm" variant="ghost">
Report
</Button>
</Link>
<Link href={`/project/${id}`}>
<Button size="sm" variant="outline">
<ArrowLeft className="h-4 w-4 mr-1" />
Project
</Button>
</Link>
</div>
</div>
{!statusLoaded && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Checking PCB review status
</div>
)}
{isQueued && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Loader2 className="h-5 w-5 text-blue-600 animate-spin" />
<p className="text-sm">Queued starting PCB worker</p>
</div>
)}
{isRunning && !isQueued && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Progress</CardTitle>
</CardHeader>
<CardContent>
<PipelineStepper steps={steps} />
<div className="mt-4">
<Button
size="sm"
variant="outline"
disabled={cancelling}
onClick={handleCancel}
>
<OctagonX className="h-4 w-4 mr-1" />
{cancelling ? "Cancelling…" : "Cancel"}
</Button>
</div>
</CardContent>
</Card>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{cancelled && (
<p className="text-sm text-muted-foreground">PCB review cancelled.</p>
)}
{finished && !cancelled && !error && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Review complete
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
{summary?.findings ?? "—"} findings · {summary?.domains ?? inventory?.domains.length ?? "—"} domains ·{" "}
{summary?.groups ?? inventory?.group_count ?? "—"} groups
</p>
{inventory && (
<p className="text-muted-foreground">
{inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists).
</p>
)}
<div className="flex flex-wrap gap-2">
<Link href={`/project/${id}/report?domain=layout`}>
<Button size="sm">View layout findings</Button>
</Link>
<Button
size="sm"
variant="outline"
disabled={starting}
onClick={handleStart}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{starting ? "Starting…" : "Re-run PCB review"}
</Button>
</div>
</CardContent>
</Card>
)}
{statusLoaded && pcbStatus === "draft" && (
<Button size="sm" disabled={starting} onClick={handleStart}>
<CircuitBoard className="h-4 w-4 mr-1" />
{starting ? "Starting…" : "Run PCB review"}
</Button>
)}
</div>
);
}