mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
feat(inspector-ui): wire 'same context' filter end-to-end — chip onClick + applyFilter branch + clear-banner (R5-4)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
116
tests/unit/ui/same-context-filter.test.tsx
Normal file
116
tests/unit/ui/same-context-filter.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user