Skip to content

Commit 85494ad

Browse files
authored
feat(security): wire port-scan queue + SSE live feedback (#82)
1 parent 119a824 commit 85494ad

8 files changed

Lines changed: 719 additions & 35 deletions

File tree

‎app/(app)/projects/[id]/security/page.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ export default async function ProjectSecurityPage({
7777
.order("created_at", { ascending: false })
7878
.limit(10);
7979
const scans = (scansData ?? []) as ScanRow[];
80+
// If the most recent scan is still in flight, hand its id to the client so
81+
// the SSE stream resumes on load.
82+
const activeScanId =
83+
scans[0] && (scans[0].status === "queued" || scans[0].status === "running")
84+
? scans[0].id
85+
: null;
8086

8187
return (
8288
<div className="flex flex-col gap-6">
@@ -90,7 +96,7 @@ export default async function ProjectSecurityPage({
9096
Scans run only against your own verified host.
9197
</p>
9298
</div>
93-
<RequestScanButton projectId={id} />
99+
<RequestScanButton projectId={id} activeScanId={activeScanId} />
94100
</div>
95101

96102
{/* Open findings */}
Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,109 @@
11
"use client";
22

3-
import { useState, useTransition } from "react";
3+
import { useCallback, useEffect, useRef, useState } from "react";
44
import { useRouter } from "next/navigation";
55
import { requestPortScan } from "@/app/actions/portScans";
66

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+
}) {
829
const router = useRouter();
9-
const [pending, startTransition] = useTransition();
30+
const [status, setStatus] = useState<ScanStatus | null>(null);
31+
const [busy, setBusy] = useState(false);
1032
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]);
1169

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);
2282
}
2383

2484
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>}
34107
</div>
35108
);
36109
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// SSE stream for live port-scan feedback (uptime-monitoring-prd.md §12.4).
2+
// The browser opens an EventSource here after requesting a scan; we push each
3+
// status change (queued → running → done/failed) and, on completion, the open
4+
// findings — then close. Server-side we poll the DB (which the worker drives
5+
// from the BullMQ result), so the web tier needs no Redis connection.
6+
import { NextRequest } from "next/server";
7+
import { createClient } from "@/lib/supabase/server";
8+
9+
export const dynamic = "force-dynamic";
10+
11+
const POLL_MS = 1500;
12+
const MAX_MS = 5 * 60 * 1000;
13+
14+
export async function GET(
15+
req: NextRequest,
16+
{ params }: { params: Promise<{ id: string }> },
17+
) {
18+
const { id } = await params;
19+
const scanId = req.nextUrl.searchParams.get("scanId");
20+
21+
const supabase = await createClient();
22+
const {
23+
data: { user },
24+
} = await supabase.auth.getUser();
25+
if (!user) return new Response("unauthorized", { status: 401 });
26+
27+
const encoder = new TextEncoder();
28+
const stream = new ReadableStream({
29+
async start(controller) {
30+
let closed = false;
31+
const close = () => {
32+
if (closed) return;
33+
closed = true;
34+
try {
35+
controller.close();
36+
} catch {
37+
/* already closed */
38+
}
39+
};
40+
req.signal.addEventListener("abort", close);
41+
42+
const send = (event: string, data: unknown) => {
43+
if (closed) return;
44+
controller.enqueue(
45+
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
46+
);
47+
};
48+
49+
const deadline = Date.now() + MAX_MS;
50+
let lastStatus = "";
51+
try {
52+
while (!closed && Date.now() < deadline) {
53+
const base = supabase
54+
.from("port_scans")
55+
.select("id, status, open_ports, completed_at")
56+
.eq("project_id", id);
57+
const { data: scan } = scanId
58+
? await base.eq("id", scanId).maybeSingle()
59+
: await base.order("created_at", { ascending: false }).limit(1).maybeSingle();
60+
61+
if (scan) {
62+
if (scan.status !== lastStatus) {
63+
lastStatus = scan.status;
64+
send("status", scan);
65+
}
66+
if (scan.status === "done" || scan.status === "failed") {
67+
const { data: findings } = await supabase
68+
.from("port_findings")
69+
.select("id, port, service, severity, state")
70+
.eq("project_id", id)
71+
.eq("state", "open")
72+
.order("severity", { ascending: false });
73+
send("done", { scan, findings: findings ?? [] });
74+
break;
75+
}
76+
}
77+
await new Promise((r) => setTimeout(r, POLL_MS));
78+
}
79+
} finally {
80+
close();
81+
}
82+
},
83+
});
84+
85+
return new Response(stream, {
86+
headers: {
87+
"Content-Type": "text/event-stream",
88+
"Cache-Control": "no-cache, no-transform",
89+
Connection: "keep-alive",
90+
},
91+
});
92+
}

‎app/actions/portScans.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createClient } from "@/lib/supabase/server";
88
// only record the request — RLS ensures the caller owns the project.
99
export async function requestPortScan(
1010
projectId: string,
11-
): Promise<{ ok: boolean; error?: string }> {
11+
): Promise<{ ok: boolean; error?: string; scanId?: string }> {
1212
const supabase = await createClient();
1313
const {
1414
data: { user },
@@ -29,17 +29,21 @@ export async function requestPortScan(
2929
return { ok: false, error: "Project URL is not a valid host." };
3030
}
3131

32-
const { error } = await supabase.from("port_scans").insert({
33-
project_id: projectId,
34-
host,
35-
status: "queued",
36-
requested_by: user.id,
37-
});
32+
const { data: inserted, error } = await supabase
33+
.from("port_scans")
34+
.insert({
35+
project_id: projectId,
36+
host,
37+
status: "queued",
38+
requested_by: user.id,
39+
})
40+
.select("id")
41+
.single();
3842
if (error) {
3943
// Most likely the migration hasn't been applied yet in this environment.
4044
return { ok: false, error: `Could not queue scan: ${error.message}` };
4145
}
4246

4347
revalidatePath(`/projects/${projectId}/security`);
44-
return { ok: true };
48+
return { ok: true, scanId: inserted.id };
4549
}

0 commit comments

Comments
 (0)