diff --git a/app/console/attack-pipeline/page.tsx b/app/console/attack-pipeline/page.tsx
index 7b6dbb9..f2b37fc 100644
--- a/app/console/attack-pipeline/page.tsx
+++ b/app/console/attack-pipeline/page.tsx
@@ -135,7 +135,6 @@ function ScoreGauge({ score }: { score: number }) {
const r = 54;
const cx = 64;
const cy = 64;
- // Start at 180° (left), sweep clockwise to angle
const toRad = (deg: number) => (deg * Math.PI) / 180;
const sx = cx + r * Math.cos(toRad(180));
const sy = cy + r * Math.sin(toRad(180));
@@ -175,7 +174,7 @@ function CurlCopy({ cmd }: { cmd: string }) {
function FindingRow({ f }: { f: Finding }) {
const [open, setOpen] = useState(false);
- if (f.result === "pass") return null; // only show failures
+ if (f.result === "pass") return null;
return (
(null);
const currentRunIdRef = useRef(null);
+ // Stable log line counter so keys never collide even after clears
+ const logCounterRef = useRef(0);
+ const [logLines, setLogLines] = useState<{ id: string; text: string }[]>([]);
+
const selectedDeployment = deployments.find((d) => d.sandboxId === selectedSandbox);
const targetUrl = useCustomUrl ? customUrl.trim() : (selectedDeployment?.publicUrl ?? "");
@@ -260,7 +263,6 @@ export default function AttackPipelinePage() {
.catch(() => null);
}, []);
- // Load run history (used for manual refresh buttons and post-scan callbacks)
const fetchRuns = useCallback(async () => {
setRunsLoading(true);
try {
@@ -271,7 +273,6 @@ export default function AttackPipelinePage() {
setRunsLoading(false);
}, []);
- // Initial load — use Promise callbacks so setState is never called synchronously in the effect body
useEffect(() => {
let active = true;
fetch("/api/attack-pipeline")
@@ -285,7 +286,14 @@ export default function AttackPipelinePage() {
// Auto-scroll logs
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [logs]);
+ }, [logLines]);
+
+ const appendLog = (text: string) => {
+ const id = `log-${++logCounterRef.current}`;
+ setLogLines((prev) => [...prev, { id, text }]);
+ // Keep legacy `logs` in sync for anything that reads it
+ setLogs((prev) => [...prev, text]);
+ };
const loadReport = async (runId: string) => {
try {
@@ -293,6 +301,7 @@ export default function AttackPipelinePage() {
const data = await res.json();
if (data.ok && data.report) {
setReport(data.report as Report);
+ setLogLines([]);
setLogs([]);
setError(null);
}
@@ -301,7 +310,7 @@ export default function AttackPipelinePage() {
const handleStop = async () => {
abortRef.current?.abort();
- setLogs((prev) => [...prev, "⚠ Scan stopped by user."]);
+ appendLog("⚠ Scan stopped by user.");
setScanning(false);
const runId = currentRunIdRef.current;
if (runId) {
@@ -320,6 +329,7 @@ export default function AttackPipelinePage() {
if (!targetUrl.startsWith("http")) { setError("URL must start with http:// or https://"); return; }
setError(null);
setReport(null);
+ setLogLines([]);
setLogs([]);
setScanning(true);
currentRunIdRef.current = null;
@@ -364,14 +374,14 @@ export default function AttackPipelinePage() {
if (ev.type === "start" && ev.runId) {
currentRunIdRef.current = ev.runId;
} else if (ev.type === "progress" && ev.msg) {
- setLogs((prev) => [...prev, ev.msg!]);
+ appendLog(ev.msg);
} else if (ev.type === "complete" && ev.report) {
setReport(ev.report);
- setLogs((prev) => [...prev, "✓ Scan complete."]);
+ appendLog("✓ Scan complete.");
fetchRuns();
} else if (ev.type === "error") {
setError(ev.error ?? "Scan failed");
- setLogs((prev) => [...prev, `✗ Error: ${ev.error}`]);
+ appendLog(`✗ Error: ${ev.error}`);
fetchRuns();
}
} catch { /* malformed event */ }
@@ -523,7 +533,7 @@ export default function AttackPipelinePage() {
{/* Live terminal */}
- {(scanning || logs.length > 0) && (
+ {(scanning || logLines.length > 0) && (
@@ -531,8 +541,9 @@ export default function AttackPipelinePage() {
{scanning && }
- {logs.map((line, i) => (
-
{line}
+ {/* FIX #39: stable key per log line instead of array index */}
+ {logLines.map((line) => (
+
{line.text}
))}
@@ -565,6 +576,7 @@ export default function AttackPipelinePage() {
{ label: "Passed", count: report.summary.passed, color: "text-green-600 dark:text-green-400", bg: "bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20" },
{ label: "Total", count: report.findings.length, color: "text-gray-700 dark:text-zinc-300", bg: "bg-gray-50 dark:bg-zinc-800 border-gray-200 dark:border-zinc-700" },
].map(({ label, count, color, bg }) => (
+ // FIX #39: `label` is a unique static string — safe as key
{label}
{count}
@@ -585,7 +597,8 @@ export default function AttackPipelinePage() {
Key Findings
{report.aiAnalysis.criticalFindings.map((f, i) => (
-
+ // FIX #39: deterministic key combining content slug + position
+
{f}
))}
@@ -597,7 +610,8 @@ export default function AttackPipelinePage() {
Recommendations