mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
feat(inspector-ui): post snapshots to session requests endpoint while recording (R5-5 frontend half)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
140
tests/unit/ui/use-session-recorder.test.tsx
Normal file
140
tests/unit/ui/use-session-recorder.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user