mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
chore(ui): remove dead UI components unreferenced by any page (#30340)
These components are not reachable from any page: WebRTCTester, the agents agent_card/agent_card_grid pair (the agents route renders a Table directly), and view_logs ErrorViewer/RequestResponsePanel (logs uses inline equivalents in columns.tsx). Each was imported only by its own test, so the tests go too. Also drops the now-stale agent_card_grid vi.mock in agents.test.tsx and the WebRTCTester eslint-suppressions entry.
This commit is contained in:
parent
ec9353cb69
commit
1fab5c4ca6
11 changed files with 0 additions and 1670 deletions
|
|
@ -678,14 +678,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/WebRTCTester.jsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
},
|
||||
"react/no-unescaped-entities": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/activity_metrics.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,648 +0,0 @@
|
|||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
const STYLES = `
|
||||
.wrt-wrap {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
background: #0d0d14;
|
||||
border: 1px solid #1e1e2e;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.wrt-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #0d0d14;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.wrt-toggle:hover { background: #111120; }
|
||||
|
||||
.wrt-toggle-left { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.wrt-live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 8px #00ff88;
|
||||
animation: wrt-blink 2s infinite;
|
||||
}
|
||||
@keyframes wrt-blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
|
||||
.wrt-toggle-title { font-size: 12px; font-weight: 600; color: #e2e8f0; letter-spacing: 0.06em; }
|
||||
.wrt-toggle-sub { font-size: 10px; color: #4a5568; margin-top: 1px; }
|
||||
.wrt-chevron { font-size: 11px; color: #4a5568; transition: transform 0.2s; }
|
||||
.wrt-chevron.open { transform: rotate(180deg); }
|
||||
|
||||
.wrt-body {
|
||||
border-top: 1px solid #1e1e2e;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: 460px;
|
||||
}
|
||||
|
||||
.wrt-sidebar {
|
||||
border-right: 1px solid #1e1e2e;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wrt-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: #4a5568;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.wrt-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
|
||||
.wrt-field label { font-size: 10px; color: #4a5568; }
|
||||
.wrt-field input {
|
||||
background: #0a0a0f;
|
||||
border: 1px solid #1e1e2e;
|
||||
border-radius: 5px;
|
||||
color: #e2e8f0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
padding: 7px 9px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.wrt-field input:focus { border-color: #7c3aed; }
|
||||
|
||||
.wrt-divider { height: 1px; background: #1e1e2e; }
|
||||
|
||||
.wrt-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: none; border-radius: 5px; cursor: pointer;
|
||||
font-family: inherit; font-size: 11px; font-weight: 600;
|
||||
padding: 8px; width: 100%;
|
||||
transition: all 0.15s; letter-spacing: 0.04em;
|
||||
}
|
||||
.wrt-btn + .wrt-btn { margin-top: 5px; }
|
||||
.wrt-btn-primary { background: #00ff88; color: #000; }
|
||||
.wrt-btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.wrt-btn-primary:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.wrt-btn-danger { background: transparent; color: #ff4466; border: 1px solid #ff4466; }
|
||||
.wrt-btn-danger:hover:not(:disabled) { background: rgba(255,68,102,0.08); }
|
||||
.wrt-btn-danger:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.wrt-btn-ghost { background: #111118; color: #e2e8f0; border: 1px solid #1e1e2e; }
|
||||
.wrt-btn-ghost:hover { border-color: #7c3aed; }
|
||||
|
||||
.wrt-flow { display: flex; align-items: center; padding: 4px 0; gap: 0; }
|
||||
.wrt-flow-box {
|
||||
padding: 4px 7px; border-radius: 4px; font-size: 9px;
|
||||
border: 1px solid #1e1e2e; color: #4a5568;
|
||||
transition: all 0.3s; white-space: nowrap;
|
||||
}
|
||||
.wrt-flow-box.active { border-color: #00ff88; color: #00ff88; box-shadow: 0 0 8px rgba(0,255,136,0.15); }
|
||||
.wrt-flow-arrow { font-size: 10px; color: #4a5568; padding: 0 4px; transition: color 0.3s; }
|
||||
.wrt-flow-arrow.active { color: #00ff88; }
|
||||
|
||||
.wrt-meta { display: flex; flex-direction: column; gap: 4px; }
|
||||
.wrt-meta-row { display: flex; justify-content: space-between; font-size: 10px; }
|
||||
.wrt-meta-row span:first-child { color: #4a5568; }
|
||||
.wrt-meta-row span:last-child { color: #e2e8f0; }
|
||||
|
||||
.wrt-status-pill {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px; color: #4a5568;
|
||||
background: #111118; border: 1px solid #1e1e2e;
|
||||
border-radius: 100px; padding: 3px 10px;
|
||||
}
|
||||
.wrt-status-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #4a5568; transition: all 0.3s;
|
||||
}
|
||||
.wrt-status-dot.connected { background: #00ff88; box-shadow: 0 0 6px #00ff88; }
|
||||
.wrt-status-dot.connecting { background: #ffaa00; animation: wrt-blink 1s infinite; }
|
||||
.wrt-status-dot.error { background: #ff4466; }
|
||||
|
||||
.wrt-main { display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.wrt-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 14px; border-bottom: 1px solid #1e1e2e; background: #111118;
|
||||
}
|
||||
.wrt-header-title { font-size: 10px; color: #4a5568; letter-spacing: 0.08em; }
|
||||
|
||||
.wrt-tabs { display: flex; padding: 0 14px; border-bottom: 1px solid #1e1e2e; }
|
||||
.wrt-tab {
|
||||
font-size: 9px; letter-spacing: 0.08em; padding: 10px 12px; cursor: pointer;
|
||||
color: #4a5568; border-bottom: 2px solid transparent; transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.wrt-tab.active { color: #00ff88; border-bottom-color: #00ff88; }
|
||||
.wrt-tab:hover:not(.active) { color: #e2e8f0; }
|
||||
|
||||
.wrt-tab-content { flex: 1; overflow: hidden; display: none; flex-direction: column; }
|
||||
.wrt-tab-content.active { display: flex; }
|
||||
|
||||
.wrt-log {
|
||||
flex: 1; overflow-y: auto; padding: 8px 12px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.wrt-log::-webkit-scrollbar { width: 3px; }
|
||||
.wrt-log::-webkit-scrollbar-thumb { background: #1e1e2e; border-radius: 2px; }
|
||||
|
||||
.wrt-entry {
|
||||
display: grid; grid-template-columns: 58px 56px 1fr; gap: 8px;
|
||||
padding: 3px 7px; border-radius: 3px;
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 10px; line-height: 1.5;
|
||||
animation: wrt-fadein 0.15s ease;
|
||||
}
|
||||
@keyframes wrt-fadein { from { opacity:0; transform:translateY(2px); } to { opacity:1; transform:none; } }
|
||||
|
||||
.wrt-entry.info { border-left-color: #7c3aed; }
|
||||
.wrt-entry.info .we-tag { color: #7c3aed; }
|
||||
.wrt-entry.success { border-left-color: #00ff88; }
|
||||
.wrt-entry.success .we-tag { color: #00ff88; }
|
||||
.wrt-entry.error { border-left-color: #ff4466; }
|
||||
.wrt-entry.error .we-tag { color: #ff4466; }
|
||||
.wrt-entry.warn { border-left-color: #ffaa00; }
|
||||
.wrt-entry.warn .we-tag { color: #ffaa00; }
|
||||
.wrt-entry.step { border-left-color: #60a5fa; }
|
||||
.wrt-entry.step .we-tag { color: #60a5fa; }
|
||||
|
||||
.we-time { color: #4a5568; font-size: 9px; padding-top: 1px; }
|
||||
.we-tag { font-size: 9px; font-weight: 700; padding-top: 1px; }
|
||||
.we-msg { color: #e2e8f0; word-break: break-all; white-space: pre-wrap; }
|
||||
|
||||
.wrt-empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
flex: 1; gap: 6px; color: #4a5568; font-size: 11px;
|
||||
}
|
||||
|
||||
.wrt-sdp-pane { flex: 1; display: grid; grid-template-columns: 1fr 1fr; overflow: hidden; }
|
||||
.wrt-sdp-box { display: flex; flex-direction: column; border-right: 1px solid #1e1e2e; overflow: hidden; }
|
||||
.wrt-sdp-box:last-child { border-right: none; }
|
||||
.wrt-sdp-hdr {
|
||||
padding: 7px 12px; border-bottom: 1px solid #1e1e2e;
|
||||
font-size: 9px; color: #4a5568; letter-spacing: 0.08em;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.wrt-sdp-dot { width: 5px; height: 5px; border-radius: 50%; background: #1e1e2e; }
|
||||
.wrt-sdp-dot.active { background: #00ff88; }
|
||||
.wrt-sdp-pane textarea {
|
||||
flex: 1; background: transparent; border: none; color: #e2e8f0;
|
||||
font-family: inherit; font-size: 10px; padding: 10px 12px;
|
||||
resize: none; outline: none; line-height: 1.5;
|
||||
}
|
||||
|
||||
.wrt-audio-pane {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 14px;
|
||||
}
|
||||
.wrt-viz { display: flex; align-items: center; gap: 2px; height: 44px; }
|
||||
.wrt-bar { width: 3px; border-radius: 2px; min-height: 2px; background: #00ff88; transition: height 0.05s; }
|
||||
.wrt-mic-btn {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
background: #111118; border: 1.5px solid #1e1e2e;
|
||||
font-size: 18px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; transition: all 0.2s;
|
||||
}
|
||||
.wrt-mic-btn.active { border-color: #00ff88; box-shadow: 0 0 16px rgba(0,255,136,0.2); }
|
||||
.wrt-audio-status { font-size: 10px; color: #4a5568; text-align: center; }
|
||||
`;
|
||||
|
||||
function useLog() {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const add = useCallback((level, tag, msg) => {
|
||||
const time = new Date().toTimeString().slice(0, 8);
|
||||
setEntries((prev) => [...prev, { level, tag, msg, time, id: Date.now() + Math.random() }]);
|
||||
}, []);
|
||||
const clear = useCallback(() => setEntries([]), []);
|
||||
return { entries, add, clear };
|
||||
}
|
||||
|
||||
export default function WebRTCTester() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("logs");
|
||||
const [proxyUrl, setProxyUrl] = useState("http://localhost:4000");
|
||||
const [apiKey, setApiKey] = useState("sk-1234");
|
||||
const [model, setModel] = useState("gpt-4o-realtime-preview");
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [flowStep, setFlowStep] = useState(0);
|
||||
const [tokenPreview, setTokenPreview] = useState("—");
|
||||
const [iceState, setIceState] = useState("—");
|
||||
const [connState, setConnState] = useState("—");
|
||||
const [dcState, setDcState] = useState("—");
|
||||
const [sdpOffer, setSdpOffer] = useState("");
|
||||
const [sdpAnswer, setSdpAnswer] = useState("");
|
||||
const [offerActive, setOfferActive] = useState(false);
|
||||
const [answerActive, setAnswerActive] = useState(false);
|
||||
const [audioStatus, setAudioStatus] = useState("Start a session first");
|
||||
const [micActive, setMicActive] = useState(false);
|
||||
const [bars, setBars] = useState(Array(28).fill(2));
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const { entries, add: log, clear: clearLogs } = useLog();
|
||||
const logRef = useRef(null);
|
||||
|
||||
const pcRef = useRef(null);
|
||||
const dcRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const audioCtxRef = useRef(null);
|
||||
const analyserRef = useRef(null);
|
||||
const animRef = useRef(null);
|
||||
const tokenRef = useRef(null);
|
||||
const micRef = useRef(false);
|
||||
const remoteAudioRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}, [entries]);
|
||||
|
||||
function drawBars() {
|
||||
animRef.current = requestAnimationFrame(drawBars);
|
||||
if (!analyserRef.current) return;
|
||||
const data = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(data);
|
||||
setBars(Array.from({ length: 28 }, (_, i) => Math.max(2, ((data[i] || 0) / 255) * 42)));
|
||||
}
|
||||
|
||||
function setupAnalyser(stream) {
|
||||
audioCtxRef.current = new AudioContext();
|
||||
const src = audioCtxRef.current.createMediaStreamSource(stream);
|
||||
analyserRef.current = audioCtxRef.current.createAnalyser();
|
||||
analyserRef.current.fftSize = 64;
|
||||
src.connect(analyserRef.current);
|
||||
drawBars();
|
||||
}
|
||||
|
||||
async function startSession() {
|
||||
const url = proxyUrl.trim().replace(/\/$/, "");
|
||||
const key = apiKey.trim();
|
||||
const mdl = model.trim();
|
||||
|
||||
setConnected(true);
|
||||
setStatus("connecting");
|
||||
setFlowStep(1);
|
||||
|
||||
// Step 1: ephemeral token
|
||||
log("step", "STEP 1", `POST ${url}/v1/realtime/client_secrets`);
|
||||
let tokenResp;
|
||||
try {
|
||||
const r = await fetch(`${url}/v1/realtime/client_secrets`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
||||
body: JSON.stringify({ model: mdl }),
|
||||
});
|
||||
log("info", "HTTP", `${r.status} ${r.statusText}`);
|
||||
const raw = await r.text();
|
||||
if (!r.ok) {
|
||||
log("error", "ERR", raw);
|
||||
stopSession();
|
||||
return;
|
||||
}
|
||||
tokenResp = JSON.parse(raw);
|
||||
log("success", "TOKEN", "Received encrypted ephemeral token");
|
||||
} catch (e) {
|
||||
log("error", "ERR", `client_secrets failed: ${e.message}`);
|
||||
stopSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = tokenResp?.client_secret?.value ?? tokenResp?.value;
|
||||
if (!token) {
|
||||
log("error", "ERR", `Cannot extract token: ${JSON.stringify(tokenResp)}`);
|
||||
stopSession();
|
||||
return;
|
||||
}
|
||||
tokenRef.current = token;
|
||||
setTokenPreview(token.slice(0, 10) + "…");
|
||||
log("info", "TOKEN", `Preview: ${token.slice(0, 10)}…`);
|
||||
|
||||
// Step 2: PeerConnection
|
||||
log("step", "STEP 2", "Creating RTCPeerConnection");
|
||||
const pc = new RTCPeerConnection();
|
||||
pcRef.current = pc;
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
setIceState(pc.iceConnectionState);
|
||||
log("info", "ICE", pc.iceConnectionState);
|
||||
if (pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed") {
|
||||
setStatus("connected");
|
||||
setFlowStep(3);
|
||||
}
|
||||
if (pc.iceConnectionState === "failed" || pc.iceConnectionState === "disconnected") {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
setConnState(pc.connectionState);
|
||||
log("info", "CONN", pc.connectionState);
|
||||
};
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
log("success", "AUDIO", "Remote audio track received from OpenAI");
|
||||
if (remoteAudioRef.current) remoteAudioRef.current.srcObject = e.streams[0];
|
||||
setupAnalyser(e.streams[0]);
|
||||
setAudioStatus("Receiving audio from OpenAI ✓");
|
||||
};
|
||||
|
||||
const dc = pc.createDataChannel("oai-events");
|
||||
dcRef.current = dc;
|
||||
dc.onopen = () => {
|
||||
setDcState("open");
|
||||
log("success", "DC", "Data channel open — ready!");
|
||||
setStatus("connected");
|
||||
};
|
||||
dc.onclose = () => {
|
||||
setDcState("closed");
|
||||
log("warn", "DC", "Closed");
|
||||
};
|
||||
dc.onmessage = (e) => {
|
||||
try {
|
||||
log("info", "EVENT", JSON.parse(e.data).type ?? "unknown");
|
||||
} catch {
|
||||
log("info", "EVENT", e.data.slice(0, 100));
|
||||
}
|
||||
};
|
||||
|
||||
// Mic
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
|
||||
log("success", "MIC", "Microphone access granted");
|
||||
setAudioStatus("Mic active — waiting for remote audio");
|
||||
micRef.current = true;
|
||||
setMicActive(true);
|
||||
} catch (e) {
|
||||
log("warn", "MIC", `Mic denied: ${e.message}`);
|
||||
const ctx = new AudioContext();
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
dest.stream.getTracks().forEach((t) => pc.addTrack(t, dest.stream));
|
||||
}
|
||||
|
||||
// Step 3: SDP offer
|
||||
log("step", "STEP 3", "Creating SDP offer");
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
setSdpOffer(offer.sdp);
|
||||
setOfferActive(true);
|
||||
log("info", "SDP", `Offer created (${offer.sdp.split("\n").length} lines)`);
|
||||
|
||||
// Step 4: SDP exchange
|
||||
setFlowStep(2);
|
||||
log("step", "STEP 4", `POST ${url}/v1/realtime/calls`);
|
||||
try {
|
||||
const r = await fetch(`${url}/v1/realtime/calls`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/sdp" },
|
||||
body: offer.sdp,
|
||||
});
|
||||
log("info", "HTTP", `${r.status} ${r.statusText}`);
|
||||
if (!r.ok) {
|
||||
log("error", "ERR", await r.text());
|
||||
stopSession();
|
||||
return;
|
||||
}
|
||||
const ans = await r.text();
|
||||
log("success", "SDP", `Answer received (${ans.split("\n").length} lines)`);
|
||||
|
||||
// Step 5: remote description
|
||||
log("step", "STEP 5", "Setting remote description");
|
||||
await pc.setRemoteDescription({ type: "answer", sdp: ans });
|
||||
setSdpAnswer(ans);
|
||||
setAnswerActive(true);
|
||||
log("success", "CONN", "✓ Session established — Browser ↔ LiteLLM ↔ OpenAI");
|
||||
} catch (e) {
|
||||
log("error", "ERR", `calls failed: ${e.message}`);
|
||||
stopSession();
|
||||
}
|
||||
}
|
||||
|
||||
function stopSession() {
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (animRef.current) {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
animRef.current = null;
|
||||
}
|
||||
tokenRef.current = null;
|
||||
micRef.current = false;
|
||||
setConnected(false);
|
||||
setStatus("idle");
|
||||
setFlowStep(0);
|
||||
setTokenPreview("—");
|
||||
setIceState("—");
|
||||
setConnState("—");
|
||||
setDcState("—");
|
||||
setMicActive(false);
|
||||
setOfferActive(false);
|
||||
setAnswerActive(false);
|
||||
setBars(Array(28).fill(2));
|
||||
setAudioStatus("Start a session first");
|
||||
log("warn", "SESSION", "Session stopped");
|
||||
}
|
||||
|
||||
function toggleMic() {
|
||||
if (!streamRef.current) {
|
||||
log("warn", "MIC", "No active session");
|
||||
return;
|
||||
}
|
||||
const next = !micRef.current;
|
||||
micRef.current = next;
|
||||
streamRef.current.getAudioTracks().forEach((t) => {
|
||||
t.enabled = next;
|
||||
});
|
||||
setMicActive(next);
|
||||
log("info", "MIC", next ? "Unmuted" : "Muted");
|
||||
}
|
||||
|
||||
const f = (n) => flowStep >= n;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{STYLES}</style>
|
||||
<div className="wrt-wrap">
|
||||
{/* Toggle header */}
|
||||
<div className={`wrt-toggle${open ? "" : " closed"}`} onClick={() => setOpen((o) => !o)}>
|
||||
<div className="wrt-toggle-left">
|
||||
<div className="wrt-live-dot" />
|
||||
<div>
|
||||
<div className="wrt-toggle-title">INTERACTIVE TESTER</div>
|
||||
<div className="wrt-toggle-sub">Browser → LiteLLM → OpenAI · WebRTC</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`wrt-chevron${open ? " open" : ""}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="wrt-body">
|
||||
{/* Sidebar */}
|
||||
<div className="wrt-sidebar">
|
||||
<div>
|
||||
<div className="wrt-label">Proxy Config</div>
|
||||
<div className="wrt-field">
|
||||
<label>Proxy URL</label>
|
||||
<input
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
placeholder="http://localhost:4000"
|
||||
/>
|
||||
</div>
|
||||
<div className="wrt-field">
|
||||
<label>API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-1234"
|
||||
/>
|
||||
</div>
|
||||
<div className="wrt-field">
|
||||
<label>Model</label>
|
||||
<input value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wrt-divider" />
|
||||
|
||||
<div>
|
||||
<div className="wrt-label">Flow</div>
|
||||
<div className="wrt-flow">
|
||||
<div className={`wrt-flow-box${f(1) ? " active" : ""}`}>Browser</div>
|
||||
<div className={`wrt-flow-arrow${f(1) ? " active" : ""}`}>→</div>
|
||||
<div className={`wrt-flow-box${f(1) ? " active" : ""}`}>LiteLLM</div>
|
||||
<div className={`wrt-flow-arrow${f(2) ? " active" : ""}`}>→</div>
|
||||
<div className={`wrt-flow-box${f(2) ? " active" : ""}`}>OpenAI</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wrt-divider" />
|
||||
|
||||
<div>
|
||||
<div className="wrt-label">Controls</div>
|
||||
<button className="wrt-btn wrt-btn-primary" onClick={startSession} disabled={connected}>
|
||||
▶ Start Session
|
||||
</button>
|
||||
<button className="wrt-btn wrt-btn-danger" onClick={stopSession} disabled={!connected}>
|
||||
■ Stop
|
||||
</button>
|
||||
<button className="wrt-btn wrt-btn-ghost" onClick={clearLogs} style={{ marginTop: 5 }}>
|
||||
✕ Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="wrt-divider" />
|
||||
|
||||
<div>
|
||||
<div className="wrt-label">Session Info</div>
|
||||
<div className="wrt-meta">
|
||||
{[
|
||||
["token", tokenPreview],
|
||||
["ice", iceState],
|
||||
["conn", connState],
|
||||
["data ch.", dcState],
|
||||
].map(([k, v]) => (
|
||||
<div className="wrt-meta-row" key={k}>
|
||||
<span>{k}</span>
|
||||
<span>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="wrt-main">
|
||||
<div className="wrt-header">
|
||||
<span className="wrt-header-title">WEBRTC REALTIME TESTER</span>
|
||||
<div className="wrt-status-pill">
|
||||
<div className={`wrt-status-dot${status !== "idle" ? ` ${status}` : ""}`} />
|
||||
<span style={{ fontSize: 10, color: "#4a5568" }}>{status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wrt-tabs">
|
||||
{["logs", "sdp", "audio"].map((t) => (
|
||||
<div key={t} className={`wrt-tab${activeTab === t ? " active" : ""}`} onClick={() => setActiveTab(t)}>
|
||||
{t.toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div className={`wrt-tab-content${activeTab === "logs" ? " active" : ""}`}>
|
||||
<div className="wrt-log" ref={logRef}>
|
||||
{entries.length === 0 ? (
|
||||
<div className="wrt-empty">
|
||||
<div style={{ fontSize: 22, opacity: 0.3 }}>📡</div>
|
||||
<div>Hit "Start Session" to begin</div>
|
||||
</div>
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<div key={e.id} className={`wrt-entry ${e.level}`}>
|
||||
<span className="we-time">{e.time}</span>
|
||||
<span className="we-tag">[{e.tag}]</span>
|
||||
<span className="we-msg">{e.msg}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SDP */}
|
||||
<div className={`wrt-tab-content${activeTab === "sdp" ? " active" : ""}`}>
|
||||
<div className="wrt-sdp-pane">
|
||||
<div className="wrt-sdp-box">
|
||||
<div className="wrt-sdp-hdr">
|
||||
<div className={`wrt-sdp-dot${offerActive ? " active" : ""}`} />
|
||||
SDP OFFER
|
||||
</div>
|
||||
<textarea readOnly value={sdpOffer} placeholder="SDP offer appears here..." />
|
||||
</div>
|
||||
<div className="wrt-sdp-box">
|
||||
<div className="wrt-sdp-hdr">
|
||||
<div className={`wrt-sdp-dot${answerActive ? " active" : ""}`} />
|
||||
SDP ANSWER
|
||||
</div>
|
||||
<textarea readOnly value={sdpAnswer} placeholder="SDP answer appears here..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio */}
|
||||
<div className={`wrt-tab-content${activeTab === "audio" ? " active" : ""}`}>
|
||||
<div className="wrt-audio-pane">
|
||||
<div className="wrt-viz">
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="wrt-bar"
|
||||
style={{ height: h + "px", background: `hsl(${150 - (h / 42) * 30},100%,55%)` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button className={`wrt-mic-btn${micActive ? " active" : ""}`} onClick={toggleMic}>
|
||||
🎙️
|
||||
</button>
|
||||
<div className="wrt-audio-status">{audioStatus}</div>
|
||||
<audio ref={remoteAudioRef} autoPlay style={{ display: "none" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,12 +14,6 @@ vi.mock("./agents/add_agent_form", () => ({
|
|||
default: () => <div data-testid="add-agent-form" />,
|
||||
}));
|
||||
|
||||
vi.mock("./agents/agent_card_grid", () => ({
|
||||
default: ({ isAdmin }: { isAdmin: boolean }) => <div data-testid="agent-card-grid" data-is-admin={String(isAdmin)} />,
|
||||
}));
|
||||
|
||||
// Note: agents.tsx no longer uses AgentCardGrid — it renders a Table directly.
|
||||
|
||||
vi.mock("./agents/agent_info", () => ({
|
||||
default: () => <div data-testid="agent-info" />,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import AgentCard from "./agent_card";
|
||||
import type { Agent } from "./types";
|
||||
|
||||
const baseAgent: Agent = {
|
||||
agent_id: "agent-123",
|
||||
agent_name: "Test Agent",
|
||||
litellm_params: { model: "gpt-4" },
|
||||
agent_card_params: {
|
||||
description: "A test agent for unit testing",
|
||||
url: "https://agent.example.com",
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
agent: baseAgent,
|
||||
onAgentClick: vi.fn(),
|
||||
accessToken: "token-123",
|
||||
isAdmin: false,
|
||||
onAgentUpdated: vi.fn(),
|
||||
};
|
||||
|
||||
describe("AgentCard", () => {
|
||||
it("should render the agent name and description", () => {
|
||||
renderWithProviders(<AgentCard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("A test agent for unit testing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'No description' when agent has no description", () => {
|
||||
const agent = { ...baseAgent, agent_card_params: {} };
|
||||
renderWithProviders(<AgentCard {...defaultProps} agent={agent} />);
|
||||
|
||||
expect(screen.getByText("No description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the agent URL when provided", () => {
|
||||
renderWithProviders(<AgentCard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("https://agent.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Needs Setup' badge when agent has no key", () => {
|
||||
renderWithProviders(<AgentCard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Needs Setup")).toBeInTheDocument();
|
||||
expect(screen.getByText("No key assigned")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Active' badge and key info when agent has a key", () => {
|
||||
const keyInfo = { has_key: true, key_alias: "my-key" };
|
||||
renderWithProviders(<AgentCard {...defaultProps} keyInfo={keyInfo} />);
|
||||
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("my-key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onAgentClick when card is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAgentClick = vi.fn();
|
||||
renderWithProviders(<AgentCard {...defaultProps} onAgentClick={onAgentClick} />);
|
||||
|
||||
await user.click(screen.getByText("Test Agent"));
|
||||
|
||||
expect(onAgentClick).toHaveBeenCalledWith("agent-123");
|
||||
});
|
||||
|
||||
it("should show delete button only for admins", () => {
|
||||
const onDeleteClick = vi.fn();
|
||||
const { unmount } = renderWithProviders(
|
||||
<AgentCard {...defaultProps} isAdmin={false} onDeleteClick={onDeleteClick} />,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
renderWithProviders(<AgentCard {...defaultProps} isAdmin={true} onDeleteClick={onDeleteClick} />);
|
||||
expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onDeleteClick with agent id and name when delete is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeleteClick = vi.fn();
|
||||
renderWithProviders(<AgentCard {...defaultProps} isAdmin={true} onDeleteClick={onDeleteClick} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /delete/i }));
|
||||
|
||||
expect(onDeleteClick).toHaveBeenCalledWith("agent-123", "Test Agent");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import React from "react";
|
||||
import { Card, Badge, Tooltip, Button } from "antd";
|
||||
import { CopyOutlined, KeyOutlined, WarningOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Agent, AgentKeyInfo } from "./types";
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent;
|
||||
keyInfo?: AgentKeyInfo;
|
||||
onAgentClick: (agentId: string) => void;
|
||||
onDeleteClick?: (agentId: string, agentName: string) => void;
|
||||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
onAgentUpdated: () => void;
|
||||
}
|
||||
|
||||
const AgentCard: React.FC<AgentCardProps> = ({ agent, keyInfo, onAgentClick, onDeleteClick, isAdmin }) => {
|
||||
const description = agent.agent_card_params?.description || "No description";
|
||||
const url = agent.agent_card_params?.url;
|
||||
const hasKey = keyInfo?.has_key ?? false;
|
||||
const statusBadge = hasKey ? <Badge status="success" text="Active" /> : <Badge status="warning" text="Needs Setup" />;
|
||||
|
||||
const copyToClipboard = (e: React.MouseEvent, text: string) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="h-full flex flex-col"
|
||||
styles={{
|
||||
body: { flex: 1, display: "flex", flexDirection: "column" },
|
||||
}}
|
||||
onClick={() => onAgentClick(agent.agent_id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900 truncate">{agent.agent_name}</span>
|
||||
<Tooltip title="Copy Agent ID">
|
||||
<CopyOutlined
|
||||
onClick={(e) => copyToClipboard(e, agent.agent_id)}
|
||||
className="cursor-pointer text-gray-400 hover:text-blue-500 text-xs shrink-0"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mt-1">{statusBadge}</div>
|
||||
</div>
|
||||
{isAdmin && onDeleteClick && (
|
||||
<Tooltip title="Delete agent">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteClick(agent.agent_id, agent.agent_name);
|
||||
}}
|
||||
className="shrink-0 -mr-1"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 line-clamp-2 flex-1 mb-3">{description}</p>
|
||||
{url && (
|
||||
<p className="text-xs text-gray-500 truncate mb-2" title={url}>
|
||||
{url}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto pt-3 border-t border-gray-100 text-xs">
|
||||
{hasKey ? (
|
||||
<div className="flex items-center gap-1.5 text-gray-600">
|
||||
<KeyOutlined />
|
||||
<span>{keyInfo?.key_alias || keyInfo?.token_prefix || "Key assigned"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-amber-600">
|
||||
<WarningOutlined />
|
||||
<span>No key assigned</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCard;
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import AgentCardGrid from "./agent_card_grid";
|
||||
import type { Agent, AgentKeyInfo } from "./types";
|
||||
|
||||
vi.mock("./agent_card", () => ({
|
||||
default: ({ agent, onAgentClick }: any) => (
|
||||
<div data-testid={`agent-card-${agent.agent_id}`} onClick={() => onAgentClick(agent.agent_id)}>
|
||||
{agent.agent_name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
agent_id: "agent-1",
|
||||
agent_name: "Test Agent 1",
|
||||
litellm_params: { model: "gpt-4" },
|
||||
agent_card_params: { description: "First agent" },
|
||||
},
|
||||
{
|
||||
agent_id: "agent-2",
|
||||
agent_name: "Test Agent 2",
|
||||
litellm_params: { model: "claude-3" },
|
||||
agent_card_params: { description: "Second agent" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockKeyInfoMap: Record<string, AgentKeyInfo> = {
|
||||
"agent-1": { has_key: true, key_alias: "key-1" },
|
||||
"agent-2": { has_key: false },
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
agentsList: mockAgents,
|
||||
keyInfoMap: mockKeyInfoMap,
|
||||
isLoading: false,
|
||||
onDeleteClick: vi.fn(),
|
||||
accessToken: "test-token",
|
||||
onAgentUpdated: vi.fn(),
|
||||
isAdmin: true,
|
||||
onAgentClick: vi.fn(),
|
||||
};
|
||||
|
||||
describe("AgentCardGrid", () => {
|
||||
it("should render", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} />);
|
||||
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all agent cards", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} />);
|
||||
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Agent 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading skeletons when isLoading is true", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} isLoading={true} />);
|
||||
expect(screen.queryByText("Test Agent 1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show admin empty state message when no agents and isAdmin", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={true} />);
|
||||
expect(screen.getByText("No agents found. Create one to get started.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show non-admin empty state message when no agents and not admin", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={false} />);
|
||||
expect(screen.getByText("No agents found. Contact an admin to create agents.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onAgentClick when a card is clicked", async () => {
|
||||
const onAgentClick = vi.fn();
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} onAgentClick={onAgentClick} />);
|
||||
const { default: userEvent } = await import("@testing-library/user-event");
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("agent-card-agent-1"));
|
||||
expect(onAgentClick).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import React from "react";
|
||||
import { Skeleton } from "antd";
|
||||
import AgentCard from "./agent_card";
|
||||
import { Agent, AgentKeyInfo } from "./types";
|
||||
|
||||
interface AgentCardGridProps {
|
||||
agentsList: Agent[];
|
||||
keyInfoMap: Record<string, AgentKeyInfo>;
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (agentId: string, agentName: string) => void;
|
||||
accessToken: string | null;
|
||||
onAgentUpdated: () => void;
|
||||
isAdmin: boolean;
|
||||
onAgentClick: (agentId: string) => void;
|
||||
}
|
||||
|
||||
const AgentCardGrid: React.FC<AgentCardGridProps> = ({
|
||||
agentsList,
|
||||
keyInfoMap,
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
accessToken,
|
||||
onAgentUpdated,
|
||||
isAdmin,
|
||||
onAgentClick,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} active paragraph={{ rows: 3 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agentsList || agentsList.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 py-12 text-center">
|
||||
<p className="text-gray-500">
|
||||
{isAdmin
|
||||
? "No agents found. Create one to get started."
|
||||
: "No agents found. Contact an admin to create agents."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{agentsList.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.agent_id}
|
||||
agent={agent}
|
||||
keyInfo={keyInfoMap[agent.agent_id]}
|
||||
onAgentClick={onAgentClick}
|
||||
onDeleteClick={isAdmin ? onDeleteClick : undefined}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
onAgentUpdated={onAgentUpdated}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCardGrid;
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ErrorViewer } from "./ErrorViewer";
|
||||
|
||||
const basicError = {
|
||||
error_class: "NotFoundError",
|
||||
error_message: "Model gpt-5 not found",
|
||||
};
|
||||
|
||||
const errorWithTraceback = {
|
||||
error_class: "AuthenticationError",
|
||||
error_message: "Invalid API key",
|
||||
traceback: `Traceback (most recent call last):
|
||||
File "/app/main.py", line 42, in handle_request
|
||||
result = await client.chat(model="gpt-4")
|
||||
File "/app/llms/openai.py", line 100, in chat
|
||||
response = self._make_request(payload)
|
||||
File "/app/llms/base.py", line 55, in _make_request
|
||||
raise AuthenticationError("Invalid API key")`,
|
||||
};
|
||||
|
||||
describe("ErrorViewer", () => {
|
||||
it("should render error type and message", () => {
|
||||
render(<ErrorViewer errorInfo={basicError} />);
|
||||
expect(screen.getByText("NotFoundError")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model gpt-5 not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Unknown Error' when error_class is missing", () => {
|
||||
render(<ErrorViewer errorInfo={{ error_message: "something broke" }} />);
|
||||
expect(screen.getByText("Unknown Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Unknown error occurred' when error_message is missing", () => {
|
||||
render(<ErrorViewer errorInfo={{ error_class: "RuntimeError" }} />);
|
||||
expect(screen.getByText("Unknown error occurred")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render traceback frames when traceback is present", () => {
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
expect(screen.getByText("Traceback")).toBeInTheDocument();
|
||||
expect(screen.getByText("main.py")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai.py")).toBeInTheDocument();
|
||||
expect(screen.getByText("base.py")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand a frame when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
|
||||
await user.click(screen.getByText("main.py"));
|
||||
|
||||
expect(screen.getByText('result = await client.chat(model="gpt-4")')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand all frames when 'Expand All' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
|
||||
await user.click(screen.getByText("Expand All"));
|
||||
|
||||
expect(screen.getByText("Collapse All")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should copy traceback to clipboard when copy button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: mockWriteText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<ErrorViewer errorInfo={errorWithTraceback} />);
|
||||
|
||||
await user.click(screen.getByTitle("Copy traceback"));
|
||||
expect(mockWriteText).toHaveBeenCalledWith(errorWithTraceback.traceback);
|
||||
});
|
||||
|
||||
it("should not render traceback section when traceback is absent", () => {
|
||||
render(<ErrorViewer errorInfo={basicError} />);
|
||||
expect(screen.queryByText("Traceback")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
interface ErrorViewerProps {
|
||||
errorInfo: {
|
||||
error_class?: string;
|
||||
error_message?: string;
|
||||
traceback?: string;
|
||||
llm_provider?: string;
|
||||
error_code?: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ErrorViewer: React.FC<ErrorViewerProps> = ({ errorInfo }) => {
|
||||
const [expandedFrames, setExpandedFrames] = React.useState<{ [key: number]: boolean }>({});
|
||||
const [allExpanded, setAllExpanded] = React.useState(false);
|
||||
|
||||
// Toggle individual frame
|
||||
const toggleFrame = (index: number) => {
|
||||
setExpandedFrames((prev) => ({
|
||||
...prev,
|
||||
[index]: !prev[index],
|
||||
}));
|
||||
};
|
||||
|
||||
// Toggle all frames
|
||||
const toggleAllFrames = () => {
|
||||
const newState = !allExpanded;
|
||||
setAllExpanded(newState);
|
||||
|
||||
if (tracebackFrames.length > 0) {
|
||||
const newExpandedState: { [key: number]: boolean } = {};
|
||||
tracebackFrames.forEach((_, idx) => {
|
||||
newExpandedState[idx] = newState;
|
||||
});
|
||||
setExpandedFrames(newExpandedState);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse traceback into frames
|
||||
const parseTraceback = (traceback: string) => {
|
||||
if (!traceback) return [];
|
||||
|
||||
// Extract file paths, line numbers and code from traceback
|
||||
const fileLineRegex = /File "([^"]+)", line (\d+)/g;
|
||||
const matches = Array.from(traceback.matchAll(fileLineRegex));
|
||||
|
||||
// Create simplified frames
|
||||
return matches.map((match) => {
|
||||
const filePath = match[1];
|
||||
const lineNumber = match[2];
|
||||
const fileName = filePath.split("/").pop() || filePath;
|
||||
|
||||
// Extract the context around this frame
|
||||
const matchIndex = match.index || 0;
|
||||
const nextMatchIndex = traceback.indexOf('File "', matchIndex + 1);
|
||||
const frameContent =
|
||||
nextMatchIndex > -1
|
||||
? traceback.substring(matchIndex, nextMatchIndex).trim()
|
||||
: traceback.substring(matchIndex).trim();
|
||||
|
||||
// Try to extract the code line
|
||||
const lines = frameContent.split("\n");
|
||||
let code = "";
|
||||
if (lines.length > 1) {
|
||||
code = lines[lines.length - 1].trim();
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
fileName,
|
||||
lineNumber,
|
||||
code,
|
||||
inFunction: frameContent.includes(" in ") ? frameContent.split(" in ")[1].split("\n")[0] : "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const tracebackFrames = errorInfo.traceback ? parseTraceback(errorInfo.traceback) : [];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h3 className="text-lg font-medium flex items-center text-red-600">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
Error Details
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="bg-red-50 rounded-md p-4 mb-4">
|
||||
<div className="flex">
|
||||
<span className="text-red-800 font-medium w-20">Type:</span>
|
||||
<span className="text-red-700">{errorInfo.error_class || "Unknown Error"}</span>
|
||||
</div>
|
||||
<div className="flex mt-2">
|
||||
<span className="text-red-800 font-medium w-20 flex-shrink-0">Message:</span>
|
||||
<span className="text-red-700 break-words whitespace-pre-wrap">
|
||||
{errorInfo.error_message || "Unknown error occurred"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorInfo.traceback && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-medium">Traceback</h4>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={toggleAllFrames}
|
||||
className="text-gray-500 hover:text-gray-700 flex items-center text-sm"
|
||||
>
|
||||
{allExpanded ? "Collapse All" : "Expand All"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(errorInfo.traceback || "")}
|
||||
className="text-gray-500 hover:text-gray-700 flex items-center"
|
||||
title="Copy traceback"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span className="ml-1">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-md border border-gray-200 overflow-hidden shadow-sm">
|
||||
{tracebackFrames.map((frame, index) => (
|
||||
<div key={index} className="border-b border-gray-200 last:border-b-0">
|
||||
<div
|
||||
className="px-4 py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => toggleFrame(index)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-400 mr-2 w-12 text-right">{frame.lineNumber}</span>
|
||||
<span className="text-gray-600 font-medium">{frame.fileName}</span>
|
||||
<span className="text-gray-500 mx-1">in</span>
|
||||
<span className="text-indigo-600 font-medium">{frame.inFunction || frame.fileName}</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform ${expandedFrames[index] ? "transform rotate-180" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{(expandedFrames[index] || false) && frame.code && (
|
||||
<div className="px-12 py-2 font-mono text-sm text-gray-800 bg-gray-50 overflow-x-auto border-t border-gray-100">
|
||||
{frame.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { render, screen, act } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RequestResponsePanel } from "./RequestResponsePanel";
|
||||
import type { LogEntry } from "./columns";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
const mockNotificationsManager = vi.mocked(NotificationsManager);
|
||||
|
||||
const baseLogEntry: LogEntry = {
|
||||
request_id: "chatcmpl-test-id",
|
||||
api_key: "api-key",
|
||||
team_id: "team-id",
|
||||
model: "gpt-4",
|
||||
model_id: "gpt-4",
|
||||
call_type: "chat",
|
||||
spend: 0,
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
startTime: "2025-11-14T00:00:00Z",
|
||||
endTime: "2025-11-14T00:00:00Z",
|
||||
cache_hit: "miss",
|
||||
request_duration_ms: 1000,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
response: { status: "ok" },
|
||||
metadata: {
|
||||
status: "success",
|
||||
additional_usage_values: {
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
request_tags: {},
|
||||
custom_llm_provider: "openai",
|
||||
api_base: "https://api.example.com",
|
||||
};
|
||||
|
||||
describe("RequestResponsePanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the component with request and response panels", () => {
|
||||
const mockGetRawRequest = vi.fn().mockReturnValue({ test: "request" });
|
||||
const mockFormattedResponse = vi.fn().mockReturnValue({ test: "response" });
|
||||
|
||||
render(
|
||||
<RequestResponsePanel
|
||||
row={{ original: baseLogEntry }}
|
||||
hasMessages={true}
|
||||
hasResponse={true}
|
||||
hasError={false}
|
||||
errorInfo={null}
|
||||
getRawRequest={mockGetRawRequest}
|
||||
formattedResponse={mockFormattedResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Request")).toBeInTheDocument();
|
||||
expect(screen.getByText("Response")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should copy request to clipboard when copy button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockGetRawRequest = vi.fn().mockReturnValue({ test: "request data" });
|
||||
const mockFormattedResponse = vi.fn().mockReturnValue({ test: "response" });
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
if (navigator.clipboard) {
|
||||
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(mockWriteText);
|
||||
} else {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
render(
|
||||
<RequestResponsePanel
|
||||
row={{ original: baseLogEntry }}
|
||||
hasMessages={true}
|
||||
hasResponse={true}
|
||||
hasError={false}
|
||||
errorInfo={null}
|
||||
getRawRequest={mockGetRawRequest}
|
||||
formattedResponse={mockFormattedResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
const copyButtons = screen.getAllByRole("button");
|
||||
const copyRequestButton = copyButtons.find((button) => button.getAttribute("title") === "Copy request");
|
||||
|
||||
expect(copyRequestButton).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await user.click(copyRequestButton!);
|
||||
});
|
||||
|
||||
expect(mockGetRawRequest).toHaveBeenCalled();
|
||||
expect(mockWriteText).toHaveBeenCalledWith(JSON.stringify({ test: "request data" }, null, 2));
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Request copied to clipboard");
|
||||
});
|
||||
|
||||
it("should copy response to clipboard when copy button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockGetRawRequest = vi.fn().mockReturnValue({ test: "request" });
|
||||
const mockFormattedResponse = vi.fn().mockReturnValue({ test: "response data" });
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
if (navigator.clipboard) {
|
||||
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(mockWriteText);
|
||||
} else {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
render(
|
||||
<RequestResponsePanel
|
||||
row={{ original: baseLogEntry }}
|
||||
hasMessages={true}
|
||||
hasResponse={true}
|
||||
hasError={false}
|
||||
errorInfo={null}
|
||||
getRawRequest={mockGetRawRequest}
|
||||
formattedResponse={mockFormattedResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
const copyButtons = screen.getAllByRole("button");
|
||||
const copyResponseButton = copyButtons.find((button) => button.getAttribute("title") === "Copy response");
|
||||
|
||||
expect(copyResponseButton).toBeInTheDocument();
|
||||
expect(copyResponseButton).not.toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
await user.click(copyResponseButton!);
|
||||
});
|
||||
|
||||
expect(mockFormattedResponse).toHaveBeenCalled();
|
||||
expect(mockWriteText).toHaveBeenCalledWith(JSON.stringify({ test: "response data" }, null, 2));
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Response copied to clipboard");
|
||||
});
|
||||
|
||||
it("should call formattedResponse for the response panel and not getRawRequest", () => {
|
||||
const mockGetRawRequest = vi.fn().mockReturnValue({ requestData: "this should not appear in response" });
|
||||
const mockFormattedResponse = vi.fn().mockReturnValue({ responseData: "this should appear in response" });
|
||||
|
||||
render(
|
||||
<RequestResponsePanel
|
||||
row={{ original: baseLogEntry }}
|
||||
hasMessages={true}
|
||||
hasResponse={true}
|
||||
hasError={false}
|
||||
errorInfo={null}
|
||||
getRawRequest={mockGetRawRequest}
|
||||
formattedResponse={mockFormattedResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockFormattedResponse).toHaveBeenCalled();
|
||||
expect(mockGetRawRequest).toHaveBeenCalled();
|
||||
|
||||
const formattedResponseCallCount = mockFormattedResponse.mock.calls.length;
|
||||
expect(formattedResponseCallCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const responseData = mockFormattedResponse.mock.results[0].value;
|
||||
expect(responseData).toEqual({ responseData: "this should appear in response" });
|
||||
expect(responseData).not.toEqual({ requestData: "this should not appear in response" });
|
||||
});
|
||||
|
||||
it("should show error response data when hasError is true and hasResponse is false", () => {
|
||||
const failedLogEntry: LogEntry = {
|
||||
...baseLogEntry,
|
||||
messages: [],
|
||||
response: {},
|
||||
metadata: {
|
||||
status: "failure",
|
||||
error_information: {
|
||||
error_message: "Model not found",
|
||||
error_class: "NotFoundError",
|
||||
error_code: 404,
|
||||
},
|
||||
additional_usage_values: {
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
const errorResponse = { error: { message: "Model not found", type: "NotFoundError", code: 404, param: null } };
|
||||
const mockGetRawRequest = vi.fn().mockReturnValue({ messages: [] });
|
||||
const mockFormattedResponse = vi.fn().mockReturnValue(errorResponse);
|
||||
render(
|
||||
<RequestResponsePanel
|
||||
row={{ original: failedLogEntry }}
|
||||
hasMessages={false}
|
||||
hasResponse={false}
|
||||
hasError={true}
|
||||
errorInfo={failedLogEntry.metadata.error_information}
|
||||
getRawRequest={mockGetRawRequest}
|
||||
formattedResponse={mockFormattedResponse}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Response data not available")).not.toBeInTheDocument();
|
||||
expect(mockFormattedResponse).toHaveBeenCalled();
|
||||
const copyButtons = screen.getAllByRole("button");
|
||||
const copyResponseButton = copyButtons.find((button) => button.getAttribute("title") === "Copy response");
|
||||
expect(copyResponseButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show Response data not available when hasResponse and hasError are both false", () => {
|
||||
const mockGetRawRequest = vi.fn().mockReturnValue({ messages: [] });
|
||||
const mockFormattedResponse = vi.fn().mockReturnValue({});
|
||||
render(
|
||||
<RequestResponsePanel
|
||||
row={{ original: baseLogEntry }}
|
||||
hasMessages={false}
|
||||
hasResponse={false}
|
||||
hasError={false}
|
||||
errorInfo={null}
|
||||
getRawRequest={mockGetRawRequest}
|
||||
formattedResponse={mockFormattedResponse}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Response data not available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error code in response header when hasError is true", () => {
|
||||
const errorInfo = { error_message: "Rate limit exceeded", error_class: "RateLimitError", error_code: 429 };
|
||||
const mockGetRawRequest = vi.fn().mockReturnValue({ messages: [] });
|
||||
const mockFormattedResponse = vi
|
||||
.fn()
|
||||
.mockReturnValue({ error: { message: "Rate limit exceeded", type: "RateLimitError", code: 429, param: null } });
|
||||
render(
|
||||
<RequestResponsePanel
|
||||
row={{ original: baseLogEntry }}
|
||||
hasMessages={false}
|
||||
hasResponse={false}
|
||||
hasError={true}
|
||||
errorInfo={errorInfo}
|
||||
getRawRequest={mockGetRawRequest}
|
||||
formattedResponse={mockFormattedResponse}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/HTTP code 429/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { LogEntry } from "./columns";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { JsonView, defaultStyles } from "react-json-view-lite";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
|
||||
interface RequestResponsePanelProps {
|
||||
row: {
|
||||
original: LogEntry;
|
||||
};
|
||||
hasMessages: string | boolean;
|
||||
hasResponse: string | boolean;
|
||||
hasError: boolean;
|
||||
errorInfo: any;
|
||||
getRawRequest: () => any;
|
||||
formattedResponse: () => any;
|
||||
}
|
||||
|
||||
export function RequestResponsePanel({
|
||||
row,
|
||||
hasMessages,
|
||||
hasResponse,
|
||||
hasError,
|
||||
errorInfo,
|
||||
getRawRequest,
|
||||
formattedResponse,
|
||||
}: RequestResponsePanelProps) {
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// Fallback for non-secure contexts (like 0.0.0.0)
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (!successful) {
|
||||
throw new Error("execCommand failed");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Copy failed:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRequest = async () => {
|
||||
const success = await copyToClipboard(JSON.stringify(getRawRequest(), null, 2));
|
||||
if (success) {
|
||||
NotificationsManager.success("Request copied to clipboard");
|
||||
} else {
|
||||
NotificationsManager.fromBackend("Failed to copy request");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyResponse = async () => {
|
||||
const success = await copyToClipboard(JSON.stringify(formattedResponse(), null, 2));
|
||||
if (success) {
|
||||
NotificationsManager.success("Response copied to clipboard");
|
||||
} else {
|
||||
NotificationsManager.fromBackend("Failed to copy response");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full max-w-full overflow-hidden box-border">
|
||||
{/* Request Side */}
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Request</h3>
|
||||
<button onClick={handleCopyRequest} className="p-1 hover:bg-gray-200 rounded" title="Copy request">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-auto max-h-96 w-full max-w-full box-border">
|
||||
<div className="[&_[role='tree']]:bg-white [&_[role='tree']]:text-slate-900">
|
||||
<JsonView data={getRawRequest()} style={defaultStyles} clickToExpandNode={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Side */}
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">
|
||||
Response
|
||||
{hasError && <span className="ml-2 text-sm text-red-600">• HTTP code {errorInfo?.error_code || 400}</span>}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCopyResponse}
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
title="Copy response"
|
||||
disabled={!hasResponse && !hasError}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-auto max-h-96 w-full max-w-full box-border">
|
||||
{hasResponse || hasError ? (
|
||||
<div className="[&_[role='tree']]:bg-white [&_[role='tree']]:text-slate-900">
|
||||
<JsonView data={formattedResponse()} style={defaultStyles} clickToExpandNode />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm italic text-center py-4">Response data not available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue