| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613 |
- <!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>
|