Replace findings list with a tree; filter ESD/PI false positives.
PE-ESD-001 only on J* connector–IC nets (skip NC, unconnected, VSYS/GND/3V3). PE-PI-001 treats any capacitor on the rail, including KiCad slash prefixes, as decoupling. Sort findings ERROR then WARNING then INFO, then RULE/RISK/REVIEW/INFO. Report sidebar is a collapsed expand-on-click tree instead of an all-open list. GET /report and complete_findings fail-soft and sort the same way.
This commit is contained in:
@@ -2,7 +2,16 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.33.0 — 2026-09-20 — Fase B: hierarchy, derating bands, timing, PI, ESD/return
|
||||
## 2.33.1 — 2026-09-20 — ESD/PI filters, ERROR-first sort, findings tree
|
||||
|
||||
PE-ESD-001 only on J* ∩ IC nets (no NC / unconnected / VSYS/GND/3V3 spam). PE-PI-001 treats any capacitor on the rail, including KiCad `/` names, as decoupling. Report API and UI sort ERROR then WARNING then INFO, then RULE > RISK > REVIEW > INFO. The report left list is an expand-on-click tree (IC → pin/net → cards), not a dump of open groups.
|
||||
|
||||
- [Fixed] Connector GPIO / onboard rails no longer flood PE-ESD-001.
|
||||
- [Fixed] Bulk or slash-prefixed caps (C68 `/VBUS`, C71–C75 VSYS) suppress “no local cap”.
|
||||
- [Changed] GET `/report` and `complete_findings` order by severity then class.
|
||||
- [Changed] Findings sidebar is a collapsed tree; PCB exam cards live in the same tree.
|
||||
|
||||
|
||||
|
||||
Usable slices (not a platform): component→pin→net→block inventory, capacitor PASS/MARGIN/RISK from Vop/Vrated, RC/strap timing only with numbers, local vs bulk PI, ESD and HS return as REVIEW.
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
|
||||
import { Download, RotateCcw } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||
import { useReport } from "@/hooks/use-report";
|
||||
import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
|
||||
import { ReportSummary } from "@/components/report/report-summary";
|
||||
import { FindingsList } from "@/components/report/findings-list";
|
||||
import { PcbExamSection } from "@/components/report/pcb-exam-section";
|
||||
import { FindingFocusView } from "@/components/report/finding-focus-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -29,8 +28,6 @@ interface FocusState {
|
||||
|
||||
function ReportContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const domainParam = searchParams.get("domain");
|
||||
const { report, graph, loading, error } = useReport(projectId);
|
||||
const { user } = useOptionalUser();
|
||||
const [focus, setFocus] = useState<FocusState | null>(null);
|
||||
@@ -353,26 +350,6 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{domainParam !== "schema" && (
|
||||
<PcbExamSection
|
||||
findings={report.findings}
|
||||
onViewReference={handleViewReference}
|
||||
projectId={projectId}
|
||||
isReviewed={isReviewed}
|
||||
toggleReviewed={handleToggleReviewed}
|
||||
findingKey={(f) => keyByFinding.get(f) ?? getFindingKey(f, 0)}
|
||||
comments={comments}
|
||||
collaborators={collaborators}
|
||||
currentUserId={user?.id}
|
||||
currentUserName={user?.name ?? user?.email ?? "User"}
|
||||
onCommentAdded={handleCommentAdded}
|
||||
onCommentDeleted={handleCommentDeleted}
|
||||
onReportFinding={handleReportFinding}
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={handleReviewSaved}
|
||||
/>
|
||||
)}
|
||||
<FindingsList
|
||||
findings={report.findings}
|
||||
graph={graph}
|
||||
|
||||
@@ -31,7 +31,7 @@ interface ComponentGroupProps {
|
||||
}
|
||||
|
||||
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: ComponentGroupProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const sorted = sortFindings(findings);
|
||||
|
||||
const errorCount = findings.filter((f) => f.status === "ERROR").length;
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ComponentGroup } from "./component-group";
|
||||
import { FindingsTree, FindingsTreeEmpty } from "./findings-tree";
|
||||
import { ReportFilters } from "./report-filters";
|
||||
import { ReviewedSection } from "./reviewed-section";
|
||||
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
|
||||
import { groupBy, getFindingKey } from "@/lib/utils";
|
||||
import { isLayoutFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
||||
import { isLayoutFinding } from "@/lib/layout-finding";
|
||||
|
||||
interface FindingsListProps {
|
||||
findings: Finding[];
|
||||
@@ -86,7 +86,6 @@ 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 (isPcbExamFinding(f)) return false;
|
||||
if (domainParam === "layout") {
|
||||
if (!isLayoutFinding(f)) return false;
|
||||
} else if (domainParam === "schema") {
|
||||
@@ -113,7 +112,6 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
});
|
||||
}, [findings, matchesFilters, findingKeyMap, isReviewed]);
|
||||
|
||||
const grouped = useMemo(() => groupBy(filtered, (f) => f.designator), [filtered]);
|
||||
const designators = useMemo(() => {
|
||||
const all = [...new Set(findings.map((f) => f.designator))];
|
||||
const byDesignator = groupBy(findings, (f) => f.designator);
|
||||
@@ -143,38 +141,29 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
||||
onDomainChange={(v) => updateParams({ domain: v })}
|
||||
designators={designators}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
{designators
|
||||
.filter((d) => grouped[d])
|
||||
.map((d) => (
|
||||
<ComponentGroup
|
||||
key={d}
|
||||
designator={d}
|
||||
findings={grouped[d]}
|
||||
component={graph.components[d]}
|
||||
onViewReference={onViewReference}
|
||||
findingKeys={findingKeyMap}
|
||||
isReviewed={isReviewed}
|
||||
onToggleReviewed={toggleReviewed}
|
||||
comments={comments}
|
||||
projectId={projectId}
|
||||
collaborators={collaborators}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onCommentAdded={onCommentAdded}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onReportFinding={onReportFinding}
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && reviewedFindings.length === 0 && findings.some((f) => !isPcbExamFinding(f)) && (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No findings match your filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<FindingsTree
|
||||
findings={filtered}
|
||||
graph={graph}
|
||||
onViewReference={onViewReference}
|
||||
findingKeys={findingKeyMap}
|
||||
isReviewed={isReviewed}
|
||||
onToggleReviewed={toggleReviewed}
|
||||
comments={comments}
|
||||
projectId={projectId}
|
||||
collaborators={collaborators}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onCommentAdded={onCommentAdded}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onReportFinding={onReportFinding}
|
||||
reportedFindingIds={reportedFindingIds}
|
||||
reviews={reviews}
|
||||
onReviewSaved={onReviewSaved}
|
||||
/>
|
||||
) : reviewedFindings.length === 0 && findings.length > 0 ? (
|
||||
<FindingsTreeEmpty />
|
||||
) : null}
|
||||
{reviewedFindings.length > 0 && (
|
||||
<ReviewedSection
|
||||
findings={reviewedFindings}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { FindingCard } from "./finding-card";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
import type {
|
||||
Finding,
|
||||
FindingComment,
|
||||
FindingReview,
|
||||
FindingStatus,
|
||||
Collaborator,
|
||||
Component,
|
||||
DesignGraph,
|
||||
} from "@/lib/types";
|
||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||
import { isPcbExamFinding } from "@/lib/layout-finding";
|
||||
|
||||
interface FindingsTreeProps {
|
||||
findings: Finding[];
|
||||
graph: DesignGraph;
|
||||
onViewReference: (finding: Finding) => void;
|
||||
findingKeys: Map<Finding, string>;
|
||||
isReviewed?: (key: string) => boolean;
|
||||
onToggleReviewed?: (key: string) => void;
|
||||
comments?: Record<string, FindingComment[]>;
|
||||
projectId?: string;
|
||||
collaborators?: Collaborator[];
|
||||
currentUserId?: string;
|
||||
currentUserName?: string;
|
||||
onCommentAdded?: (comment: FindingComment) => void;
|
||||
onCommentDeleted?: (commentId: string, findingId: string) => void;
|
||||
onReportFinding?: (finding: Finding) => void;
|
||||
reportedFindingIds?: Set<string>;
|
||||
reviews?: Record<string, FindingReview>;
|
||||
onReviewSaved?: (findingId: string, review: FindingReview) => void;
|
||||
}
|
||||
|
||||
function worstStatus(findings: Finding[]): FindingStatus {
|
||||
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
|
||||
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
|
||||
return "INFO";
|
||||
}
|
||||
|
||||
function pinNetLabel(f: Finding): string {
|
||||
const pin = (f.pins && f.pins[0]) || "";
|
||||
const net = f.net || "";
|
||||
if (pin && net) return `${pin} · ${net}`;
|
||||
if (net) return net;
|
||||
if (pin) return pin;
|
||||
return f.rule_id || "Finding";
|
||||
}
|
||||
|
||||
function TreeBranch({
|
||||
label,
|
||||
extra,
|
||||
findings,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
extra?: string;
|
||||
findings: Finding[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const worst = worstStatus(findings);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger
|
||||
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm truncate">{label}</span>
|
||||
{extra ? (
|
||||
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<StatusBadge status={worst} />
|
||||
<span className="text-xs text-muted-foreground">{findings.length}</span>
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTree(props: FindingsTreeProps) {
|
||||
const sorted = sortFindings(props.findings);
|
||||
const schematic = sorted.filter((f) => !isPcbExamFinding(f));
|
||||
const pcb = sorted.filter(isPcbExamFinding);
|
||||
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
||||
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
||||
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
||||
|
||||
const showRoots = roots.length > 1;
|
||||
|
||||
return (
|
||||
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
|
||||
{roots.map((root) => {
|
||||
const body = (
|
||||
<DesignatorForest
|
||||
findings={root.items}
|
||||
graph={props.graph}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
if (!showRoots) return <div key={root.id}>{body}</div>;
|
||||
return (
|
||||
<TreeBranch key={root.id} label={root.label} findings={root.items}>
|
||||
{body}
|
||||
</TreeBranch>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function DesignatorForest({
|
||||
findings,
|
||||
graph,
|
||||
...cardProps
|
||||
}: FindingsTreeProps) {
|
||||
const byDes = new Map<string, Finding[]>();
|
||||
for (const f of sortFindings(findings)) {
|
||||
const list = byDes.get(f.designator) ?? [];
|
||||
list.push(f);
|
||||
byDes.set(f.designator, list);
|
||||
}
|
||||
const designators = [...byDes.keys()].sort((a, b) => {
|
||||
const wa = worstStatus(byDes.get(a)!);
|
||||
const wb = worstStatus(byDes.get(b)!);
|
||||
const order = { ERROR: 0, WARNING: 1, INFO: 2 } as const;
|
||||
return order[wa] - order[wb] || a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{designators.map((d) => {
|
||||
const group = byDes.get(d)!;
|
||||
const component: Component | undefined = graph.components[d];
|
||||
const extra = [
|
||||
component?.mpn,
|
||||
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const byLeaf = new Map<string, Finding[]>();
|
||||
for (const f of group) {
|
||||
const label = pinNetLabel(f);
|
||||
const list = byLeaf.get(label) ?? [];
|
||||
list.push(f);
|
||||
byLeaf.set(label, list);
|
||||
}
|
||||
const leaves = [...byLeaf.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
return (
|
||||
<TreeBranch key={d} label={d} extra={extra} findings={group}>
|
||||
{leaves.map(([label, items]) => (
|
||||
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
|
||||
<div className="space-y-3 py-2">
|
||||
{sortFindings(items).map((f) => {
|
||||
const key = cardProps.findingKeys.get(f);
|
||||
return (
|
||||
<FindingCard
|
||||
key={key}
|
||||
finding={f}
|
||||
onViewReference={cardProps.onViewReference}
|
||||
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
|
||||
onCheckedChange={
|
||||
key && cardProps.onToggleReviewed
|
||||
? () => cardProps.onToggleReviewed!(key)
|
||||
: undefined
|
||||
}
|
||||
comments={f.finding_id ? cardProps.comments?.[f.finding_id] : undefined}
|
||||
projectId={cardProps.projectId}
|
||||
collaborators={cardProps.collaborators}
|
||||
currentUserId={cardProps.currentUserId}
|
||||
currentUserName={cardProps.currentUserName}
|
||||
onCommentAdded={cardProps.onCommentAdded}
|
||||
onCommentDeleted={cardProps.onCommentDeleted}
|
||||
onReportFinding={cardProps.onReportFinding}
|
||||
isReported={!!(f.finding_id && cardProps.reportedFindingIds?.has(f.finding_id))}
|
||||
review={f.finding_id ? cardProps.reviews?.[f.finding_id] : undefined}
|
||||
onReviewSaved={cardProps.onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TreeBranch>
|
||||
))}
|
||||
</TreeBranch>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTreeEmpty() {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">
|
||||
No findings match your filters.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { Finding, FindingStatus } from "./types";
|
||||
import type { Finding, FindingClass, FindingStatus } from "./types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -16,9 +16,19 @@ export function groupBy<T>(items: T[], key: (item: T) => string): Record<string,
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
||||
const CLASS_ORDER: Record<FindingClass, number> = { RULE: 0, RISK: 1, REVIEW: 2, INFO: 3 };
|
||||
|
||||
export function sortFindings(findings: Finding[]): Finding[] {
|
||||
return [...findings].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
|
||||
return [...findings].sort((a, b) => {
|
||||
const status = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||||
if (status !== 0) return status;
|
||||
const ac = CLASS_ORDER[a.finding_class ?? "INFO"] ?? 9;
|
||||
const bc = CLASS_ORDER[b.finding_class ?? "INFO"] ?? 9;
|
||||
if (ac !== bc) return ac - bc;
|
||||
return (a.designator || "").localeCompare(b.designator || "")
|
||||
|| (a.rule_id || "").localeCompare(b.rule_id || "")
|
||||
|| (a.finding || "").localeCompare(b.finding || "");
|
||||
});
|
||||
}
|
||||
|
||||
export function getFindingKey(finding: Finding, index: number): string {
|
||||
|
||||
Reference in New Issue
Block a user