feat(ui): traffic-inspector shared components (waterfall, json, context bar, etc) (F8)

This commit is contained in:
diegosouzapw
2026-05-28 07:25:15 -03:00
parent 389b035bee
commit 4a802e84bd
9 changed files with 359 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
"use client";
import type { AgentId } from "@/mitm/types";
const AGENT_COLORS: Record<AgentId, { emoji: string; label: string; color: string }> = {
antigravity: { emoji: "🔵", label: "AG", color: "text-blue-400" },
kiro: { emoji: "🟠", label: "KR", color: "text-orange-400" },
copilot: { emoji: "🟢", label: "CP", color: "text-green-400" },
codex: { emoji: "🟣", label: "CD", color: "text-purple-400" },
cursor: { emoji: "🔶", label: "CU", color: "text-yellow-400" },
zed: { emoji: "🔷", label: "ZD", color: "text-sky-400" },
"claude-code": { emoji: "🟡", label: "CC", color: "text-yellow-300" },
"open-code": { emoji: "⚪", label: "OC", color: "text-gray-400" },
trae: { emoji: "⬛", label: "TR", color: "text-gray-500" },
};
interface AgentEmojiProps {
agentId?: AgentId | string;
className?: string;
}
export function AgentEmoji({ agentId, className }: AgentEmojiProps) {
if (!agentId) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
const info = AGENT_COLORS[agentId as AgentId];
if (!info) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
return (
<span
className={`inline-flex items-center gap-0.5 text-xs font-mono ${info.color} ${className ?? ""}`}
title={agentId}
>
{info.emoji} {info.label}
</span>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useCallback, useState } from "react";
import { useAnnotations } from "../../hooks/useAnnotations";
interface AnnotationFieldProps {
requestId: string | null;
initialValue?: string;
}
export function AnnotationField({ requestId, initialValue = "" }: AnnotationFieldProps) {
const [value, setValue] = useState(initialValue);
const { save, saving } = useAnnotations(requestId);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
save(e.target.value);
},
[save]
);
return (
<div className="relative">
<textarea
value={value}
onChange={handleChange}
placeholder="Add a note…"
rows={3}
maxLength={10_000}
className="w-full rounded border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main resize-none focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{saving && (
<span className="absolute right-2 bottom-2 text-xs text-text-muted animate-pulse">
Saving
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
interface ContextColorBarProps {
contextKey?: string;
className?: string;
}
function hashToHue(key: string): number {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffff;
}
return (hash * 137.5) % 360;
}
export function ContextColorBar({ contextKey, className }: ContextColorBarProps) {
const hue = contextKey ? hashToHue(contextKey) : 0;
const color = contextKey ? `hsl(${hue}, 70%, 50%)` : "transparent";
return (
<div
className={className}
style={{ width: 3, minWidth: 3, backgroundColor: color, borderRadius: 2 }}
title={contextKey ? `ctx #${contextKey.slice(0, 6)}` : undefined}
/>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
interface HeaderTableProps {
headers: Record<string, string>;
}
export function HeaderTable({ headers }: HeaderTableProps) {
const [masked, setMasked] = useState(true);
const SENSITIVE = /authorization|cookie|x-api-key|bearer/i;
return (
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-text-muted">Sensitive headers</span>
<button
type="button"
onClick={() => setMasked((m) => !m)}
className="text-xs text-blue-400 hover:text-blue-300 focus-ring rounded"
>
{masked ? "Show" : "Hide"}
</button>
</div>
<table className="w-full text-xs font-mono border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left px-2 py-1 text-text-muted font-medium">Name</th>
<th className="text-left px-2 py-1 text-text-muted font-medium">Value</th>
</tr>
</thead>
<tbody>
{Object.entries(headers).map(([name, value]) => {
const isSensitive = SENSITIVE.test(name);
const display = masked && isSensitive ? "••••••••" : value;
return (
<tr key={name} className="border-b border-border/50 hover:bg-bg-subtle">
<td className="px-2 py-1 text-text-muted select-text">{name}</td>
<td
className={`px-2 py-1 break-all select-text ${isSensitive ? "text-amber-400" : "text-text-main"}`}
>
{display}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { cn } from "@/shared/utils/cn";
interface JsonViewerProps {
data: unknown;
depth?: number;
className?: string;
}
function JsonNode({ data, depth = 0 }: { data: unknown; depth?: number }) {
const [expanded, setExpanded] = useState(depth < 2);
if (data === null) return <span className="text-text-muted">null</span>;
if (typeof data === "boolean") return <span className="text-amber-400">{String(data)}</span>;
if (typeof data === "number") return <span className="text-blue-400">{String(data)}</span>;
if (typeof data === "string") return <span className="text-green-400">&quot;{data}&quot;</span>;
if (Array.isArray(data)) {
if (data.length === 0) return <span className="text-text-muted">[]</span>;
return (
<span>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
>
{expanded ? "▼" : "▶"} [{data.length}]
</button>
{expanded && (
<div className="ml-4 border-l border-border pl-2">
{data.map((item, i) => (
<div key={i} className="flex gap-1 text-xs font-mono">
<span className="text-text-muted">{i}:</span>
<JsonNode data={item} depth={depth + 1} />
{i < data.length - 1 && <span className="text-text-muted">,</span>}
</div>
))}
</div>
)}
</span>
);
}
if (typeof data === "object" && data !== null) {
const entries = Object.entries(data as Record<string, unknown>);
if (entries.length === 0) return <span className="text-text-muted">{"{}"}</span>;
return (
<span>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
>
{expanded ? "▼" : "▶"} {"{"}
{entries.length}
{"}"}
</button>
{expanded && (
<div className="ml-4 border-l border-border pl-2">
{entries.map(([k, v], i) => (
<div key={k} className="flex gap-1 text-xs font-mono">
<span className="text-text-main">&quot;{k}&quot;</span>
<span className="text-text-muted">:</span>
<JsonNode data={v} depth={depth + 1} />
{i < entries.length - 1 && <span className="text-text-muted">,</span>}
</div>
))}
</div>
)}
</span>
);
}
return <span className="text-text-main font-mono text-xs">{String(data)}</span>;
}
export function JsonViewer({ data, className }: JsonViewerProps) {
return (
<div className={cn("overflow-auto font-mono text-xs", className)}>
<JsonNode data={data} depth={0} />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
interface SecretMaskToggleProps {
masked: boolean;
onToggle: () => void;
}
export function SecretMaskToggle({ masked, onToggle }: SecretMaskToggleProps) {
return (
<button
type="button"
onClick={onToggle}
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text-main focus-ring rounded px-2 py-0.5 border border-border"
title={masked ? "Unmask secrets" : "Mask secrets"}
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{masked ? "visibility_off" : "visibility"}
</span>
{masked ? "Show secrets" : "Mask secrets"}
</button>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import type { SseEvent } from "@/mitm/inspector/sseMerger";
interface SseEventListProps {
events: SseEvent[];
}
export function SseEventList({ events }: SseEventListProps) {
return (
<div className="flex flex-col gap-1 font-mono text-xs overflow-auto max-h-full">
{events.map((ev, i) => (
<div key={i} className="flex gap-2 border-b border-border/30 pb-1">
<span className="text-text-muted shrink-0 w-8 text-right">{i + 1}</span>
<span className="text-amber-400 shrink-0">{ev.event ?? "data"}</span>
<span className="text-text-main break-all">{ev.data}</span>
</div>
))}
{events.length === 0 && (
<p className="text-text-muted italic">No SSE events</p>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import type { InterceptedRequest } from "@/mitm/inspector/types";
interface TimingWaterfallProps {
request: InterceptedRequest;
}
export function TimingWaterfall({ request }: TimingWaterfallProps) {
const { proxyLatencyMs, upstreamLatencyMs, totalLatencyMs } = request;
const total = totalLatencyMs ?? (proxyLatencyMs ?? 0) + (upstreamLatencyMs ?? 0);
if (!total) {
return <p className="text-sm text-text-muted">No timing data available.</p>;
}
const segments: Array<{ label: string; ms: number; color: string }> = [
{
label: "Proxy overhead",
ms: proxyLatencyMs ?? 0,
color: "bg-blue-500",
},
{
label: "Upstream response",
ms: upstreamLatencyMs ?? 0,
color: "bg-green-500",
},
];
return (
<div className="space-y-4">
<div className="space-y-2">
{segments.map((seg) => {
const pct = total > 0 ? (seg.ms / total) * 100 : 0;
return (
<div key={seg.label} className="space-y-1">
<div className="flex justify-between text-xs text-text-muted">
<span>{seg.label}</span>
<span>{seg.ms}ms ({pct.toFixed(1)}%)</span>
</div>
<div className="h-4 w-full rounded bg-bg-subtle">
<div
className={`h-full rounded ${seg.color}`}
style={{ width: `${Math.max(pct, 0.5)}%` }}
/>
</div>
</div>
);
})}
</div>
<div className="flex justify-between text-xs font-medium text-text-main border-t border-border pt-2">
<span>Total latency</span>
<span>{total}ms</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
interface TokenBadgeProps {
tokensIn?: number | null;
tokensOut?: number | null;
}
export function TokenBadge({ tokensIn, tokensOut }: TokenBadgeProps) {
if (!tokensIn && !tokensOut) return null;
return (
<span className="inline-flex items-center gap-1 rounded bg-purple-900/40 px-2 py-0.5 text-xs text-purple-300 font-mono">
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
token
</span>
{tokensIn != null && <span>{tokensIn}</span>}
{tokensOut != null && <span>{tokensOut}</span>}
</span>
);
}