Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion app/(app)/projects/[id]/security/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export default async function ProjectSecurityPage({
.order("created_at", { ascending: false })
.limit(10);
const scans = (scansData ?? []) as ScanRow[];
// If the most recent scan is still in flight, hand its id to the client so
// the SSE stream resumes on load.
const activeScanId =
scans[0] && (scans[0].status === "queued" || scans[0].status === "running")
? scans[0].id
: null;

return (
<div className="flex flex-col gap-6">
Expand All @@ -90,7 +96,7 @@ export default async function ProjectSecurityPage({
Scans run only against your own verified host.
</p>
</div>
<RequestScanButton projectId={id} />
<RequestScanButton projectId={id} activeScanId={activeScanId} />
</div>

{/* Open findings */}
Expand Down
117 changes: 95 additions & 22 deletions app/(app)/projects/[id]/security/request-scan-button.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,109 @@
"use client";

import { useState, useTransition } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { requestPortScan } from "@/app/actions/portScans";

export function RequestScanButton({ projectId }: { projectId: string }) {
type ScanStatus = "queued" | "running" | "done" | "failed";

const STATUS_LABEL: Record<ScanStatus, string> = {
queued: "Queued…",
running: "Scanning…",
done: "Done",
failed: "Failed",
};
const STATUS_COLOR: Record<ScanStatus, string> = {
queued: "var(--color-muted)",
running: "var(--color-warn)",
done: "var(--color-pass)",
failed: "var(--color-fail)",
};

export function RequestScanButton({
projectId,
activeScanId = null,
}: {
projectId: string;
activeScanId?: string | null;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [status, setStatus] = useState<ScanStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const esRef = useRef<EventSource | null>(null);

const subscribe = useCallback(
(scanId: string) => {
esRef.current?.close();
const es = new EventSource(
`/projects/${projectId}/security/stream?scanId=${encodeURIComponent(scanId)}`,
);
esRef.current = es;
setBusy(true);

es.addEventListener("status", (e) => {
const d = JSON.parse((e as MessageEvent).data);
setStatus(d.status as ScanStatus);
});
es.addEventListener("done", (e) => {
const d = JSON.parse((e as MessageEvent).data);
setStatus(d.scan.status as ScanStatus);
es.close();
setBusy(false);
// Pull the freshly-persisted findings + history into the server render.
router.refresh();
});
es.onerror = () => {
es.close();
setBusy(false);
};
},
[projectId, router],
);

// Resume streaming if a scan is already in flight when the page loads.
useEffect(() => {
if (activeScanId) subscribe(activeScanId);
return () => esRef.current?.close();
}, [activeScanId, subscribe]);

function run() {
startTransition(async () => {
setError(null);
const res = await requestPortScan(projectId);
if (!res.ok) {
setError(res.error ?? "Failed to queue scan.");
return;
}
router.refresh();
});
async function run() {
setError(null);
setBusy(true);
setStatus("queued");
const res = await requestPortScan(projectId);
if (!res.ok || !res.scanId) {
setError(res.error ?? "Failed to queue scan.");
setStatus(null);
setBusy(false);
return;
}
subscribe(res.scanId);
}

return (
<div className="flex flex-col items-start gap-1">
<button
onClick={run}
disabled={pending}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)] disabled:opacity-60"
>
{pending ? "Queuing…" : "Run scan"}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
{status && (
<span
className="text-xs font-semibold"
style={{ color: STATUS_COLOR[status] }}
>
{status === "running" && (
<span className="mr-1 inline-block animate-pulse">●</span>
)}
{STATUS_LABEL[status]}
</span>
)}
<button
onClick={run}
disabled={busy}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--color-accent-fg)] disabled:opacity-60"
>
{busy ? "Working…" : "Run scan"}
</button>
</div>
{error && <p className="text-xs text-[var(--color-fail)]">{error}</p>}
</div>
);
}
92 changes: 92 additions & 0 deletions app/(app)/projects/[id]/security/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// SSE stream for live port-scan feedback (uptime-monitoring-prd.md §12.4).
// The browser opens an EventSource here after requesting a scan; we push each
// status change (queued → running → done/failed) and, on completion, the open
// findings — then close. Server-side we poll the DB (which the worker drives
// from the BullMQ result), so the web tier needs no Redis connection.
import { NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";

export const dynamic = "force-dynamic";

const POLL_MS = 1500;
const MAX_MS = 5 * 60 * 1000;

export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const scanId = req.nextUrl.searchParams.get("scanId");

const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return new Response("unauthorized", { status: 401 });

const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
let closed = false;
const close = () => {
if (closed) return;
closed = true;
try {
controller.close();
} catch {
/* already closed */
}
};
req.signal.addEventListener("abort", close);

const send = (event: string, data: unknown) => {
if (closed) return;
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
};

const deadline = Date.now() + MAX_MS;
let lastStatus = "";
try {
while (!closed && Date.now() < deadline) {
const base = supabase
.from("port_scans")
.select("id, status, open_ports, completed_at")
.eq("project_id", id);
const { data: scan } = scanId
? await base.eq("id", scanId).maybeSingle()
: await base.order("created_at", { ascending: false }).limit(1).maybeSingle();

if (scan) {
if (scan.status !== lastStatus) {
lastStatus = scan.status;
send("status", scan);
}
if (scan.status === "done" || scan.status === "failed") {
const { data: findings } = await supabase
.from("port_findings")
.select("id, port, service, severity, state")
.eq("project_id", id)
.eq("state", "open")
.order("severity", { ascending: false });
send("done", { scan, findings: findings ?? [] });
break;
}
}
await new Promise((r) => setTimeout(r, POLL_MS));
}
} finally {
close();
}
},
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
20 changes: 12 additions & 8 deletions app/actions/portScans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createClient } from "@/lib/supabase/server";
// only record the request — RLS ensures the caller owns the project.
export async function requestPortScan(
projectId: string,
): Promise<{ ok: boolean; error?: string }> {
): Promise<{ ok: boolean; error?: string; scanId?: string }> {
const supabase = await createClient();
const {
data: { user },
Expand All @@ -29,17 +29,21 @@ export async function requestPortScan(
return { ok: false, error: "Project URL is not a valid host." };
}

const { error } = await supabase.from("port_scans").insert({
project_id: projectId,
host,
status: "queued",
requested_by: user.id,
});
const { data: inserted, error } = await supabase
.from("port_scans")
.insert({
project_id: projectId,
host,
status: "queued",
requested_by: user.id,
})
.select("id")
.single();
if (error) {
// Most likely the migration hasn't been applied yet in this environment.
return { ok: false, error: `Could not queue scan: ${error.message}` };
}

revalidatePath(`/projects/${projectId}/security`);
return { ok: true };
return { ok: true, scanId: inserted.id };
}
Loading
Loading