|
|
@@ -0,0 +1,613 @@
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="en">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+ <title>Harshark - Parv's HAR Parser</title>
|
|
|
+ <link rel="icon" type="image/png" href="./favicon.png" />
|
|
|
+
|
|
|
+ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
|
+ <meta name="theme-color" content="#3b82f6">
|
|
|
+ <meta name="description" content="Simple, Online HTTP Archive (HAR) parser">
|
|
|
+
|
|
|
+ <!-- PWA Manifest Placeholder (Data URI) -->
|
|
|
+ <link rel="manifest" id="manifest-placeholder">
|
|
|
+
|
|
|
+ <style>
|
|
|
+ body {
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
|
+ }
|
|
|
+ .drag-active {
|
|
|
+ border-color: #3b82f6;
|
|
|
+ background-color: #eff6ff;
|
|
|
+ }
|
|
|
+ /* Custom scrollbar for details panel */
|
|
|
+ .details-panel::-webkit-scrollbar {
|
|
|
+ width: 8px;
|
|
|
+ }
|
|
|
+ .details-panel::-webkit-scrollbar-track {
|
|
|
+ background: #f1f1f1;
|
|
|
+ }
|
|
|
+ .details-panel::-webkit-scrollbar-thumb {
|
|
|
+ background: #cbd5e1;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+ .details-panel::-webkit-scrollbar-thumb:hover {
|
|
|
+ background: #94a3b8;
|
|
|
+ }
|
|
|
+ .truncate-text {
|
|
|
+ max-width: 200px;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body class="bg-gray-50 h-screen flex flex-col overflow-hidden">
|
|
|
+
|
|
|
+ <!-- Header -->
|
|
|
+ <header class="bg-blue-600 text-white shadow-md z-10">
|
|
|
+ <div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
|
|
+ <div class="flex items-center space-x-2">
|
|
|
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
|
+ </svg>
|
|
|
+ <h1 class="text-xl font-bold">Harshark</h1>
|
|
|
+ </div>
|
|
|
+ <div class="flex items-center space-x-4">
|
|
|
+ <button id="installBtn" class="px-3 py-1 bg-white text-blue-600 hover:bg-blue-50 rounded text-sm font-bold hidden shadow-sm transition-colors flex items-center gap-1">
|
|
|
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
|
+ </svg>
|
|
|
+ Install
|
|
|
+ </button>
|
|
|
+ <button id="resetBtn" class="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm hidden">Reset</button>
|
|
|
+ <div class="text-xs opacity-75">Build By Parv Ashwani</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <!-- Main Content -->
|
|
|
+ <main class="flex-1 flex overflow-hidden relative">
|
|
|
+
|
|
|
+ <!-- Dropzone (Initial State) -->
|
|
|
+ <div id="dropzone" class="absolute inset-0 flex flex-col items-center justify-center bg-white z-20 transition-all duration-300">
|
|
|
+ <div class="border-4 border-dashed border-gray-300 rounded-xl p-12 text-center max-w-lg mx-4 hover:border-blue-400 transition-colors cursor-pointer" id="dropzone-border">
|
|
|
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
|
+ </svg>
|
|
|
+ <h2 class="text-2xl font-bold text-gray-700 mb-2">Drop HAR or XML file here</h2>
|
|
|
+ <p class="text-gray-500 mb-6">or click to browse</p>
|
|
|
+ <input type="file" id="fileInput" class="hidden" accept=".har,.json,.xml">
|
|
|
+ <button class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition">Select File</button>
|
|
|
+ <p class="mt-4 text-xs text-gray-400">Supports Chrome, Firefox, Safari, Edge, and legacy IE XML logs</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Split View (Hidden initially) -->
|
|
|
+ <div id="app-view" class="w-full h-full flex hidden">
|
|
|
+
|
|
|
+ <!-- List View -->
|
|
|
+ <div class="flex-1 flex flex-col min-w-0 border-r border-gray-200 bg-white">
|
|
|
+ <!-- Search/Filter Bar -->
|
|
|
+ <div class="p-2 border-b border-gray-200 flex space-x-2 bg-gray-50">
|
|
|
+ <input type="text" id="searchInput" placeholder="Filter by URL..." class="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:border-blue-500">
|
|
|
+ <select id="methodFilter" class="px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:border-blue-500 bg-white">
|
|
|
+ <option value="ALL">All Methods</option>
|
|
|
+ <option value="GET">GET</option>
|
|
|
+ <option value="POST">POST</option>
|
|
|
+ <option value="PUT">PUT</option>
|
|
|
+ <option value="DELETE">DELETE</option>
|
|
|
+ <option value="OPTIONS">OPTIONS</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Table Header -->
|
|
|
+ <div class="grid grid-cols-12 bg-gray-100 border-b border-gray-200 text-xs font-semibold text-gray-600 py-2 px-4">
|
|
|
+ <div class="col-span-1">Status</div>
|
|
|
+ <div class="col-span-1">Method</div>
|
|
|
+ <div class="col-span-6">URL</div>
|
|
|
+ <div class="col-span-2 text-right">Size</div>
|
|
|
+ <div class="col-span-2 text-right">Time</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Table Body -->
|
|
|
+ <div id="entriesList" class="flex-1 overflow-y-auto divide-y divide-gray-100">
|
|
|
+ <!-- Entries will be injected here -->
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Footer Status -->
|
|
|
+ <div class="p-2 border-t border-gray-200 text-xs text-gray-500 bg-gray-50 flex justify-between">
|
|
|
+ <span id="entryCount">0 entries</span>
|
|
|
+ <span id="totalSize">0 KB</span>
|
|
|
+ <span id="totalTime">0 ms</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Detail View -->
|
|
|
+ <div id="detailsPanel" class="w-1/3 bg-gray-50 border-l border-gray-200 flex flex-col hidden details-panel overflow-y-auto shadow-xl z-30">
|
|
|
+ <div class="sticky top-0 bg-white border-b border-gray-200 p-4 flex justify-between items-start shadow-sm">
|
|
|
+ <h3 class="font-bold text-gray-800 text-lg">Details</h3>
|
|
|
+ <button id="closeDetails" class="text-gray-400 hover:text-gray-600">
|
|
|
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="detailsContent" class="p-4 space-y-6">
|
|
|
+ <div class="text-center text-gray-500 mt-10">Select an entry to view details</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+
|
|
|
+ <!-- XML Parser Helper (Invisible) -->
|
|
|
+ <div id="xml-helper" style="display:none;"></div>
|
|
|
+
|
|
|
+ <script>
|
|
|
+ // --- PWA Setup via Data URI ---
|
|
|
+ const manifest = {
|
|
|
+ "name": "Harshark",
|
|
|
+ "short_name": "Harshark",
|
|
|
+ "start_url": ".",
|
|
|
+ "display": "standalone",
|
|
|
+ "background_color": "#ffffff",
|
|
|
+ "theme_color": "#3b82f6",
|
|
|
+ "icons": [
|
|
|
+ {
|
|
|
+ "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%233b82f6'%3E%3Cpath d='M13 10V3L4 14h7v7l9-11h-7z'/%3E%3C/svg%3E",
|
|
|
+ "sizes": "192x192",
|
|
|
+ "type": "image/svg+xml"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ const manifestBlob = new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json'});
|
|
|
+ document.getElementById('manifest-placeholder').href = URL.createObjectURL(manifestBlob);
|
|
|
+
|
|
|
+ // --- Application State ---
|
|
|
+ let entries = [];
|
|
|
+ let filteredEntries = [];
|
|
|
+ let isXmlMode = false;
|
|
|
+
|
|
|
+ // --- DOM Elements ---
|
|
|
+ const dropzone = document.getElementById('dropzone');
|
|
|
+ const dropzoneBorder = document.getElementById('dropzone-border');
|
|
|
+ const fileInput = document.getElementById('fileInput');
|
|
|
+ const appView = document.getElementById('app-view');
|
|
|
+ const entriesList = document.getElementById('entriesList');
|
|
|
+ const detailsPanel = document.getElementById('detailsPanel');
|
|
|
+ const detailsContent = document.getElementById('detailsContent');
|
|
|
+ const closeDetailsBtn = document.getElementById('closeDetails');
|
|
|
+ const searchInput = document.getElementById('searchInput');
|
|
|
+ const methodFilter = document.getElementById('methodFilter');
|
|
|
+ const resetBtn = document.getElementById('resetBtn');
|
|
|
+ const entryCountEl = document.getElementById('entryCount');
|
|
|
+ const totalSizeEl = document.getElementById('totalSize');
|
|
|
+ const totalTimeEl = document.getElementById('totalTime');
|
|
|
+ const installBtn = document.getElementById('installBtn');
|
|
|
+
|
|
|
+ // --- PWA Install Logic ---
|
|
|
+ let deferredPrompt;
|
|
|
+
|
|
|
+ window.addEventListener('beforeinstallprompt', (e) => {
|
|
|
+ // Prevent Chrome 67 and earlier from automatically showing the prompt
|
|
|
+ e.preventDefault();
|
|
|
+ // Stash the event so it can be triggered later.
|
|
|
+ deferredPrompt = e;
|
|
|
+ // Update UI to notify the user they can add to home screen
|
|
|
+ installBtn.classList.remove('hidden');
|
|
|
+ });
|
|
|
+
|
|
|
+ installBtn.addEventListener('click', (e) => {
|
|
|
+ // Hide our user interface that shows our A2HS button
|
|
|
+ installBtn.classList.add('hidden');
|
|
|
+ // Show the prompt
|
|
|
+ deferredPrompt.prompt();
|
|
|
+ // Wait for the user to respond to the prompt
|
|
|
+ deferredPrompt.userChoice.then((choiceResult) => {
|
|
|
+ if (choiceResult.outcome === 'accepted') {
|
|
|
+ console.log('User accepted the A2HS prompt');
|
|
|
+ } else {
|
|
|
+ console.log('User dismissed the A2HS prompt');
|
|
|
+ }
|
|
|
+ deferredPrompt = null;
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ window.addEventListener('appinstalled', () => {
|
|
|
+ // Hide the app-provided install promotion
|
|
|
+ installBtn.classList.add('hidden');
|
|
|
+ deferredPrompt = null;
|
|
|
+ console.log('PWA was installed');
|
|
|
+ });
|
|
|
+
|
|
|
+ // --- Event Listeners ---
|
|
|
+
|
|
|
+ // Drag & Drop
|
|
|
+ dropzone.addEventListener('click', () => fileInput.click());
|
|
|
+ dropzone.addEventListener('dragover', (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ dropzoneBorder.classList.add('drag-active');
|
|
|
+ });
|
|
|
+ dropzone.addEventListener('dragleave', () => {
|
|
|
+ dropzoneBorder.classList.remove('drag-active');
|
|
|
+ });
|
|
|
+ dropzone.addEventListener('drop', (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ dropzoneBorder.classList.remove('drag-active');
|
|
|
+ if (e.dataTransfer.files.length) {
|
|
|
+ handleFile(e.dataTransfer.files[0]);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ fileInput.addEventListener('change', (e) => {
|
|
|
+ if (e.target.files.length) {
|
|
|
+ handleFile(e.target.files[0]);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ resetBtn.addEventListener('click', () => {
|
|
|
+ entries = [];
|
|
|
+ filteredEntries = [];
|
|
|
+ renderEntries();
|
|
|
+ dropzone.classList.remove('hidden');
|
|
|
+ appView.classList.add('hidden');
|
|
|
+ resetBtn.classList.add('hidden');
|
|
|
+ detailsPanel.classList.add('hidden');
|
|
|
+ });
|
|
|
+
|
|
|
+ closeDetailsBtn.addEventListener('click', () => {
|
|
|
+ detailsPanel.classList.add('hidden');
|
|
|
+ });
|
|
|
+
|
|
|
+ searchInput.addEventListener('input', filterEntries);
|
|
|
+ methodFilter.addEventListener('change', filterEntries);
|
|
|
+
|
|
|
+ // --- File Handling Logic ---
|
|
|
+
|
|
|
+ function handleFile(file) {
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onload = (e) => {
|
|
|
+ const content = e.target.result;
|
|
|
+ try {
|
|
|
+ // Try JSON first
|
|
|
+ const json = JSON.parse(content);
|
|
|
+ if (json.log && json.log.entries) {
|
|
|
+ isXmlMode = false;
|
|
|
+ entries = json.log.entries.map((entry, index) => ({
|
|
|
+ id: index,
|
|
|
+ method: entry.request.method,
|
|
|
+ url: entry.request.url,
|
|
|
+ status: entry.response.status,
|
|
|
+ statusText: entry.response.statusText,
|
|
|
+ mimeType: entry.response.content.mimeType,
|
|
|
+ size: entry.response.content.size,
|
|
|
+ time: entry.time,
|
|
|
+ request: entry.request,
|
|
|
+ response: entry.response,
|
|
|
+ startedDateTime: entry.startedDateTime
|
|
|
+ }));
|
|
|
+ showApp();
|
|
|
+ } else {
|
|
|
+ // Not a standard HAR, maybe different JSON structure?
|
|
|
+ throw new Error("Invalid HAR structure");
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ // If JSON parsing fails, try XML
|
|
|
+ try {
|
|
|
+ const parser = new DOMParser();
|
|
|
+ const xmlDoc = parser.parseFromString(content, "text/xml");
|
|
|
+ if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
|
|
|
+ throw new Error("Invalid XML");
|
|
|
+ }
|
|
|
+ parseXML(xmlDoc);
|
|
|
+ showApp();
|
|
|
+ } catch (xmlErr) {
|
|
|
+ alert("Could not parse file. It must be a valid HAR (JSON) or supported XML network log.");
|
|
|
+ console.error(xmlErr);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ reader.readAsText(file);
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseXML(xmlDoc) {
|
|
|
+ // Basic XML parsing logic trying to find common network log patterns
|
|
|
+ // IE Developer Tools Network Data often exports as XML
|
|
|
+ // We look for common tags
|
|
|
+ isXmlMode = true;
|
|
|
+ entries = [];
|
|
|
+
|
|
|
+ // Try to find list of requests
|
|
|
+ // Common structure in some IE exports: <xml><entry>...</entry></xml> or similar
|
|
|
+ // This is a heuristic approach
|
|
|
+ const xmlEntries = xmlDoc.querySelectorAll("entry, request, item, log > *");
|
|
|
+
|
|
|
+ if (xmlEntries.length === 0) {
|
|
|
+ // Fallback: just dump the top level children
|
|
|
+ const children = xmlDoc.documentElement.children;
|
|
|
+ for (let i = 0; i < children.length; i++) {
|
|
|
+ processXmlNode(children[i], i);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ xmlEntries.forEach((node, index) => {
|
|
|
+ processXmlNode(node, index);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (entries.length === 0) {
|
|
|
+ alert("XML parsed but no recognizable entries found.");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function processXmlNode(node, index) {
|
|
|
+ // Attempt to extract method, url, status from attributes or child tags
|
|
|
+ const url = node.getAttribute("url") || node.querySelector("url")?.textContent || "Unknown URL";
|
|
|
+ const method = node.getAttribute("method") || node.querySelector("method")?.textContent || "GET"; // Default to GET if missing
|
|
|
+ const status = node.getAttribute("status") || node.querySelector("status")?.textContent || "0";
|
|
|
+ const size = node.getAttribute("size") || node.querySelector("size")?.textContent || "0";
|
|
|
+ const time = node.getAttribute("time") || node.querySelector("time")?.textContent || "0";
|
|
|
+
|
|
|
+ // Store raw node for details
|
|
|
+ entries.push({
|
|
|
+ id: index,
|
|
|
+ method: method,
|
|
|
+ url: url,
|
|
|
+ status: parseInt(status),
|
|
|
+ statusText: "",
|
|
|
+ size: parseInt(size),
|
|
|
+ time: parseFloat(time),
|
|
|
+ rawXml: new XMLSerializer().serializeToString(node),
|
|
|
+ originalNode: node
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function showApp() {
|
|
|
+ dropzone.classList.add('hidden');
|
|
|
+ appView.classList.remove('hidden');
|
|
|
+ resetBtn.classList.remove('hidden');
|
|
|
+ filterEntries();
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Rendering Logic ---
|
|
|
+
|
|
|
+ function filterEntries() {
|
|
|
+ const term = searchInput.value.toLowerCase();
|
|
|
+ const method = methodFilter.value;
|
|
|
+
|
|
|
+ filteredEntries = entries.filter(e => {
|
|
|
+ const matchesTerm = e.url.toLowerCase().includes(term);
|
|
|
+ const matchesMethod = method === "ALL" || e.method === method;
|
|
|
+ return matchesTerm && matchesMethod;
|
|
|
+ });
|
|
|
+
|
|
|
+ renderEntries();
|
|
|
+ updateStats();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderEntries() {
|
|
|
+ entriesList.innerHTML = '';
|
|
|
+ filteredEntries.forEach(entry => {
|
|
|
+ const row = document.createElement('div');
|
|
|
+ row.className = 'grid grid-cols-12 border-b border-gray-100 py-2 px-4 hover:bg-blue-50 cursor-pointer items-center text-sm';
|
|
|
+ row.onclick = () => showDetails(entry);
|
|
|
+
|
|
|
+ // Status Color
|
|
|
+ let statusColor = 'text-gray-600';
|
|
|
+ if (entry.status >= 200 && entry.status < 300) statusColor = 'text-green-600';
|
|
|
+ else if (entry.status >= 300 && entry.status < 400) statusColor = 'text-yellow-600';
|
|
|
+ else if (entry.status >= 400) statusColor = 'text-red-600';
|
|
|
+
|
|
|
+ // Format Size
|
|
|
+ const sizeStr = formatBytes(entry.size);
|
|
|
+
|
|
|
+ // Format Time
|
|
|
+ const timeStr = Math.round(entry.time) + ' ms';
|
|
|
+
|
|
|
+ row.innerHTML = `
|
|
|
+ <div class="col-span-1 ${statusColor} font-mono">${entry.status || '-'}</div>
|
|
|
+ <div class="col-span-1 font-bold text-gray-700 text-xs">${entry.method}</div>
|
|
|
+ <div class="col-span-6 truncate-text text-gray-800" title="${entry.url}">${entry.url}</div>
|
|
|
+ <div class="col-span-2 text-right text-gray-500 text-xs">${sizeStr}</div>
|
|
|
+ <div class="col-span-2 text-right text-gray-500 text-xs">${timeStr}</div>
|
|
|
+ `;
|
|
|
+ entriesList.appendChild(row);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function showDetails(entry) {
|
|
|
+ detailsPanel.classList.remove('hidden');
|
|
|
+
|
|
|
+ if (isXmlMode) {
|
|
|
+ renderXmlDetails(entry);
|
|
|
+ } else {
|
|
|
+ renderHarDetails(entry);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderHarDetails(entry) {
|
|
|
+ // General Info
|
|
|
+ let html = `
|
|
|
+ <div class="bg-white p-3 rounded shadow-sm border border-gray-100">
|
|
|
+ <div class="text-xs font-bold text-gray-500 uppercase mb-1">General</div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Request URL:</span> <span class="break-all text-sm">${entry.url}</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Request Method:</span> <span class="text-sm">${entry.method}</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Status Code:</span> <span class="text-sm">${entry.status} ${entry.statusText}</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Time:</span> <span class="text-sm">${entry.time.toFixed(2)} ms</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Started At:</span> <span class="text-sm">${new Date(entry.startedDateTime).toLocaleString()}</span></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ // Request Headers
|
|
|
+ html += renderHeadersSection("Request Headers", entry.request.headers);
|
|
|
+
|
|
|
+ // Response Headers
|
|
|
+ html += renderHeadersSection("Response Headers", entry.response.headers);
|
|
|
+
|
|
|
+ // Request Content (if any)
|
|
|
+ if (entry.request.postData && entry.request.postData.text) {
|
|
|
+ const contentText = entry.request.postData.text;
|
|
|
+ html += createContentSection("Request Body", contentText, entry.request.postData.mimeType);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Response Content
|
|
|
+ if (entry.response.content && entry.response.content.text) {
|
|
|
+ const contentText = entry.response.content.text;
|
|
|
+ html += createContentSection(`Response Body (${formatBytes(entry.response.content.size)})`, contentText, entry.response.content.mimeType);
|
|
|
+ } else if (entry.response.content && entry.response.content.size > 0) {
|
|
|
+ html += createContentSection(`Response Body (${formatBytes(entry.response.content.size)})`, "(Binary data or content not available)", entry.response.content.mimeType);
|
|
|
+ }
|
|
|
+
|
|
|
+ detailsContent.innerHTML = html;
|
|
|
+
|
|
|
+ // Re-attach event listeners for copy buttons if any
|
|
|
+ document.querySelectorAll('.copy-btn').forEach(btn => {
|
|
|
+ btn.addEventListener('click', (e) => {
|
|
|
+ const text = decodeURIComponent(e.target.dataset.content);
|
|
|
+ navigator.clipboard.writeText(text).then(() => {
|
|
|
+ const originalText = e.target.textContent;
|
|
|
+ e.target.textContent = 'Copied!';
|
|
|
+ setTimeout(() => e.target.textContent = originalText, 1500);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function createContentSection(title, content, mimeType) {
|
|
|
+ let displayContent = escapeHtml(content);
|
|
|
+ let isJson = false;
|
|
|
+
|
|
|
+ // Try to pretty print JSON
|
|
|
+ if (mimeType && (mimeType.includes('json') || mimeType.includes('javascript'))) {
|
|
|
+ try {
|
|
|
+ const obj = JSON.parse(content);
|
|
|
+ displayContent = escapeHtml(JSON.stringify(obj, null, 2));
|
|
|
+ isJson = true;
|
|
|
+ } catch(e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ return `
|
|
|
+ <div class="bg-white p-3 rounded shadow-sm border border-gray-100 mt-4 group">
|
|
|
+ <div class="flex justify-between items-center mb-2">
|
|
|
+ <div class="text-xs font-bold text-gray-500 uppercase">${title}</div>
|
|
|
+ <button class="copy-btn text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded hover:bg-blue-100 transition opacity-0 group-hover:opacity-100" data-content="${encodeURIComponent(content)}">Copy</button>
|
|
|
+ </div>
|
|
|
+ ${mimeType ? `<div class="text-xs text-gray-500 mb-2">Mime-Type: ${mimeType}</div>` : ''}
|
|
|
+ <div class="bg-gray-50 p-2 rounded text-xs font-mono overflow-auto max-h-60 whitespace-pre-wrap break-all ${isJson ? 'text-green-700' : ''}">${displayContent}</div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderXmlDetails(entry) {
|
|
|
+ // Pretty print XML
|
|
|
+ let formattedXml = entry.rawXml;
|
|
|
+ try {
|
|
|
+ // Simple indentation for XML
|
|
|
+ const PADDING = ' ';
|
|
|
+ const reg = /(>)(<)(\/*)/g;
|
|
|
+ let pad = 0;
|
|
|
+ formattedXml = formattedXml.replace(reg, '$1\r\n$2$3');
|
|
|
+ formattedXml = formattedXml.split('\r\n').map(node => {
|
|
|
+ let indent = 0;
|
|
|
+ if (node.match(/.+<\/\w[^>]*>$/)) {
|
|
|
+ indent = 0;
|
|
|
+ } else if (node.match(/^<\/\w/)) {
|
|
|
+ if (pad !== 0) pad -= 1;
|
|
|
+ } else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
|
|
|
+ indent = 1;
|
|
|
+ } else {
|
|
|
+ indent = 0;
|
|
|
+ }
|
|
|
+ const padding = new Array(pad + 1).join(PADDING);
|
|
|
+ if (indent > 0) pad += 1;
|
|
|
+ return padding + node;
|
|
|
+ }).join('\r\n');
|
|
|
+ } catch(e) {
|
|
|
+ // fallback to raw if formatting fails
|
|
|
+ formattedXml = entry.rawXml;
|
|
|
+ }
|
|
|
+
|
|
|
+ detailsContent.innerHTML = `
|
|
|
+ <div class="bg-white p-3 rounded shadow-sm border border-gray-100">
|
|
|
+ <div class="text-xs font-bold text-gray-500 uppercase mb-1">General Info</div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">URL:</span> <span class="break-all text-sm">${entry.url}</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Method:</span> <span class="text-sm">${entry.method}</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Status:</span> <span class="text-sm">${entry.status}</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Size:</span> <span class="text-sm">${formatBytes(entry.size)}</span></div>
|
|
|
+ <div class="mb-1"><span class="font-semibold">Time:</span> <span class="text-sm">${entry.time} ms</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="bg-white p-3 rounded shadow-sm border border-gray-100 mt-4 group">
|
|
|
+ <div class="flex justify-between items-center mb-2">
|
|
|
+ <div class="text-xs font-bold text-gray-500 uppercase">Raw XML Element</div>
|
|
|
+ <button class="copy-btn text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded hover:bg-blue-100 transition opacity-0 group-hover:opacity-100" data-content="${encodeURIComponent(formattedXml)}">Copy</button>
|
|
|
+ </div>
|
|
|
+ <pre class="bg-gray-50 p-2 rounded text-xs font-mono overflow-auto max-h-96 whitespace-pre-wrap text-blue-900">${escapeHtml(formattedXml)}</pre>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ document.querySelectorAll('.copy-btn').forEach(btn => {
|
|
|
+ btn.addEventListener('click', (e) => {
|
|
|
+ const text = decodeURIComponent(e.target.dataset.content);
|
|
|
+ navigator.clipboard.writeText(text).then(() => {
|
|
|
+ const originalText = e.target.textContent;
|
|
|
+ e.target.textContent = 'Copied!';
|
|
|
+ setTimeout(() => e.target.textContent = originalText, 1500);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderHeadersSection(title, headers) {
|
|
|
+ if (!headers || headers.length === 0) return '';
|
|
|
+
|
|
|
+ let items = headers.map(h => `
|
|
|
+ <div class="grid grid-cols-3 gap-2 py-1 border-b border-gray-50 last:border-0">
|
|
|
+ <div class="col-span-1 font-semibold text-gray-600 truncate text-xs" title="${h.name}">${h.name}</div>
|
|
|
+ <div class="col-span-2 text-gray-800 break-all text-xs">${escapeHtml(h.value)}</div>
|
|
|
+ </div>
|
|
|
+ `).join('');
|
|
|
+
|
|
|
+ return `
|
|
|
+ <div class="bg-white p-3 rounded shadow-sm border border-gray-100 mt-4">
|
|
|
+ <div class="text-xs font-bold text-gray-500 uppercase mb-2">${title}</div>
|
|
|
+ ${items}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Helpers ---
|
|
|
+
|
|
|
+ function updateStats() {
|
|
|
+ entryCountEl.textContent = `${filteredEntries.length} entries`;
|
|
|
+
|
|
|
+ let size = filteredEntries.reduce((acc, curr) => acc + (curr.size || 0), 0);
|
|
|
+ totalSizeEl.textContent = formatBytes(size);
|
|
|
+
|
|
|
+ let time = filteredEntries.reduce((acc, curr) => acc + (curr.time || 0), 0);
|
|
|
+ totalTimeEl.textContent = Math.round(time) + ' ms';
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatBytes(bytes, decimals = 2) {
|
|
|
+ if (!+bytes) return '0 Bytes';
|
|
|
+ const k = 1024;
|
|
|
+ const dm = decimals < 0 ? 0 : decimals;
|
|
|
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
|
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function escapeHtml(text) {
|
|
|
+ if (!text) return '';
|
|
|
+ return text
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">")
|
|
|
+ .replace(/"/g, """)
|
|
|
+ .replace(/'/g, "'");
|
|
|
+ }
|
|
|
+
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>
|