mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
feat(ui): traffic-inspector shared components (waterfall, json, context bar, etc) (F8)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">"{data}"</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">"{k}"</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user