feat(inspector-ui): wire 'same context' filter end-to-end — chip onClick + applyFilter branch + clear-banner (R5-4)

This commit is contained in:
diegosouzapw
2026-05-28 22:07:45 -03:00
parent 5b39527e11
commit 7c16f75f99
4 changed files with 207 additions and 37 deletions

View File

@@ -9,6 +9,7 @@ interface RequestRowProps {
request: InterceptedRequest;
selected: boolean;
onClick: () => void;
onSameContext?: (contextKey: string) => void;
style?: React.CSSProperties;
}
@@ -39,7 +40,7 @@ function formatTime(iso: string): string {
}
}
export function RequestRow({ request, selected, onClick, style }: RequestRowProps) {
export function RequestRow({ request, selected, onClick, onSameContext, style }: RequestRowProps) {
const pathShort = request.path.length > 32 ? `${request.path.slice(-30)}` : request.path;
const sc = statusColor(request.status);
@@ -74,9 +75,17 @@ export function RequestRow({ request, selected, onClick, style }: RequestRowProp
<span className="text-text-main">{pathShort}</span>
</div>
{request.contextKey && (
<div className="text-[10px] text-text-muted font-mono opacity-60">
<button
type="button"
className="text-[10px] text-text-muted font-mono opacity-60 hover:opacity-100 hover:text-blue-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 rounded"
title="Filter by this context"
onClick={(e) => {
e.stopPropagation();
onSameContext?.(request.contextKey as string);
}}
>
ctx #{request.contextKey.slice(0, 6)}
</div>
</button>
)}
</div>
</div>

View File

@@ -10,6 +10,9 @@ interface RequestStreamingListProps {
selectedId: string | null;
onSelect: (req: InterceptedRequest) => void;
containerHeight: number;
onSameContext?: (contextKey: string) => void;
sameContextKey?: string;
onClearContextFilter?: () => void;
}
export function RequestStreamingList({
@@ -17,6 +20,9 @@ export function RequestStreamingList({
selectedId,
onSelect,
containerHeight,
onSameContext,
sameContextKey,
onClearContextFilter,
}: RequestStreamingListProps) {
const { virtualItems, totalHeight, containerRef, rowRef } = useVirtualList(
requests,
@@ -25,44 +31,73 @@ export function RequestStreamingList({
if (requests.length === 0) {
return (
<div
ref={containerRef}
className="h-full flex items-center justify-center text-sm text-text-muted"
>
<div className="text-center space-y-2">
<span
className="material-symbols-outlined text-[36px] text-text-muted block"
aria-hidden="true"
>
network_check
</span>
<p>No requests captured yet.</p>
<p className="text-xs">Make sure AgentBridge is running or enable another capture mode.</p>
<div className="h-full flex flex-col">
{sameContextKey && (
<div className="shrink-0 flex items-center gap-2 px-2 py-1 bg-blue-900/30 border-b border-blue-500/40 text-xs text-blue-300 font-mono">
<span>Filtering: context {sameContextKey.slice(0, 6)}</span>
<button
type="button"
onClick={onClearContextFilter}
className="ml-1 underline hover:text-blue-100 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-400"
>
[clear]
</button>
</div>
)}
<div
ref={containerRef}
className="flex-1 flex items-center justify-center text-sm text-text-muted"
>
<div className="text-center space-y-2">
<span
className="material-symbols-outlined text-[36px] text-text-muted block"
aria-hidden="true"
>
network_check
</span>
<p>No requests captured yet.</p>
<p className="text-xs">Make sure AgentBridge is running or enable another capture mode.</p>
</div>
</div>
</div>
);
}
return (
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
className="h-full overflow-y-auto relative"
style={{ contain: "strict" }}
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(({ index, item, top }) => (
<div
key={item.id}
ref={rowRef(index)}
style={{ position: "absolute", top, left: 0, right: 0 }}
<div className="h-full flex flex-col">
{sameContextKey && (
<div className="shrink-0 flex items-center gap-2 px-2 py-1 bg-blue-900/30 border-b border-blue-500/40 text-xs text-blue-300 font-mono">
<span>Filtering: context {sameContextKey.slice(0, 6)}</span>
<button
type="button"
onClick={onClearContextFilter}
className="ml-1 underline hover:text-blue-100 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-400"
>
<RequestRow
request={item}
selected={item.id === selectedId}
onClick={() => onSelect(item)}
/>
</div>
))}
[clear]
</button>
</div>
)}
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
className="flex-1 overflow-y-auto relative"
style={{ contain: "strict" }}
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(({ index, item, top }) => (
<div
key={item.id}
ref={rowRef(index)}
style={{ position: "absolute", top, left: 0, right: 0 }}
>
<RequestRow
request={item}
selected={item.id === selectedId}
onClick={() => onSelect(item)}
onSameContext={onSameContext}
/>
</div>
))}
</div>
</div>
</div>
);

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { InterceptedRequest, ListFilters, WsEvent } from "@/mitm/inspector/types";
import type { FiltersState } from "./useTrafficFilters";
const WS_PATH = "/api/tools/traffic-inspector/ws";
const INITIAL_BACKOFF_MS = 500;
@@ -13,6 +14,7 @@ export interface TrafficStreamState {
connected: boolean;
paused: boolean;
total: number;
pendingCount: number;
}
export interface TrafficStreamActions {
@@ -22,11 +24,12 @@ export interface TrafficStreamActions {
}
export function useTrafficStream(
filters: ListFilters
filters: FiltersState | ListFilters
): [TrafficStreamState, TrafficStreamActions] {
const [requests, setRequests] = useState<InterceptedRequest[]>([]);
const [connected, setConnected] = useState(false);
const [paused, setPaused] = useState(false);
const [pendingCount, setPendingCount] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const backoffRef = useRef(INITIAL_BACKOFF_MS);
@@ -44,13 +47,14 @@ export function useTrafficStream(
});
const applyFilter = useCallback((req: InterceptedRequest): boolean => {
const f = filtersRef.current;
const f = filtersRef.current as FiltersState;
if (f.profile === "llm" && req.detectedKind !== "llm") return false;
if (f.profile === "custom" && req.source !== "custom-host") return false;
if (f.host && !req.host.includes(f.host)) return false;
if (f.agent && req.agent !== f.agent) return false;
if (f.source && req.source !== f.source) return false;
if (f.sessionId && req.sessionId !== f.sessionId) return false;
if (f.sameContextKey && req.contextKey !== f.sameContextKey) return false;
if (f.status) {
const s = req.status;
if (typeof s === "number") {
@@ -93,7 +97,10 @@ export function useTrafficStream(
}
if (pausedRef.current) {
if (event.type === "new") pendingRef.current.push(event.data);
if (event.type === "new") {
pendingRef.current.push(event.data);
setPendingCount(pendingRef.current.length);
}
if (event.type === "update") {
const idx = pendingRef.current.findIndex((r) => r.id === event.data.id);
if (idx !== -1) pendingRef.current[idx] = event.data;
@@ -157,6 +164,7 @@ export function useTrafficStream(
if (pendingRef.current.length > 0) {
const pending = pendingRef.current.filter(applyFilter);
pendingRef.current = [];
setPendingCount(0);
setRequests((prev) => [...pending, ...prev].slice(0, 1000));
}
}, [applyFilter]);
@@ -164,6 +172,7 @@ export function useTrafficStream(
const clear = useCallback(() => {
setRequests([]);
pendingRef.current = [];
setPendingCount(0);
}, []);
const state: TrafficStreamState = {
@@ -171,6 +180,7 @@ export function useTrafficStream(
connected,
paused,
total: requests.length,
pendingCount,
};
return [state, { pause, resume, clear }];

View File

@@ -0,0 +1,116 @@
/**
* Tests for R5-4: same-context filter wired end-to-end
*
* Source-grep assertions that:
* - useTrafficStream.applyFilter branches on sameContextKey
* - RequestRow exports an onSameContext prop
* - useTrafficFilters.setSameContext is referenced from TrafficInspectorPageClient
*/
import { describe, it } 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 ROOT = path.resolve(
__dirname,
"../../../src/app/(dashboard)/dashboard/tools/traffic-inspector"
);
function read(rel: string): string {
return fs.readFileSync(path.join(ROOT, rel), "utf8");
}
describe("R5-4 same-context filter end-to-end", () => {
it("useTrafficStream.applyFilter has sameContextKey branch", () => {
const src = read("hooks/useTrafficStream.ts");
assert.ok(
src.includes("sameContextKey") && src.includes("contextKey"),
"applyFilter should branch on sameContextKey / contextKey"
);
// Must actually exclude requests where contextKey differs
assert.ok(
src.includes("req.contextKey !== f.sameContextKey"),
"should exclude when contextKey !== sameContextKey"
);
});
it("RequestRow accepts onSameContext prop in interface", () => {
const src = read("components/RequestRow.tsx");
assert.ok(
src.includes("onSameContext"),
"RequestRow interface should declare onSameContext prop"
);
assert.ok(
src.includes("onSameContext?.("),
"RequestRow should call onSameContext on click"
);
});
it("RequestRow ctx chip is a button element", () => {
const src = read("components/RequestRow.tsx");
// The ctx chip should now be a button for keyboard/mouse click
const hasButton = src.includes("<button") && src.includes("onSameContext");
assert.ok(hasButton, "ctx chip should be a <button> that calls onSameContext");
});
it("TrafficInspectorPageClient references setSameContext", () => {
const src = read("TrafficInspectorPageClient.tsx");
assert.ok(
src.includes("setSameContext"),
"TrafficInspectorPageClient should destructure setSameContext from useTrafficFilters"
);
});
it("RequestStreamingList passes onSameContext to RequestRow", () => {
const src = read("components/RequestStreamingList.tsx");
assert.ok(
src.includes("onSameContext"),
"RequestStreamingList should accept and forward onSameContext prop"
);
});
it("RequestStreamingList shows sameContextKey banner when active", () => {
const src = read("components/RequestStreamingList.tsx");
assert.ok(
src.includes("sameContextKey") && src.includes("onClearContextFilter"),
"RequestStreamingList should show a clear-filter banner when sameContextKey is set"
);
});
it("TrafficInspectorPageClient passes sameContextKey and onClearContextFilter to list", () => {
const src = read("TrafficInspectorPageClient.tsx");
assert.ok(
src.includes("sameContextKey={filters.sameContextKey}"),
"should pass sameContextKey to RequestStreamingList"
);
assert.ok(
src.includes("onClearContextFilter"),
"should pass onClearContextFilter to RequestStreamingList"
);
});
it("useTrafficFilters exports setSameContext", () => {
const src = read("hooks/useTrafficFilters.ts");
assert.ok(
src.includes("setSameContext"),
"useTrafficFilters should export setSameContext"
);
});
describe("applyFilter sameContextKey logic (unit)", () => {
it("returns false when contextKey does not match filter", () => {
type Req = { contextKey?: string; detectedKind: string; source: string; host: string; agent?: string; sessionId?: string; status: number };
const applyFilter = (req: Req, sameContextKey?: string): boolean => {
if (sameContextKey && req.contextKey !== sameContextKey) return false;
return true;
};
assert.equal(applyFilter({ contextKey: "abc123", detectedKind: "llm", source: "agent", host: "api.openai.com", status: 200 }, "abc123"), true);
assert.equal(applyFilter({ contextKey: "xyz456", detectedKind: "llm", source: "agent", host: "api.openai.com", status: 200 }, "abc123"), false);
assert.equal(applyFilter({ contextKey: undefined, detectedKind: "llm", source: "agent", host: "api.openai.com", status: 200 }, "abc123"), false);
assert.equal(applyFilter({ contextKey: "abc123", detectedKind: "llm", source: "agent", host: "api.openai.com", status: 200 }, undefined), true);
});
});
});