import { useState, useEffect } from "react"; import { ChevronUp, ChevronDown, X, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; interface ApiLog { id: string; timestamp: Date; method: string; url: string; requestBody?: unknown; requestHeaders?: Record; status?: number; statusText?: string; responseBody?: unknown; responseHeaders?: Record; duration?: number; error?: string; } // Global store for API logs const apiLogs: ApiLog[] = []; const listeners: Set<() => void> = new Set(); function notifyListeners() { listeners.forEach((fn) => fn()); } export function addApiLog(log: ApiLog) { apiLogs.unshift(log); if (apiLogs.length > 50) apiLogs.pop(); notifyListeners(); } export function updateApiLog(id: string, updates: Partial) { const log = apiLogs.find((l) => l.id === id); if (log) { Object.assign(log, updates); notifyListeners(); } } function clearLogs() { apiLogs.length = 0; notifyListeners(); } // Intercept fetch (dev only) const isDev = import.meta.env.DEV; const originalFetch = window.fetch; // Avoid patching multiple times during HMR const globalAny = window as unknown as { __gatehouseFetchPatched?: boolean }; if (isDev && !globalAny.__gatehouseFetchPatched) { globalAny.__gatehouseFetchPatched = true; window.fetch = async function (input, init) { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; // Log calls that look like our backend API (support both absolute + relative base URLs) const shouldLog = url.includes("/api/") || url.includes("/api/v1") || url.includes("/auth/") || url.includes("/users/") || url.includes("/org/"); if (!shouldLog) { return originalFetch.apply(this, [input, init]); } const id = crypto.randomUUID(); const method = init?.method || "GET"; let requestBody: unknown; try { if (init?.body) { requestBody = JSON.parse(init.body as string); } } catch { requestBody = init?.body; } // Extract request headers const requestHeaders: Record = {}; if (init?.headers) { if (init.headers instanceof Headers) { init.headers.forEach((value, key) => { requestHeaders[key] = value; }); } else if (Array.isArray(init.headers)) { init.headers.forEach(([key, value]) => { requestHeaders[key] = value; }); } else { Object.entries(init.headers).forEach(([key, value]) => { if (value) requestHeaders[key] = value; }); } } addApiLog({ id, timestamp: new Date(), method, url, requestBody, requestHeaders, }); const start = performance.now(); try { const response = await originalFetch.apply(this, [input, init]); const duration = Math.round(performance.now() - start); // Extract response headers const responseHeaders: Record = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); // Clone response to read body const cloned = response.clone(); let responseBody: unknown; try { responseBody = await cloned.json(); } catch { responseBody = await cloned.text(); } updateApiLog(id, { status: response.status, statusText: response.statusText, responseBody, responseHeaders, duration, }); return response; } catch (err) { updateApiLog(id, { error: err instanceof Error ? err.message : "Unknown error", duration: Math.round(performance.now() - start), }); throw err; } }; } // Check if we're in development mode const isDev = import.meta.env.DEV; export default function ApiDevTools() { const [isOpen, setIsOpen] = useState(false); const [logs, setLogs] = useState([...apiLogs]); const [selectedLog, setSelectedLog] = useState(null); useEffect(() => { if (!isDev) return; const update = () => setLogs([...apiLogs]); listeners.add(update); return () => { listeners.delete(update); }; }, []); const getStatusColor = (status?: number) => { if (!status) return "secondary"; if (status >= 200 && status < 300) return "default"; if (status >= 400) return "destructive"; return "secondary"; }; // Don't render in production if (!isDev) { return null; } if (!isOpen) { return ( ); } return (
{/* Header */}
Gatehouse API DevTools {logs.length} requests
{/* Request list */} {logs.length === 0 ? (
No API requests yet
) : (
{logs.map((log) => ( ))}
)}
{/* Detail view */} {selectedLog ? (
{/* Status & URL Summary */}
{selectedLog.method} {selectedLog.status && ( = 400 ? 'bg-red-900/50 text-red-300' : ''}`} > {selectedLog.status} {selectedLog.statusText} )} {selectedLog.duration && ( {selectedLog.duration}ms )}
URL
{selectedLog.url}
{/* Network Error */} {selectedLog.error && (
Network Error
{selectedLog.error}
)} {/* Request Headers (collapsible) */} {selectedLog.requestHeaders && Object.keys(selectedLog.requestHeaders).length > 0 && (
Request Headers ({Object.keys(selectedLog.requestHeaders).length})
                    {JSON.stringify(selectedLog.requestHeaders, null, 2)}
                  
)} {selectedLog.requestBody && (
Request Body
                    {JSON.stringify(selectedLog.requestBody, null, 2)}
                  
)} {/* Response Section with Status Context */} {selectedLog.status && (
Response {selectedLog.status >= 400 && ( Error Response )}
{selectedLog.responseBody ? (
= 400 ? 'text-red-400 border border-red-800/50' : 'text-blue-400'
                    }`}>
                      {JSON.stringify(selectedLog.responseBody, null, 2)}
                    
) : (
No response body
)}
)} {/* Response Headers (collapsible) */} {selectedLog.responseHeaders && Object.keys(selectedLog.responseHeaders).length > 0 && (
Response Headers ({Object.keys(selectedLog.responseHeaders).length})
                    {JSON.stringify(selectedLog.responseHeaders, null, 2)}
                  
)}
) : (
Select a request to view details
)}
); }