diff --git a/src/App.tsx b/src/App.tsx index 1aa0198..8017e78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import OrgAuditPage from "@/pages/org/OrgAuditPage"; import OIDCClientsPage from "@/pages/org/OIDCClientsPage"; import NotFound from "@/pages/NotFound"; +import ApiDevTools from "@/components/dev/ApiDevTools"; const queryClient = new QueryClient(); @@ -89,6 +90,9 @@ function AppRoutes() { {/* Catch-all */} } /> + + {/* Dev tools - only shown in development */} + ); } diff --git a/src/components/dev/ApiDevTools.tsx b/src/components/dev/ApiDevTools.tsx new file mode 100644 index 0000000..b17715f --- /dev/null +++ b/src/components/dev/ApiDevTools.tsx @@ -0,0 +1,267 @@ +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; + status?: number; + responseBody?: unknown; + 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 +const originalFetch = window.fetch; +window.fetch = async function (input, init) { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + + // Only log API calls + if (!url.includes("/api/")) { + 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; + } + + addApiLog({ + id, + timestamp: new Date(), + method, + url, + requestBody, + }); + + const start = performance.now(); + + try { + const response = await originalFetch.apply(this, [input, init]); + const duration = Math.round(performance.now() - start); + + // 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, + responseBody, + duration, + }); + + return response; + } catch (err) { + updateApiLog(id, { + error: err instanceof Error ? err.message : "Unknown error", + duration: Math.round(performance.now() - start), + }); + throw err; + } +}; + +export default function ApiDevTools() { + const [isOpen, setIsOpen] = useState(false); + const [logs, setLogs] = useState([...apiLogs]); + const [selectedLog, setSelectedLog] = useState(null); + + useEffect(() => { + 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"; + }; + + if (!isOpen) { + return ( + setIsOpen(true)} + className="fixed bottom-4 right-4 z-50 bg-slate-900 text-slate-100 px-3 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm font-mono hover:bg-slate-800 transition-colors" + > + + API DevTools + {logs.length > 0 && ( + + {logs.length} + + )} + + ); + } + + return ( + + {/* Header */} + + + Gatehouse API DevTools + + {logs.length} requests + + + + + + + setIsOpen(false)} + className="text-slate-400 hover:text-slate-100 hover:bg-slate-700" + > + + + + + + + {/* Request list */} + + {logs.length === 0 ? ( + + No API requests yet + + ) : ( + + {logs.map((log) => ( + setSelectedLog(log)} + className={`w-full text-left px-3 py-2 hover:bg-slate-800 transition-colors ${ + selectedLog?.id === log.id ? "bg-slate-800" : "" + }`} + > + + + {log.method} + + + {log.url.replace("/api/v1", "")} + + {log.status && ( + + {log.status} + + )} + {log.duration && ( + + {log.duration}ms + + )} + + + {log.timestamp.toLocaleTimeString()} + + + ))} + + )} + + + {/* Detail view */} + + {selectedLog ? ( + + + URL + {selectedLog.url} + + + {selectedLog.requestBody && ( + + Request Body + + {JSON.stringify(selectedLog.requestBody, null, 2)} + + + )} + + {selectedLog.responseBody && ( + + Response + + {JSON.stringify(selectedLog.responseBody, null, 2)} + + + )} + + {selectedLog.error && ( + + Error + {selectedLog.error} + + )} + + ) : ( + + Select a request to view details + + )} + + + + ); +}
+ {JSON.stringify(selectedLog.requestBody, null, 2)} +
+ {JSON.stringify(selectedLog.responseBody, null, 2)} +