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:
@@ -2,6 +2,15 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.30.0 — 2026-09-19 — PCB review pipeline (exam, not auto-place)
|
||||
|
||||
Parallel `MODE=pcb` job examines an uploaded `.kicad_pcb`: domains/groups, trace inventory, deterministic layout checks, and per-IC AI datasheet review. Findings merge into the report with a Layout filter. No packing or pcbnew write-back.
|
||||
|
||||
- [New] `POST /api/pipeline/{id}/pcb/start` — PCB exam job (`pcb_status`).
|
||||
- [New] Deterministic `PE-LAY-*`, `PE-PLC-*`, `PE-SI-001`, `PE-DRT-001` with required recommendations.
|
||||
- [New] AI PCB review (same loop as schematic) with layout/domain context.
|
||||
- [Changed] Schematic review no longer seeds placement/SI checks.
|
||||
|
||||
## 2.29.1 — 2026-09-13 — Fix sign-in /login 404 after rebrand
|
||||
|
||||
Landing CTAs pointed at `/login`, which did not exist (page is `/sign-in`). Added redirects, accepted legacy JWT issuer `pinscope-local`, and CORS for both Periscope and Pinscope hosts.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +187,7 @@ type NavItem =
|
||||
|
||||
const PROJECT_NAV_ITEMS: NavItem[] = [
|
||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
|
||||
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
||||
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
|
||||
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
|
||||
@@ -243,7 +244,7 @@ function ProjectNav({
|
||||
const currentTab = searchParams.get("tab");
|
||||
const isRunning = project?.status === "running";
|
||||
const isOnProgress = pathname === `${base}/progress`;
|
||||
// Nested routes (report / progress / placement): soft-nav to `?tab=` can
|
||||
// Nested routes (report / progress / placement / pcb): soft-nav to `?tab=` can
|
||||
// leave the report page mounted — use a full document navigation instead.
|
||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||
|
||||
|
||||
@@ -176,6 +176,21 @@ export function FindingCard({
|
||||
Automated check
|
||||
</span>
|
||||
)}
|
||||
{(finding.finding_id?.startsWith("PCB-") ||
|
||||
finding.source === "pcb_review" ||
|
||||
(finding.rule_id || "").startsWith("PE-PLC") ||
|
||||
(finding.rule_id || "").startsWith("PE-LAY") ||
|
||||
(finding.rule_id || "").startsWith("PE-SI") ||
|
||||
(finding.rule_id || "").startsWith("PE-DRT")) && (
|
||||
<span className="inline-flex items-center rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:text-emerald-300">
|
||||
Layout
|
||||
</span>
|
||||
)}
|
||||
{finding.rule_id && (
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{finding.rule_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -42,6 +42,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
const componentParam = searchParams.get("component");
|
||||
const reviewParam = searchParams.get("review");
|
||||
const searchParam = searchParams.get("q") ?? "";
|
||||
const domainParam = searchParams.get("domain");
|
||||
|
||||
const statusFilters = useMemo(() => {
|
||||
if (!statusParam) return new Set<FindingStatus>(["ERROR", "WARNING", "INFO"]);
|
||||
@@ -84,9 +85,23 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
|
||||
if (st && st !== "open") return false;
|
||||
}
|
||||
if (domainParam === "layout") {
|
||||
const layout =
|
||||
(f.finding_id || "").startsWith("PCB-") ||
|
||||
f.source === "pcb_review" ||
|
||||
(f.rule_id || "").startsWith("PE-PLC") ||
|
||||
(f.rule_id || "").startsWith("PE-LAY") ||
|
||||
(f.rule_id || "").startsWith("PE-SI") ||
|
||||
(f.rule_id || "").startsWith("PE-DRT");
|
||||
if (!layout) return false;
|
||||
} else if (domainParam === "schema") {
|
||||
const layout =
|
||||
(f.finding_id || "").startsWith("PCB-");
|
||||
if (layout) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[statusFilters, componentParam, searchParam, reviewParam, reviews]
|
||||
[statusFilters, componentParam, searchParam, reviewParam, reviews, domainParam]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -131,6 +146,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
onToggleNeedsReview={() =>
|
||||
updateParams({ review: reviewParam === "open" ? null : "open" })
|
||||
}
|
||||
domain={domainParam ?? "all"}
|
||||
onDomainChange={(v) => updateParams({ domain: v })}
|
||||
designators={designators}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -23,6 +23,8 @@ interface ReportFiltersProps {
|
||||
designators: string[];
|
||||
needsReview?: boolean;
|
||||
onToggleNeedsReview?: () => void;
|
||||
domain?: string;
|
||||
onDomainChange?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
|
||||
@@ -41,6 +43,8 @@ export function ReportFilters({
|
||||
designators,
|
||||
needsReview,
|
||||
onToggleNeedsReview,
|
||||
domain,
|
||||
onDomainChange,
|
||||
}: ReportFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -73,6 +77,24 @@ export function ReportFilters({
|
||||
Needs review
|
||||
</Button>
|
||||
)}
|
||||
{onDomainChange && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(["all", "schema", "layout"] as const).map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs capitalize",
|
||||
(domain || "all") === d && "bg-emerald-500/15 border-emerald-500/40",
|
||||
)}
|
||||
onClick={() => onDomainChange(d === "all" ? null : d)}
|
||||
>
|
||||
{d === "all" ? "All" : d === "schema" ? "Schematic" : "Layout"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { PipelineStep } from "@/lib/types";
|
||||
import { pcbEventsUrl } from "@/lib/api";
|
||||
import { useOptionalAuth } from "@/hooks/use-optional-auth";
|
||||
|
||||
const PCB_STAGES = [
|
||||
{ id: "ensure_graph", title: "Ensure design graph", description: "Reuse or build design_graph.json" },
|
||||
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
|
||||
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
|
||||
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
|
||||
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, pad nets, derating" },
|
||||
{ id: "ai_review", title: "AI datasheet exam", description: "Per-IC layout vs datasheet" },
|
||||
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
|
||||
] as const;
|
||||
|
||||
const STAGE_INDEX: Record<string, number> = Object.fromEntries(
|
||||
PCB_STAGES.map((s, i) => [s.id, i]),
|
||||
);
|
||||
|
||||
function createInitialSteps(): PipelineStep[] {
|
||||
return PCB_STAGES.map((s) => ({
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
status: "pending" as const,
|
||||
substeps: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function usePcbProgress(projectId: string | null, enabled = true) {
|
||||
const [steps, setSteps] = useState<PipelineStep[]>(createInitialSteps);
|
||||
const [done, setDone] = useState(false);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<{
|
||||
findings?: number;
|
||||
domains?: number;
|
||||
groups?: number;
|
||||
} | null>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const terminalRef = useRef(false);
|
||||
const { getToken } = useOptionalAuth();
|
||||
|
||||
const handleEvent = useCallback((event: MessageEvent) => {
|
||||
const eventType = event.type || "message";
|
||||
if (eventType === "heartbeat") return;
|
||||
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "pcb_complete") {
|
||||
setSummary({
|
||||
findings: Number(data.findings) || 0,
|
||||
domains: Number(data.domains) || 0,
|
||||
groups: Number(data.groups) || 0,
|
||||
});
|
||||
setDone(true);
|
||||
terminalRef.current = true;
|
||||
esRef.current?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "pcb_cancelled") {
|
||||
setCancelled(true);
|
||||
setDone(true);
|
||||
terminalRef.current = true;
|
||||
esRef.current?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === "pcb_error") {
|
||||
setError((data.error as string) || "PCB review failed");
|
||||
setDone(true);
|
||||
terminalRef.current = true;
|
||||
esRef.current?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType !== "pcb_step_update") return;
|
||||
|
||||
setStarted(true);
|
||||
const stage = data.stage as string;
|
||||
const status = data.status as "pending" | "running" | "complete" | "failed";
|
||||
const detail = data.detail as string | undefined;
|
||||
|
||||
setSteps((prev) => {
|
||||
const next = prev.map((s) => ({ ...s, substeps: [...s.substeps] }));
|
||||
const idx = STAGE_INDEX[stage];
|
||||
if (idx === undefined) return next;
|
||||
const step = next[idx];
|
||||
if (status === "running") {
|
||||
step.status = "running";
|
||||
if (detail) step.description = detail;
|
||||
} else if (status === "complete") {
|
||||
step.status = "complete";
|
||||
if (detail) step.description = detail;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !enabled) return;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let retries = 0;
|
||||
const MAX_RETRIES = 50;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
|
||||
async function connect() {
|
||||
if (closed) return;
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
const baseUrl = pcbEventsUrl(projectId!);
|
||||
const url = token ? `${baseUrl}?token=${token}` : baseUrl;
|
||||
|
||||
es = new EventSource(url);
|
||||
esRef.current = es;
|
||||
|
||||
for (const eventName of [
|
||||
"pcb_step_update",
|
||||
"pcb_complete",
|
||||
"pcb_error",
|
||||
"pcb_cancelled",
|
||||
"heartbeat",
|
||||
]) {
|
||||
es.addEventListener(eventName, (event: MessageEvent) => {
|
||||
retries = 0;
|
||||
handleEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
if (closed || terminalRef.current) return;
|
||||
es?.close();
|
||||
es = null;
|
||||
esRef.current = null;
|
||||
if (retries >= MAX_RETRIES) {
|
||||
setError("Lost connection to PCB review. Refresh to reconnect.");
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
retries++;
|
||||
reconnectTimer = setTimeout(connect, 30_000);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
es?.close();
|
||||
esRef.current = null;
|
||||
};
|
||||
}, [projectId, enabled, getToken, handleEvent]);
|
||||
|
||||
return { steps, done, cancelled, error, summary, started };
|
||||
}
|
||||
@@ -110,6 +110,8 @@ function mapProject(p: Record<string, unknown>): Project {
|
||||
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
|
||||
placementStatus: (p.placement_status as Project["placementStatus"]) ?? "draft",
|
||||
placementState: (p.placement_state as Record<string, unknown> | null) ?? null,
|
||||
pcbStatus: (p.pcb_status as Project["pcbStatus"]) ?? "draft",
|
||||
pcbState: (p.pcb_state as Record<string, unknown> | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,6 +616,47 @@ export function placementEventsUrl(projectId: string): string {
|
||||
return `${BASE}/api/pipeline/${projectId}/placement/events`;
|
||||
}
|
||||
|
||||
export async function startPcbPipeline(projectId: string) {
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/pipeline/${projectId}/pcb/start`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Failed to start PCB review" }));
|
||||
throw new Error(err.detail || "Failed to start PCB review");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function cancelPcbPipeline(projectId: string) {
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/pipeline/${projectId}/pcb/cancel`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Failed to cancel PCB review" }));
|
||||
throw new Error(err.detail || "Failed to cancel PCB review");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function pcbEventsUrl(projectId: string): string {
|
||||
return `${BASE}/api/pipeline/${projectId}/pcb/events`;
|
||||
}
|
||||
|
||||
export async function fetchPcbInventory(projectId: string): Promise<{
|
||||
nets: Array<Record<string, unknown>>;
|
||||
domains: string[];
|
||||
group_count: number;
|
||||
}> {
|
||||
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/pcb/inventory`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "PCB inventory not found" }));
|
||||
throw new Error(err.detail || "PCB inventory not found");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchPlacementPlan(projectId: string): Promise<PlacementPlan> {
|
||||
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -283,6 +283,8 @@ export interface Project {
|
||||
// Placement pipeline (parallel to analysis — topology only).
|
||||
placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
||||
placementState?: Record<string, unknown> | null;
|
||||
pcbStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
||||
pcbState?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type RoleHint =
|
||||
|
||||
Reference in New Issue
Block a user