diff --git a/backend/routers/projects.py b/backend/routers/projects.py index eee2d6f..617e0fc 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -143,7 +143,12 @@ async def get_project(project_id: str, request: Request): storage = get_storage(request) owner_user_id, meta = await resolve_or_404(request, project_id) healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id) - return (healed or meta).model_dump() + if healed is not None: + meta = healed + healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id) + if healed_pl is not None: + meta = healed_pl + return meta.model_dump() @router.delete("/projects/{project_id}") diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index c1d5206..d37d59d 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -1551,9 +1551,11 @@ def _write_functional_groups(ws: PipelineWorkspace, graph) -> None: if extracted_dir.is_dir(): cmap = _build_constraints_map(_load_datasheets(extracted_dir)) report = build_functional_groups(graph, cmap) - out = ws.local_path("functional_groups.json") - out.write_text(report.model_dump_json(indent=2) + "\n") - ws._upload_file("functional_groups.json") + payload = report.model_dump_json(indent=2) + "\n" + for name in ("functional_groups.json", "placement_plan.json"): + out = ws.local_path(name) + out.write_text(payload) + ws._upload_file(name) except Exception: logger.exception("functional_groups.json write failed — continuing") diff --git a/backend/services/projects.py b/backend/services/projects.py index c88987f..35ca564 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -326,6 +326,77 @@ def heal_if_pipeline_finished( return None +def heal_if_placement_stuck( + storage: StorageBackend, user_id: str, project_id: str, +) -> ProjectMeta | None: + """Unstick placement_status queued/running when the worker is gone. + + - Last event ``placement_complete`` → ``complete`` + - Dead worker + plan artifact present → ``complete`` + - Dead worker otherwise → ``error`` + """ + meta = get_project(storage, user_id, project_id) + if meta is None: + return None + pst = meta.placement_status or "draft" + if pst not in ("queued", "running"): + return None + + prefix = _project_prefix(user_id, project_id) + events_prefix = f"{prefix}/events/" + last_event = None + try: + keys = sorted( + k for k in storage.list_prefix(events_prefix) + if k.endswith(".json") and "/events/" in k + ) + if keys: + last_event = storage.read_json(keys[-1]) + except Exception: + last_event = None + + if (last_event or {}).get("event") == "placement_complete": + data = (last_event or {}).get("data") or {} + return update_project( + storage, user_id, project_id, + placement_status="complete", + placement_cancel_requested=False, + placement_state={ + "domains": data.get("domains"), + "groups": data.get("groups"), + }, + ) + + from backend.services import job_runner + + exec_name = meta.placement_execution_name or f"local/placement/{project_id}" + try: + state = job_runner.get_execution_state(exec_name) + except Exception: + state = "unknown" + + if state in ("pending", "running"): + return None + + has_plan = ( + storage.exists(f"{prefix}/placement_plan.json") + or storage.exists(f"{prefix}/functional_groups.json") + ) + if has_plan: + return update_project( + storage, user_id, project_id, + placement_status="complete", + placement_cancel_requested=False, + placement_state=meta.placement_state, + ) + return update_project( + storage, user_id, project_id, + placement_status="error", + placement_cancel_requested=False, + placement_state={"error": f"Placement worker terminated ({state})"}, + ) + + # --- CRUD --- diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index af0cf7c..a7b405c 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.28.5 — 2026-09-13 — Domains & power-rail topology views + +Browse routing-first functional groups from the project sidebar: Domains (power-net islands) and Power rails, each highlighting IC groups and satellite roles. + +- [New] Sidebar tabs **Domains** and **Power rails** (from `placement_plan` / `functional_groups`). +- [Changed] Analysis `graph_build` also writes `placement_plan.json`. +- [Fixed] Heal stuck Placement `queued`/`running` when the worker is dead. + ## 2.28.4 — 2026-09-13 — Unstick zombie running pipelines A finished run whose worker died before flipping meta stayed `running`, so Progress showed Review spinning forever. Heal those projects when the event log already ends with `pipeline_complete`. diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index 4f1f12e..3948c2b 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -43,6 +43,7 @@ import { } from "lucide-react"; import { useOptionalUser } from "@/hooks/use-optional-auth"; import { ImpedancePanel } from "@/components/project/impedance-panel"; +import { TopologyPanel } from "@/components/project/topology-panel"; import { PcbUploadButton } from "@/components/project/pcb-upload"; import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet"; @@ -401,6 +402,14 @@ export default function ProjectDetailPage({ /> )} + {tab === "domains" && ( + + )} + + {tab === "rails" && ( + + )} + {tab === "derating" && ( >; - export default function PlacementPage({ params, }: { @@ -31,13 +32,17 @@ export default function PlacementPage({ const router = useRouter(); const [projectName, setProjectName] = useState(""); const [placementStatus, setPlacementStatus] = useState("draft"); - const [plan, setPlan] = useState(null); + const [plan, setPlan] = useState(null); const [cancelling, setCancelling] = useState(false); + const [starting, setStarting] = useState(false); + const [statusLoaded, setStatusLoaded] = useState(false); + const active = + placementStatus === "queued" || placementStatus === "running"; const alreadyDone = placementStatus === "complete"; const { steps, done, cancelled, error, summary, started } = usePlacementProgress( id, - !alreadyDone && placementStatus !== "draft", + statusLoaded && active, ); useEffect(() => { @@ -45,16 +50,24 @@ export default function PlacementPage({ .then((p) => { setProjectName(p.name); setPlacementStatus(p.placementStatus ?? "draft"); + setStatusLoaded(true); }) - .catch(() => {}); + .catch(() => setStatusLoaded(true)); }, [id]); useEffect(() => { - if (!alreadyDone && !done) return; + if (done) setPlacementStatus("complete"); + }, [done]); + + useEffect(() => { + if (!statusLoaded) return; + if (!alreadyDone && !done && placementStatus !== "draft" && placementStatus !== "error") { + return; + } fetchPlacementPlan(id) .then(setPlan) .catch(() => setPlan(null)); - }, [id, alreadyDone, done]); + }, [id, alreadyDone, done, placementStatus, statusLoaded]); const handleCancel = async () => { setCancelling(true); @@ -67,9 +80,21 @@ export default function PlacementPage({ } }; + const handleStart = async () => { + setStarting(true); + try { + await startPlacementPipeline(id); + setPlacementStatus("queued"); + window.location.reload(); + } catch (e) { + setStarting(false); + alert(e instanceof Error ? e.message : "Failed to start placement"); + } + }; + const finished = alreadyDone || done; - const isRunning = !finished && !cancelled && !error; - const isQueued = isRunning && !started && !alreadyDone; + const isRunning = statusLoaded && active && !done && !cancelled && !error; + const isQueued = isRunning && !started; return (
@@ -81,14 +106,28 @@ export default function PlacementPage({ Routing-first topology (no millimetres)

- - - +
+ + + + + + +
+ {!statusLoaded && ( +
+ + Checking placement status… +
+ )} + {isQueued && (
@@ -133,25 +172,38 @@ export default function PlacementPage({ size="sm" className="mt-3" variant="outline" - onClick={() => router.push(`/project/${id}`)} + disabled={starting} + onClick={handleStart} > - Back to project + + {starting ? "Starting…" : "Retry placement"}
)} - {finished && !error && !cancelled && ( + {statusLoaded && !active && !error && !cancelled && (
-
- - Placement plan ready - {summary && ( + {finished || plan ? ( +
+ + Placement plan ready - · {summary.domains ?? plan?.domains.length ?? "?"} domains,{" "} - {summary.groups ?? plan?.groups.length ?? "?"} IC groups + · {summary?.domains ?? plan?.domains.length ?? "?"} domains,{" "} + {summary?.groups ?? plan?.groups.length ?? "?"} IC groups - )} -
+
+ ) : ( +
+

+ No placement plan yet. Build one (free, no LLM) or open Domains + if analysis already wrote functional groups. +

+ +
+ )} {plan && ( diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index 4bd2dfe..c73bd31 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -17,6 +17,8 @@ import { ScrollText, MessageSquareWarning, Library, + Boxes, + CircuitBoard, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthApi } from "@/hooks/use-auth-api"; @@ -186,6 +188,8 @@ type NavItem = const PROJECT_NAV_ITEMS: NavItem[] = [ { type: "route", path: "/report", label: "Report", icon: ClipboardList }, { 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 }, { type: "tab", tab: "derating", label: "Derating", icon: Zap }, { type: "tab", tab: "impedance", label: "Impedance", icon: Ruler }, { type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true }, diff --git a/frontend/src/components/project/topology-panel.tsx b/frontend/src/components/project/topology-panel.tsx new file mode 100644 index 0000000..a93ba04 --- /dev/null +++ b/frontend/src/components/project/topology-panel.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { fetchPlacementPlan } from "@/lib/api"; +import type { PlacementPlan, PlacementIcGroup, RoleHint } from "@/lib/types"; +import { LayoutGrid, Zap, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const ROLE_STYLES: Record = { + ic: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/30", + decoupling: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30", + bulk: "bg-teal-500/15 text-teal-700 dark:text-teal-300 border-teal-500/30", + load_cap: "bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30", + crystal: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/30", + filter: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30", + pullup: "bg-orange-500/15 text-orange-700 dark:text-orange-300 border-orange-500/30", + series: "bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30", + divider: "bg-pink-500/15 text-pink-700 dark:text-pink-300 border-pink-500/30", + bridge: "bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30", + other: "bg-muted text-muted-foreground border-border", +}; + +function RoleBadge({ role, label }: { role: RoleHint | "ic"; label: string }) { + return ( + + {label} + {role !== "ic" && ( + {role} + )} + + ); +} + +function GroupBlock({ group, emphasize }: { group: PlacementIcGroup; emphasize?: boolean }) { + return ( +
+
+ + {group.mpn && ( + {group.mpn} + )} + {group.component_subtype && ( + + {group.component_subtype} + + )} +
+ {group.satellites.length > 0 ? ( +
+ {group.satellites.map((s) => ( + + ))} +
+ ) : ( +

No satellite parts classified

+ )} +
+ ); +} + +function EmptyPlan({ projectId }: { projectId: string }) { + return ( + + +

+ No topology plan yet. Run analysis (graph build writes{" "} + functional_groups.json) or build a + placement plan. +

+ + + +
+
+ ); +} + +function DomainsView({ plan }: { plan: PlacementPlan }) { + const byRef = useMemo( + () => Object.fromEntries(plan.groups.map((g) => [g.ref, g])), + [plan.groups], + ); + + if (plan.domains.length === 0) { + return ( +

No power domains detected.

+ ); + } + + return ( +
+ {plan.domains.map((dom) => { + const groups = dom.ic_refs + .map((r) => byRef[r]) + .filter(Boolean) as PlacementIcGroup[]; + return ( + + +
+ {dom.domain_id} + + {groups.length} IC group{groups.length === 1 ? "" : "s"} + +
+

+ Power nets:{" "} + {dom.power_nets.length + ? dom.power_nets.map((n) => ( + + {n} + + )) + : "—"} +

+ {dom.assemble_order.length > 0 && ( +

+ Assemble: {dom.assemble_order.join(" → ")} +

+ )} +
+ +

+ Functional groups +

+ {groups.map((g) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +type RailRow = { + net: string; + domainIds: string[]; + groups: PlacementIcGroup[]; +}; + +function RailsView({ plan }: { plan: PlacementPlan }) { + const rails = useMemo(() => { + const byRef = Object.fromEntries(plan.groups.map((g) => [g.ref, g])); + const map = new Map; groupRefs: Set }>(); + + for (const dom of plan.domains) { + for (const net of dom.power_nets) { + let entry = map.get(net); + if (!entry) { + entry = { domainIds: new Set(), groupRefs: new Set() }; + map.set(net, entry); + } + entry.domainIds.add(dom.domain_id); + for (const iref of dom.ic_refs) entry.groupRefs.add(iref); + } + } + + // Also attach groups that list the rail on their nets / satellites + for (const g of plan.groups) { + for (const net of g.nets) { + const entry = map.get(net); + if (entry) entry.groupRefs.add(g.ref); + } + for (const s of g.satellites) { + for (const net of s.nets || []) { + const entry = map.get(net); + if (entry) entry.groupRefs.add(g.ref); + } + } + } + + const rows: RailRow[] = [...map.entries()] + .map(([net, v]) => ({ + net, + domainIds: [...v.domainIds].sort(), + groups: [...v.groupRefs] + .map((r) => byRef[r]) + .filter(Boolean) + .sort((a, b) => (a.rank ?? 99) - (b.rank ?? 99) || a.ref.localeCompare(b.ref)), + })) + .sort((a, b) => a.net.localeCompare(b.net)); + + return rows; + }, [plan]); + + if (rails.length === 0) { + return ( +

No power rails in the topology plan.

+ ); + } + + return ( +
+ {rails.map((rail) => ( + + +
+ + {rail.net} + + {rail.groups.length} group{rail.groups.length === 1 ? "" : "s"} + +
+ {rail.domainIds.length > 0 && ( +

+ Domain{rail.domainIds.length === 1 ? "" : "s"}:{" "} + {rail.domainIds.join(", ")} +

+ )} +
+ +

+ Functional groups on this rail +

+ {rail.groups.map((g) => { + const onRailSats = g.satellites.filter( + (s) => (s.nets || []).includes(rail.net), + ); + const highlight: PlacementIcGroup = { + ...g, + satellites: + onRailSats.length > 0 + ? onRailSats + : g.satellites.filter((s) => + ["decoupling", "bulk"].includes(s.role_hint || ""), + ), + }; + return ; + })} +
+
+ ))} +
+ ); +} + +export function TopologyPanel({ + projectId, + mode, +}: { + projectId: string; + mode: "domains" | "rails"; +}) { + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [missing, setMissing] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setMissing(false); + fetchPlacementPlan(projectId) + .then((p) => { + if (!cancelled) { + setPlan(p); + setMissing(false); + } + }) + .catch(() => { + if (!cancelled) { + setPlan(null); + setMissing(true); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + if (loading) { + return ( +
+ + Loading topology… +
+ ); + } + + if (missing || !plan) { + return ; + } + + return ( +
+
+

+ {mode === "domains" ? "Functional domains" : "Power rails"} +

+

+ {mode === "domains" + ? "Power-net islands with IC functional groups and satellite roles (routing-first, no mm)." + : "Each supply rail with the functional groups that hang off it."} +

+
+ {mode === "domains" ? : } +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3a03a18..d325cfe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -21,6 +21,7 @@ import type { LcscPayload, NetlistPreviewDesignator, PauseCheckpoint, + PlacementPlan, Project, SkippedComponent, ValidationReport, @@ -608,24 +609,7 @@ export function placementEventsUrl(projectId: string): string { return `${BASE}/api/pipeline/${projectId}/placement/events`; } -export async function fetchPlacementPlan(projectId: string): Promise<{ - objective?: string; - domains: Array<{ - domain_id: string; - power_nets: string[]; - ic_refs: string[]; - assemble_order: string[]; - }>; - groups: Array<{ - ref: string; - mpn?: string | null; - component_subtype?: string | null; - rank?: number; - satellites: Array<{ ref: string; role_hint?: string; hop?: number }>; - layout_rules?: unknown[]; - assemble_order?: string[]; - }>; -}> { +export async function fetchPlacementPlan(projectId: string): Promise { const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`); if (!res.ok) { const err = await res.json().catch(() => ({ detail: "Placement plan not found" })); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index dac4439..b460165 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -285,6 +285,51 @@ export interface Project { placementState?: Record | null; } +export type RoleHint = + | "decoupling" + | "bulk" + | "load_cap" + | "filter" + | "pullup" + | "series" + | "divider" + | "bridge" + | "crystal" + | "other"; + +export interface PlacementSatellite { + ref: string; + component_type?: string; + component_subtype?: string | null; + nets?: string[]; + hop?: number; + role_hint?: RoleHint; +} + +export interface PlacementIcGroup { + ref: string; + mpn?: string | null; + component_subtype?: string | null; + rank?: number; + nets?: string[]; + satellites: PlacementSatellite[]; + layout_rules?: unknown[]; + assemble_order?: string[]; +} + +export interface PlacementDomain { + domain_id: string; + power_nets: string[]; + ic_refs: string[]; + assemble_order: string[]; +} + +export interface PlacementPlan { + objective?: string; + domains: PlacementDomain[]; + groups: PlacementIcGroup[]; +} + // One entry per EDIF sub-design (`&NNNN` ID prefix). Returned by the upload // endpoint and the subdesigns inspection endpoint; consumed by the wizard's // sub-design picker step.