Add Reprocess to retry failed IC reviews without the create wizard.
Successful reviews are kept; skipped ICs such as a DeepSeek 400 are run again. Reprocess all re-reviews every IC while still using the library cache. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
removeCollaborator,
|
||||
makeCollaboratorOwner,
|
||||
startPipeline,
|
||||
reprocessPipeline,
|
||||
resumePipeline,
|
||||
fetchPipelineEstimate,
|
||||
} from "@/lib/api";
|
||||
@@ -24,6 +25,7 @@ import type { Project, SkippedComponent, ApiLogEntry, BomSummaryRow, DeratingRow
|
||||
import {
|
||||
ArrowRight,
|
||||
Play,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
X,
|
||||
@@ -106,7 +108,14 @@ export default function ProjectDetailPage({
|
||||
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
|
||||
|
||||
const canRun = Boolean(project?.hasBom && project?.hasNetlist);
|
||||
const canStartFresh = project?.status !== "complete" && project?.status !== "paused_insufficient_credits";
|
||||
const canReprocess =
|
||||
project?.status === "complete" ||
|
||||
project?.status === "error" ||
|
||||
project?.status === "cancelled";
|
||||
const canStartFresh =
|
||||
project?.status !== "complete" &&
|
||||
project?.status !== "paused_insufficient_credits" &&
|
||||
!canReprocess;
|
||||
|
||||
// Pull a cost estimate when the project is ready to run, so we can show
|
||||
// the "~X credits" hint under the button. No blocking — the user can
|
||||
@@ -157,6 +166,20 @@ export default function ProjectDetailPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReprocess = async (mode: "failed" | "all") => {
|
||||
if (!canRun) return;
|
||||
setStarting(true);
|
||||
try {
|
||||
await reprocessPipeline(id, mode);
|
||||
router.push(`/project/${id}/progress`);
|
||||
} catch (e) {
|
||||
setStarting(false);
|
||||
alert(e instanceof Error ? e.message : "Failed to reprocess");
|
||||
}
|
||||
};
|
||||
|
||||
const hasFailedReviews = Boolean(hasSkipped);
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -190,16 +213,38 @@ export default function ProjectDetailPage({
|
||||
<Card>
|
||||
<CardContent className="py-3">
|
||||
{project.status === "complete" ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<span className="text-sm text-emerald-600 dark:text-emerald-400">
|
||||
Validation complete
|
||||
</span>
|
||||
<Link href={`/project/${id}/report`} className="ml-auto">
|
||||
<Button size="sm">
|
||||
View Report
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
<div className="flex flex-wrap items-center gap-2 sm:ml-auto">
|
||||
{hasFailedReviews && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={starting}
|
||||
onClick={() => handleReprocess("failed")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
{starting ? "Starting..." : "Retry failed reviews"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={starting}
|
||||
onClick={() => handleReprocess("all")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Reprocess all
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/project/${id}/report`}>
|
||||
<Button size="sm">
|
||||
View Report
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : isPaused ? (
|
||||
<span className="text-sm text-amber-600 dark:text-amber-400">
|
||||
@@ -207,11 +252,32 @@ export default function ProjectDetailPage({
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" disabled={!canRun || starting} onClick={handleRunPipeline}>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
{starting ? "Starting..." : "Run Pipeline"}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{(project.status === "error" || project.status === "cancelled") && canRun ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={starting}
|
||||
onClick={() => handleReprocess("failed")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
{starting ? "Starting..." : "Reprocess"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={starting}
|
||||
onClick={() => handleReprocess("all")}
|
||||
>
|
||||
Reprocess all
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" disabled={!canRun || starting} onClick={handleRunPipeline}>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
{starting ? "Starting..." : "Run Pipeline"}
|
||||
</Button>
|
||||
)}
|
||||
{!canRun && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Upload BOM and netlist to enable
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
|
||||
import { PausedRunBanner } from "@/components/billing/paused-run-banner";
|
||||
import { usePipelineProgress } from "@/hooks/use-pipeline-progress";
|
||||
import { cancelPipeline, fetchProject, resumePipeline } from "@/lib/api";
|
||||
import { cancelPipeline, fetchProject, resumePipeline, reprocessPipeline } from "@/lib/api";
|
||||
import type { PauseCheckpoint } from "@/lib/types";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Loader2,
|
||||
OctagonX,
|
||||
Ban,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function ProgressPage({
|
||||
@@ -51,6 +52,18 @@ export default function ProgressPage({
|
||||
const [projectPaused, setProjectPaused] = useState(false);
|
||||
const [projectCheckpoint, setProjectCheckpoint] = useState<PauseCheckpoint | null>(null);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
const [reprocessing, setReprocessing] = useState(false);
|
||||
|
||||
async function handleReprocess(mode: "failed" | "all") {
|
||||
setReprocessing(true);
|
||||
try {
|
||||
await reprocessPipeline(id, mode);
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
setReprocessing(false);
|
||||
alert(e instanceof Error ? e.message : "Failed to reprocess");
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch project name + initial paused state. The SSE stream only reports
|
||||
// `pipeline_paused` if the page is open when it fires; landing on the
|
||||
@@ -333,6 +346,15 @@ export default function ProgressPage({
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reprocessing}
|
||||
onClick={() => handleReprocess("failed")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
{reprocessing ? "Starting..." : "Reprocess"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -348,12 +370,28 @@ export default function ProgressPage({
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={reprocessing}
|
||||
onClick={() => handleReprocess("failed")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
{reprocessing ? "Starting..." : "Reprocess"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && error && !cancelled && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg border border-rose-500/30 bg-rose-500/5">
|
||||
<span className="text-sm text-rose-600 dark:text-rose-400">{error}</span>
|
||||
<span className="text-sm text-rose-600 dark:text-rose-400 flex-1">{error}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={reprocessing}
|
||||
onClick={() => handleReprocess("failed")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
{reprocessing ? "Starting..." : "Reprocess"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import { Download, RotateCcw } from "lucide-react";
|
||||
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";
|
||||
@@ -12,7 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Toast, useToast } from "@/components/ui/toast";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { fetchCollaborators, fetchProject, fetchMyFeedback } from "@/lib/api";
|
||||
import { fetchCollaborators, fetchProject, fetchMyFeedback, reprocessPipeline } from "@/lib/api";
|
||||
import { exportReportToExcel } from "@/lib/report-export";
|
||||
import { cn, getFindingKey } from "@/lib/utils";
|
||||
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
|
||||
@@ -26,6 +27,7 @@ interface FocusState {
|
||||
}
|
||||
|
||||
function ReportContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const { report, graph, loading, error } = useReport(projectId);
|
||||
const { user } = useOptionalUser();
|
||||
const [focus, setFocus] = useState<FocusState | null>(null);
|
||||
@@ -39,6 +41,18 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
const [feedbackFinding, setFeedbackFinding] = useState<Finding | null>(null);
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const [reportedFindingIds, setReportedFindingIds] = useState<Set<string>>(new Set());
|
||||
const [reprocessing, setReprocessing] = useState(false);
|
||||
|
||||
async function handleReprocessFailed() {
|
||||
setReprocessing(true);
|
||||
try {
|
||||
await reprocessPipeline(projectId, "failed");
|
||||
router.push(`/project/${projectId}/progress`);
|
||||
} catch (e) {
|
||||
setReprocessing(false);
|
||||
alert(e instanceof Error ? e.message : "Failed to reprocess");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyFeedback()
|
||||
@@ -211,14 +225,25 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
{new Date(report.timestamp).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportReportToExcel(report, graph, projectName)}
|
||||
disabled={report.findings.length === 0}
|
||||
>
|
||||
<Download /> Export Excel
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={reprocessing}
|
||||
onClick={handleReprocessFailed}
|
||||
>
|
||||
<RotateCcw />
|
||||
{reprocessing ? "Starting..." : "Retry failed reviews"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => exportReportToExcel(report, graph, projectName)}
|
||||
disabled={report.findings.length === 0}
|
||||
>
|
||||
<Download /> Export Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ReportSummary
|
||||
summary={report.summary}
|
||||
@@ -231,7 +256,8 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
{Object.keys(report.review_errors).length} IC review{Object.keys(report.review_errors).length === 1 ? "" : "s"} failed
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These ICs could not be reviewed against their datasheets. Re-run the pipeline to retry; if the failure repeats, share the error with support.
|
||||
These ICs could not be reviewed against their datasheets. Use Retry
|
||||
failed reviews to run them again without repeating ICs that already succeeded.
|
||||
</p>
|
||||
<ul className="space-y-1 text-xs">
|
||||
{Object.entries(report.review_errors).map(([ref, err]) => (
|
||||
|
||||
+16
-1
@@ -63,6 +63,7 @@ function mapProject(p: Record<string, unknown>): Project {
|
||||
hasBom: p.has_bom as boolean,
|
||||
datasheetCount: p.datasheet_count as number,
|
||||
skippedComponents: (p.skipped_components as SkippedComponent[] | null) ?? undefined,
|
||||
completedReviewRefs: (p.completed_review_refs as string[] | null) ?? undefined,
|
||||
userId: p.user_id as string | undefined,
|
||||
collaborators: (p.collaborators as string[] | null) ?? undefined,
|
||||
creditsSpent: (p.credits_spent as number | undefined) ?? undefined,
|
||||
@@ -439,7 +440,21 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
|
||||
|
||||
// --- Pipeline ---
|
||||
|
||||
export async function startPipeline(projectId: string) {
|
||||
export async function reprocessPipeline(
|
||||
projectId: string,
|
||||
mode: "failed" | "all" = "failed",
|
||||
) {
|
||||
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/reprocess`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Failed to reprocess" }));
|
||||
throw new Error(err.detail || "Failed to reprocess pipeline");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/start`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -227,6 +227,7 @@ export interface Project {
|
||||
hasBom: boolean;
|
||||
datasheetCount: number;
|
||||
skippedComponents?: SkippedComponent[];
|
||||
completedReviewRefs?: string[];
|
||||
userId?: string;
|
||||
collaborators?: string[];
|
||||
creditsSpent?: number;
|
||||
|
||||
Reference in New Issue
Block a user