|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useState, useTransition } from "react"; |
| 3 | +import { useCallback, useEffect, useRef, useState } from "react"; |
4 | 4 | import { useRouter } from "next/navigation"; |
5 | 5 | import { requestPortScan } from "@/app/actions/portScans"; |
6 | 6 |
|
7 | | -export function RequestScanButton({ projectId }: { projectId: string }) { |
| 7 | +type ScanStatus = "queued" | "running" | "done" | "failed"; |
| 8 | + |
| 9 | +const STATUS_LABEL: Record<ScanStatus, string> = { |
| 10 | + queued: "Queued…", |
| 11 | + running: "Scanning…", |
| 12 | + done: "Done", |
| 13 | + failed: "Failed", |
| 14 | +}; |
| 15 | +const STATUS_COLOR: Record<ScanStatus, string> = { |
| 16 | + queued: "var(--color-muted)", |
| 17 | + running: "var(--color-warn)", |
| 18 | + done: "var(--color-pass)", |
| 19 | + failed: "var(--color-fail)", |
| 20 | +}; |
| 21 | + |
| 22 | +export function RequestScanButton({ |
| 23 | + projectId, |
| 24 | + activeScanId = null, |
| 25 | +}: { |
| 26 | + projectId: string; |
| 27 | + activeScanId?: string | null; |
| 28 | +}) { |
8 | 29 | const router = useRouter(); |
9 | | - const [pending, startTransition] = useTransition(); |
| 30 | + const [status, setStatus] = useState<ScanStatus | null>(null); |
| 31 | + const [busy, setBusy] = useState(false); |
10 | 32 | const [error, setError] = useState<string | null>(null); |
| 33 | + const esRef = useRef<EventSource | null>(null); |
| 34 | + |
| 35 | + const subscribe = useCallback( |
| 36 | + (scanId: string) => { |
| 37 | + esRef.current?.close(); |
| 38 | + const es = new EventSource( |
| 39 | + `/projects/${projectId}/security/stream?scanId=${encodeURIComponent(scanId)}`, |
| 40 | + ); |
| 41 | + esRef.current = es; |
| 42 | + setBusy(true); |
| 43 | + |
| 44 | + es.addEventListener("status", (e) => { |
| 45 | + const d = JSON.parse((e as MessageEvent).data); |
| 46 | + setStatus(d.status as ScanStatus); |
| 47 | + }); |
| 48 | + es.addEventListener("done", (e) => { |
| 49 | + const d = JSON.parse((e as MessageEvent).data); |
| 50 | + setStatus(d.scan.status as ScanStatus); |
| 51 | + es.close(); |
| 52 | + setBusy(false); |
| 53 | + // Pull the freshly-persisted findings + history into the server render. |
| 54 | + router.refresh(); |
| 55 | + }); |
| 56 | + es.onerror = () => { |
| 57 | + es.close(); |
| 58 | + setBusy(false); |
| 59 | + }; |
| 60 | + }, |
| 61 | + [projectId, router], |
| 62 | + ); |
| 63 | + |
| 64 | + // Resume streaming if a scan is already in flight when the page loads. |
| 65 | + useEffect(() => { |
| 66 | + if (activeScanId) subscribe(activeScanId); |
| 67 | + return () => esRef.current?.close(); |
| 68 | + }, [activeScanId, subscribe]); |
11 | 69 |
|
12 | | - function run() { |
13 | | - startTransition(async () => { |
14 | | - setError(null); |
15 | | - const res = await requestPortScan(projectId); |
16 | | - if (!res.ok) { |
17 | | - setError(res.error ?? "Failed to queue scan."); |
18 | | - return; |
19 | | - } |
20 | | - router.refresh(); |
21 | | - }); |
| 70 | + async function run() { |
| 71 | + setError(null); |
| 72 | + setBusy(true); |
| 73 | + setStatus("queued"); |
| 74 | + const res = await requestPortScan(projectId); |
| 75 | + if (!res.ok || !res.scanId) { |
| 76 | + setError(res.error ?? "Failed to queue scan."); |
| 77 | + setStatus(null); |
| 78 | + setBusy(false); |
| 79 | + return; |
| 80 | + } |
| 81 | + subscribe(res.scanId); |
22 | 82 | } |
23 | 83 |
|
24 | 84 | return ( |
25 | | - <div className="flex flex-col items-start gap-1"> |
26 | | - <button |
27 | | - onClick={run} |
28 | | - disabled={pending} |
29 | | - className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)] disabled:opacity-60" |
30 | | - > |
31 | | - {pending ? "Queuing…" : "Run scan"} |
32 | | - </button> |
33 | | - {error && <p className="text-xs text-red-500">{error}</p>} |
| 85 | + <div className="flex flex-col items-end gap-1"> |
| 86 | + <div className="flex items-center gap-2"> |
| 87 | + {status && ( |
| 88 | + <span |
| 89 | + className="text-xs font-semibold" |
| 90 | + style={{ color: STATUS_COLOR[status] }} |
| 91 | + > |
| 92 | + {status === "running" && ( |
| 93 | + <span className="mr-1 inline-block animate-pulse">●</span> |
| 94 | + )} |
| 95 | + {STATUS_LABEL[status]} |
| 96 | + </span> |
| 97 | + )} |
| 98 | + <button |
| 99 | + onClick={run} |
| 100 | + disabled={busy} |
| 101 | + className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)] disabled:opacity-60" |
| 102 | + > |
| 103 | + {busy ? "Working…" : "Run scan"} |
| 104 | + </button> |
| 105 | + </div> |
| 106 | + {error && <p className="text-xs text-[var(--color-fail)]">{error}</p>} |
34 | 107 | </div> |
35 | 108 | ); |
36 | 109 | } |
0 commit comments