feat(inspector-ui): post snapshots to session requests endpoint while recording (R5-5 frontend half)

This commit is contained in:
diegosouzapw
2026-05-28 22:07:56 -03:00
parent 39dcfd7307
commit 4c48730e43
2 changed files with 255 additions and 20 deletions

View File

@@ -1,6 +1,11 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { WsEvent } from "@/mitm/inspector/types";
const WS_PATH = "/api/tools/traffic-inspector/ws";
const SNAPSHOT_FLUSH_MS = 500;
const SNAPSHOT_FLUSH_BATCH = 10;
export interface SessionInfo {
id: string;
@@ -24,6 +29,10 @@ export function useSessionRecorder() {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
const mountedRef = useRef(true);
const recordingWsRef = useRef<WebSocket | null>(null);
const recordingSessionRef = useRef<SessionInfo | null>(null);
const pendingSnapshotsRef = useRef<string[]>([]);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
mountedRef.current = true;
@@ -54,27 +63,101 @@ export function useSessionRecorder() {
};
}, []);
const start = useCallback(async (name?: string) => {
try {
const res = await fetch("/api/tools/traffic-inspector/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) return;
const data = (await res.json()) as { session: SessionInfo };
setSession(data.session);
setRecording(true);
startTimeRef.current = Date.now();
setElapsed(0);
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
} catch {
// ignore
const flushSnapshots = useCallback(async (sessionId: string) => {
if (pendingSnapshotsRef.current.length === 0) return;
const batch = pendingSnapshotsRef.current.splice(0, pendingSnapshotsRef.current.length);
for (const payload of batch) {
try {
await fetch(
`/api/tools/traffic-inspector/sessions/${encodeURIComponent(sessionId)}/requests`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload }),
}
);
} catch {
// best-effort: don't break recording UI on network failure
}
}
}, []);
const scheduleFlush = useCallback(
(sessionId: string) => {
if (flushTimerRef.current) return;
flushTimerRef.current = setTimeout(() => {
flushTimerRef.current = null;
void flushSnapshots(sessionId);
}, SNAPSHOT_FLUSH_MS);
},
[flushSnapshots]
);
const stopRecordingWs = useCallback(() => {
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
if (recordingWsRef.current) {
recordingWsRef.current.onclose = null;
recordingWsRef.current.close();
recordingWsRef.current = null;
}
}, []);
const start = useCallback(
async (name?: string) => {
try {
const res = await fetch("/api/tools/traffic-inspector/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) return;
const data = (await res.json()) as { session: SessionInfo };
const newSession = data.session;
setSession(newSession);
recordingSessionRef.current = newSession;
setRecording(true);
startTimeRef.current = Date.now();
setElapsed(0);
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
// Open a dedicated WS to capture traffic events during the recording window
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${proto}//${window.location.host}${WS_PATH}`;
const ws = new WebSocket(wsUrl);
recordingWsRef.current = ws;
ws.onmessage = (ev: MessageEvent) => {
if (!mountedRef.current) return;
let event: WsEvent;
try {
event = JSON.parse(ev.data as string) as WsEvent;
} catch {
return;
}
if (event.type !== "new") return;
const sid = recordingSessionRef.current?.id;
if (!sid) return;
pendingSnapshotsRef.current.push(JSON.stringify(event.data));
if (pendingSnapshotsRef.current.length >= SNAPSHOT_FLUSH_BATCH) {
void flushSnapshots(sid);
} else {
scheduleFlush(sid);
}
};
ws.onerror = () => ws.close();
} catch {
// ignore
}
},
[flushSnapshots, scheduleFlush]
);
const stop = useCallback(async () => {
if (!session) return;
if (timerRef.current) {
@@ -82,8 +165,15 @@ export function useSessionRecorder() {
timerRef.current = null;
}
setRecording(false);
// Flush any remaining pending snapshots before stopping
const sid = session.id;
stopRecordingWs();
if (pendingSnapshotsRef.current.length > 0) {
await flushSnapshots(sid);
}
recordingSessionRef.current = null;
try {
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(session.id)}`, {
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(sid)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "stop" }),
@@ -93,7 +183,7 @@ export function useSessionRecorder() {
}
await fetchSessions();
setSession(null);
}, [session, fetchSessions]);
}, [session, fetchSessions, stopRecordingWs, flushSnapshots]);
const deleteSession = useCallback(async (id: string) => {
try {
@@ -109,6 +199,11 @@ export function useSessionRecorder() {
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (flushTimerRef.current) clearTimeout(flushTimerRef.current);
if (recordingWsRef.current) {
recordingWsRef.current.onclose = null;
recordingWsRef.current.close();
}
};
}, []);

View File

@@ -0,0 +1,140 @@
/**
* Tests for useSessionRecorder (R5-5 frontend half)
*
* Verifies that during recording, new traffic WS events trigger
* POST to /api/tools/traffic-inspector/sessions/{id}/requests.
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const HOOK_SRC = fs.readFileSync(
path.resolve(
__dirname,
"../../../src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSessionRecorder.ts"
),
"utf8"
);
describe("useSessionRecorder R5-5 source assertions", () => {
it("opens a WebSocket during start() for traffic capture", () => {
assert.ok(
HOOK_SRC.includes("new WebSocket(wsUrl)"),
"should open a WebSocket connection inside start()"
);
});
it("POSTs to /sessions/{id}/requests on new WS event", () => {
assert.ok(
HOOK_SRC.includes("/requests"),
"should POST to sessions/{id}/requests endpoint"
);
assert.ok(
HOOK_SRC.includes(`method: "POST"`),
"should use POST method"
);
assert.ok(
HOOK_SRC.includes("payload"),
"should send payload in body"
);
});
it("buffers events and flushes in batches", () => {
assert.ok(
HOOK_SRC.includes("SNAPSHOT_FLUSH_BATCH"),
"should define SNAPSHOT_FLUSH_BATCH constant"
);
assert.ok(
HOOK_SRC.includes("SNAPSHOT_FLUSH_MS"),
"should define SNAPSHOT_FLUSH_MS constant for debounce"
);
assert.ok(
HOOK_SRC.includes("pendingSnapshotsRef"),
"should use a pendingSnapshotsRef buffer"
);
});
it("stops WS and flushes on stop()", () => {
assert.ok(
HOOK_SRC.includes("stopRecordingWs()"),
"stop() should call stopRecordingWs to clean up the WS"
);
assert.ok(
HOOK_SRC.includes("await flushSnapshots(sid)"),
"stop() should await a final flush before sending PATCH"
);
});
it("handles POST failure gracefully (does not throw)", () => {
// The fetch call must be wrapped in try/catch
const fetchBlock = HOOK_SRC.slice(HOOK_SRC.indexOf("flushSnapshots"));
assert.ok(
fetchBlock.includes("} catch {"),
"POST fetch should be wrapped in try/catch to handle failures gracefully"
);
});
it("only pushes 'new' event type to snapshots", () => {
assert.ok(
HOOK_SRC.includes(`event.type !== "new"`),
"should early-return for non-new events"
);
});
});
describe("useSessionRecorder snapshot flush logic (unit)", () => {
it("batch threshold triggers immediate flush instead of timer", () => {
// Simulate the flush decision logic
const SNAPSHOT_FLUSH_BATCH = 10;
const pendingSnapshots: string[] = [];
let immediateFlushCalled = false;
let scheduleFlushCalled = false;
const flushSnapshots = () => { immediateFlushCalled = true; };
const scheduleFlush = () => { scheduleFlushCalled = true; };
// Below threshold
pendingSnapshots.push(JSON.stringify({ id: "req-1" }));
if (pendingSnapshots.length >= SNAPSHOT_FLUSH_BATCH) {
flushSnapshots();
} else {
scheduleFlush();
}
assert.equal(immediateFlushCalled, false);
assert.equal(scheduleFlushCalled, true);
// At threshold
immediateFlushCalled = false;
scheduleFlushCalled = false;
for (let i = 0; i < SNAPSHOT_FLUSH_BATCH - 1; i++) {
pendingSnapshots.push(JSON.stringify({ id: `req-${i + 2}` }));
}
assert.equal(pendingSnapshots.length, SNAPSHOT_FLUSH_BATCH);
if (pendingSnapshots.length >= SNAPSHOT_FLUSH_BATCH) {
flushSnapshots();
} else {
scheduleFlush();
}
assert.equal(immediateFlushCalled, true);
assert.equal(scheduleFlushCalled, false);
});
it("POST URL is correct format", () => {
const sessionId = "test-session-123";
const url = `/api/tools/traffic-inspector/sessions/${encodeURIComponent(sessionId)}/requests`;
assert.equal(url, "/api/tools/traffic-inspector/sessions/test-session-123/requests");
});
it("POST body contains stringified payload", () => {
const req = { id: "req-1", host: "api.openai.com", method: "POST" };
const payload = JSON.stringify(req);
const body = JSON.stringify({ payload });
const parsed = JSON.parse(body) as { payload: string };
assert.equal(parsed.payload, payload);
const reparsed = JSON.parse(parsed.payload) as typeof req;
assert.equal(reparsed.id, "req-1");
});
});