source/test/debug_viewer.html
2025-12-08 19:27:48 +01:00

1555 lines
No EOL
85 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Debug Log Viewer</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Use Tailwind utility classes; minimal custom CSS kept only for small utility used by JS-rendered HTML -->
<style>
/* ensure full-viewport layout so inner scrolling works reliably */
html, body { height: 100%; }
/* small utilities retained */
.copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; opacity: 0; transition: opacity 0.15s; }
.copyable:hover .copy-btn { opacity: 1; }
.log-entry-raw { white-space: pre-wrap; }
/* resizer handle */
.resizer { width: 8px; cursor: col-resize; background: transparent; }
.resizer:hover { background: rgba(255,255,255,0.03); }
</style>
</head>
<body class="bg-base-300">
<div class="flex h-screen overflow-hidden">
<!-- Main Content -->
<div class="flex-1 p-4 min-w-0 flex flex-col h-full">
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
<i data-lucide="search" class="w-6 h-6"></i>
Advanced Debug Log Viewer
<span id="liveIndicator" class="badge badge-success badge-sm hidden">Live</span>
</h1>
<p class="text-sm opacity-60 mt-1">Loaded from: <code id="dataSource">No data loaded</code></p>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" id="stepNavigation" style="display:none;">
<button onclick="prevStep()" class="btn btn-sm btn-ghost" title="Previous Step"><i data-lucide="chevron-left" class="w-4 h-4"></i></button>
<span id="stepIndicator" class="text-sm">Step 1 of 1</span>
<button onclick="nextStep()" class="btn btn-sm btn-ghost" title="Next Step"><i data-lucide="chevron-right" class="w-4 h-4"></i></button>
</div>
<button onclick="loadFromFile()" class="btn btn-primary btn-sm flex items-center gap-2">
<i data-lucide="folder" class="w-4 h-4"></i>
<span>Load JSON</span>
</button>
<button onclick="exportFiltered()" class="btn btn-ghost btn-sm flex items-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i>
<span>Export Filtered</span>
</button>
<button onclick="toggleSidebar()" class="btn btn-ghost btn-sm flex items-center gap-2">
<i data-lucide="columns" class="w-4 h-4"></i>
<span>Toggle Sidebar</span>
</button>
<button onclick="copyPermalink()" class="btn btn-ghost btn-sm flex items-center gap-2" title="Copy permalink with current filters">
<i data-lucide="link" class="w-4 h-4"></i>
<span>Permalink</span>
</button>
</div>
</div>
<!-- Returned Data Panel (replaces Quick Stats) -->
<div id="returnedDataPanel" class="mb-4 bg-base-100 p-3 rounded shadow">
<div class="flex items-center justify-between mb-2">
<div class="font-medium">Returned Data</div>
<div class="flex gap-2 items-center">
<div class="btn-group btn-group-xs mr-2" role="tablist" aria-label="Returned data view toggle">
<button id="viewJsonBtn" class="btn btn-xs">JSON</button>
<button id="viewVisualBtn" class="btn btn-xs btn-active">Visual</button>
</div>
<button id="toggleReturnedDataBtn" class="btn btn-xs btn-ghost" title="Collapse Returned Data"><i data-lucide="chevron-up" class="w-4 h-4"></i></button>
<button id="copyReturnedDataBtn" class="btn btn-xs btn-ghost">Copy</button>
<button id="downloadReturnedDataBtn" class="btn btn-xs btn-ghost">Download</button>
</div>
</div>
<div id="returnedDataSummary" class="text-sm opacity-70 mb-2">No returned data loaded</div>
<div id="returnedDataJson" class="prose max-w-full overflow-auto bg-base-200 p-2 rounded text-xs log-entry-raw" style="max-height:220px; display:none;">{}</div>
<div id="returnedDataVisual" class="max-w-full overflow-auto rounded p-2" style="display:block; max-height:320px;"></div>
</div>
<!-- Filters Panel -->
<div class="collapse collapse-arrow bg-base-100 mb-4">
<input type="checkbox" checked aria-label="toggle-filters" />
<div class="collapse-title font-medium">
<i data-lucide="sliders" class="w-4 h-4 inline-block mr-2"></i> Filters & Search
<span id="activeFilters" class="badge badge-primary badge-sm ml-2">0 active</span>
</div>
<div class="collapse-content">
<div class="grid grid-cols-1 md:grid-cols-5 gap-3 mt-2">
<div>
<label class="label py-1"><span class="label-text text-xs">Log Level</span></label>
<select id="levelFilter" class="select select-bordered select-sm w-full" onchange="applyFilters()">
<option value="">All Levels</option>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="ERROR">ERROR</option>
<option value="WARN">WARN</option>
</select>
</div>
<div>
<label class="label py-1"><span class="label-text text-xs">Source</span></label>
<select id="sourceFilter" class="select select-bordered select-sm w-full" onchange="applyFilters()">
<option value="">All Sources</option>
<option value="MODULE">MODULE</option>
<option value="HOST">HOST</option>
</select>
</div>
<div>
<label class="label py-1"><span class="label-text text-xs">Type</span></label>
<select id="typeFilter" class="select select-bordered select-sm w-full" onchange="applyFilters()">
<option value="">All Types</option>
<option value="FETCH REQUEST">Fetch Requests</option>
<option value="FETCH RESPONSE">Fetch Responses</option>
<option value="GENERAL">General Logs</option>
</select>
</div>
<div>
<label class="label py-1"><span class="label-text text-xs">Status Code</span></label>
<select id="statusFilter" class="select select-bordered select-sm w-full" onchange="applyFilters()">
<option value="">All Status</option>
<option value="200">200 OK</option>
<option value="404">404 Not Found</option>
<option value="500">500 Error</option>
</select>
</div>
<div>
<label class="label py-1"><span class="label-text text-xs">URL Filter</span></label>
<input type="text" id="urlFilter" placeholder="Filter by URL..." aria-label="url-filter" class="input input-bordered input-sm w-full" oninput="debouncedApplyFilters()">
</div>
</div>
<div class="flex gap-2 mt-3 items-center">
<input type="text" id="searchFilter" placeholder="🔍 Search in logs (supports regex)..." aria-label="search-filter" class="input input-bordered input-sm flex-1" oninput="debouncedApplyFilters()">
<label class="label cursor-pointer gap-2">
<span class="label-text text-xs">Regex</span>
<input type="checkbox" id="regexMode" aria-label="regex-mode" class="checkbox checkbox-sm" onchange="applyFilters()">
</label>
<button onclick="clearFilters()" class="btn btn-sm btn-ghost">Clear All</button>
<span id="resultCount" class="badge badge-lg">0 entries</span>
</div>
<!-- Quick URL list for easy access (collapsible, persisted) -->
<div class="mt-3">
<div class="collapse collapse-arrow">
<input type="checkbox" id="urlListCollapse" aria-label="toggle-urls" />
<div class="collapse-title text-sm font-semibold flex items-center gap-2">
<i data-lucide="link-2" class="w-4 h-4"></i>
<span>URLs</span>
<span id="urlCount" class="badge badge-xs ml-2">0</span>
</div>
<div class="collapse-content">
<div id="urlListPanel" class="flex gap-2 flex-wrap text-sm"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Timeline View Toggle -->
<div class="flex gap-2 mb-3">
<button onclick="setView('timeline')" id="viewTimeline" class="btn btn-sm btn-ghost flex items-center gap-2"><i data-lucide="clock" class="w-4 h-4"></i><span>Timeline</span></button>
<button onclick="setView('grouped')" id="viewGrouped" class="btn btn-sm btn-ghost flex items-center gap-2"><i data-lucide="layers" class="w-4 h-4"></i><span>Grouped</span></button>
<button onclick="setView('table')" id="viewTable" class="btn btn-sm btn-ghost flex items-center gap-2"><i data-lucide="table" class="w-4 h-4"></i><span>Table</span></button>
<div class="flex-1"></div>
<button onclick="scrollToTop()" class="btn btn-sm btn-ghost flex items-center gap-2"><i data-lucide="chevrons-up" class="w-4 h-4"></i><span>Top</span></button>
<button onclick="scrollToBottom()" class="btn btn-sm btn-ghost flex items-center gap-2"><i data-lucide="chevrons-down" class="w-4 h-4"></i><span>Bottom</span></button>
</div>
<!-- Log Container (scrollable area) -->
<div id="logContainer" class="flex-1 overflow-auto max-w-full relative">
<div id="logList" class="space-y-2 px-0 py-0"></div>
<div id="noResults" class="absolute inset-0 flex flex-col items-center justify-center text-center p-6 opacity-60 hidden">
<div class="mb-4"><i data-lucide="search" class="w-12 h-12"></i></div>
<div class="text-xl mb-4">No logs match your filters</div>
<button onclick="clearFilters()" class="btn btn-sm btn-primary">Clear Filters</button>
</div>
</div>
</div>
<!-- Resizer between main and sidebar -->
<div id="resizer" class="resizer hidden md:block" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>
<!-- Sidebar -->
<div id="sidebar" class="w-96 bg-base-100 p-4 border-l border-base-300 overflow-y-auto sticky top-0 h-full" style="width:24rem;">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-semibold">Details</div>
<div class="flex items-center gap-2">
<button onclick="extractUrlsToSidebar()" class="btn btn-xs btn-ghost" title="Extract URLs from logs"><i data-lucide="link-2" class="w-4 h-4"></i></button>
<button onclick="renderSidebarDefault()" class="btn btn-xs btn-ghost" title="Reset sidebar"><i data-lucide="refresh-cw" class="w-4 h-4"></i></button>
</div>
</div>
<!-- Selected Log Details -->
<div id="sidebarContent">
<div class="text-center py-20 opacity-40">
<div class="mb-3"><i data-lucide="chevron-left" class="w-10 h-10 inline-block"></i></div>
<div class="text-sm">Select a log entry to view details</div>
</div>
</div>
</div>
</div>
<!-- File Input (Hidden) -->
<input type="file" id="fileInput" accept=".json" aria-label="file-input" class="hidden" onchange="handleFileSelect(event)">
<script>
let parsedLogs = [];
let filteredLogs = [];
let selectedLog = null;
let currentView = 'timeline';
let rawData = null;
let parsedJsonArrays = [];
// rendering state for chunked render
let renderPointer = 0;
const RENDER_CHUNK = 200;
// steps support
let steps = [];
let currentStepIndex = 0;
let requestedStepIndex = null;
// Auto-load from file on page load
window.addEventListener('DOMContentLoaded', () => {
// read step param (if any) before loading data so we can open the correct step
parseFiltersFromUrl();
loadFromPath('./last_command_output.json');
loadFiltersFromStorage();
restoreSidebarWidth();
attachResizer();
loadUiStateFromStorage();
// restore returned data collapsed state (persisted)
try { if (typeof restoreReturnedDataCollapsedState === 'function') restoreReturnedDataCollapsedState(); } catch (e) {}
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
});
// Load filters from URL if present (overrides saved filters)
function parseFiltersFromUrl() {
try {
const params = new URLSearchParams(window.location.search);
const keys = ['level','source','type','status','url','search','regex'];
let has = false;
keys.forEach(k => {
if (params.has(k)) {
has = true;
const v = params.get(k);
if (k === 'regex') {
const el = document.getElementById('regexMode');
if (el) el.checked = v === '1' || v === 'true';
} else {
const id = k + (k === 'url' ? 'Filter' : (k === 'search' ? 'Filter' : ''));
const el = document.getElementById(id);
if (el) el.value = v || '';
}
}
});
// read step parameter if present (0-based index)
if (params.has('step')) {
const s = parseInt(params.get('step'));
if (!Number.isNaN(s)) requestedStepIndex = s;
}
// params use custom mapping for input ids
if (params.has('url')) document.getElementById('urlFilter').value = params.get('url');
if (params.has('search')) document.getElementById('searchFilter').value = params.get('search');
if (has) applyFilters(false);
} catch (e) {}
}
async function loadFromPath(path) {
try {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
rawData = data;
document.getElementById('dataSource').textContent = path;
loadData(data);
showToast('✅ Data loaded successfully', 'success');
} catch (error) {
console.error('Failed to load JSON:', error);
showToast('⚠️ Failed to load from ' + path + '. Click "Load JSON" to select a file.', 'warning');
}
}
function loadFromFile() {
document.getElementById('fileInput').click();
}
function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
rawData = data;
document.getElementById('dataSource').textContent = file.name;
loadData(data);
showToast('✅ File loaded successfully', 'success');
} catch (error) {
showToast('❌ Invalid JSON file', 'error');
}
};
reader.readAsText(file);
}
function parseLogEntry(logString, index) {
const entry = {
raw: logString,
index: index,
level: null,
source: null,
specialType: null,
message: logString,
url: null,
status: null,
body: null,
headers: null,
domain: null,
method: 'GET',
timestamp: Date.now() + index
};
// capture the rest of the log entry including newlines so large HTML/text bodies are preserved
const match = logString.match(/^\[(\w+)\]\s+\[(\w+)\](?:\s+\[([^\]]+)\])?\s+([\s\S]*)/);
if (match) {
entry.level = match[1];
entry.source = match[2];
entry.specialType = match[3] || null;
entry.message = match[4];
if (entry.specialType === 'FETCH REQUEST' || entry.specialType === 'FETCH RESPONSE') {
const urlMatch = entry.message.match(/^(https?:\/\/[^\s]+)/);
if (urlMatch) {
entry.url = urlMatch[1];
try {
entry.domain = new URL(entry.url).hostname;
} catch (e) {}
const rest = entry.message.substring(urlMatch[1].length).trim();
if (entry.specialType === 'FETCH RESPONSE') {
const statusMatch = rest.match(/status=(\d+)/);
if (statusMatch) entry.status = parseInt(statusMatch[1]);
let bodyMatch = rest.match(/body=([\s\S]*)$/);
if (!bodyMatch) bodyMatch = logString.match(/body=([\s\S]*)/);
if (bodyMatch) {
entry.body = bodyMatch[1];
// Detect content type
if (entry.body.trim().startsWith('{')) entry.contentType = 'json';
else if (entry.body.trim().startsWith('<')) entry.contentType = 'html';
else entry.contentType = 'text';
}
} else if (entry.specialType === 'FETCH REQUEST') {
try {
if (rest && rest !== 'undefined') {
entry.headers = JSON.parse(rest);
}
} catch (e) {
entry.headers = rest;
}
}
}
}
}
// attach parsed JSON arrays found in this log entry
try {
entry.parsedJsonArrays = extractJsonArraysFromText(entry.raw || entry.message || '');
} catch (e) {
entry.parsedJsonArrays = [];
}
return entry;
}
function highlightCode(code, language) {
if (!code) return '';
try {
if (language === 'json') {
const formatted = JSON.stringify(JSON.parse(code), null, 2);
return hljs.highlight(formatted, { language: 'json' }).value;
} else if (language === 'html') {
return hljs.highlight(code, { language: 'html' }).value;
}
return hljs.highlightAuto(code).value;
} catch (e) {
return code.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
}
// Robustly extract JSON arrays from a text blob by matching brackets
function extractJsonArraysFromText(text) {
const arrays = [];
if (!text) return arrays;
const len = text.length;
for (let i = 0; i < len; i++) {
if (text[i] === '[') {
let depth = 0;
let inString = false;
let stringChar = null;
let escaped = false;
let j = i;
for (; j < len; j++) {
const ch = text[j];
if (inString) {
if (escaped) { escaped = false; continue; }
if (ch === '\\') { escaped = true; continue; }
if (ch === stringChar) { inString = false; stringChar = null; continue; }
continue;
}
if (ch === '"' || ch === "'") { inString = true; stringChar = ch; continue; }
if (ch === '[') depth++;
else if (ch === ']') depth--;
if (depth === 0) break;
}
if (depth === 0 && j > i) {
const candidate = text.substring(i, j + 1);
try {
const parsed = JSON.parse(candidate);
if (Array.isArray(parsed)) arrays.push(parsed);
i = j;
} catch (e) {
// ignore invalid JSON
}
}
}
}
return arrays;
}
function extractJsonArraysFromLogs(logs) {
const arrays = [];
if (!logs || !logs.length) return arrays;
logs.forEach(l => {
try {
const found = extractJsonArraysFromText(l);
if (found && found.length) arrays.push(...found);
} catch (e) {}
});
return arrays;
}
function renderLogEntry(entry) {
const levelColor = {
'DEBUG': 'bg-yellow-200 text-yellow-800',
'INFO': 'bg-sky-100 text-sky-800',
'ERROR': 'bg-red-100 text-red-800',
'WARN': 'bg-orange-100 text-orange-800'
}[entry.level] || 'bg-base-200 text-base-content';
const isSpecial = entry.specialType === 'FETCH REQUEST' || entry.specialType === 'FETCH RESPONSE';
const selectedCls = selectedLog === entry.index ? 'ring-2 ring-blue-300 bg-blue-50 dark:bg-blue-900' : 'hover:bg-base-200';
let urlPart = '';
if (isSpecial && entry.url) {
const statusBadge = entry.status ? `<span class="text-xs px-2 py-0.5 rounded ${entry.status === 200 ? 'bg-green-100 text-green-800' : entry.status >= 400 ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}">${entry.status}</span>` : '';
urlPart = `<div class="flex items-center gap-2 mb-1 flex-wrap">
<code class="text-xs bg-base-200 px-2 py-1 rounded truncate flex-1 min-w-0">${escapeHtml(entry.url)}</code>
${statusBadge}
</div>`;
}
const html = `
<div class="log-entry card bg-base-100 shadow-sm ${selectedCls} border-l-4 p-2 my-1 cursor-pointer" data-index="${entry.index}" onclick="selectLog(${entry.index})">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 flex flex-col gap-1 items-start">
<span class="text-[10px] px-2 py-1 rounded ${levelColor}">${entry.level}</span>
<span class="text-[10px] px-2 py-1 rounded bg-base-200">${entry.source}</span>
${entry.specialType ? `<span class="text-[10px] px-2 py-1 rounded bg-primary text-primary-content">${entry.specialType}</span>` : ''}
</div>
<div class="flex-1 min-w-0 text-sm">
${urlPart || `<p class="text-xs truncate">${escapeHtml(entry.message)}</p>`}
</div>
</div>
</div>`;
return html;
}
function selectLog(index) {
selectedLog = index;
const entry = parsedLogs[index];
// Update UI
document.querySelectorAll('.log-entry').forEach(el => el.classList.remove('selected'));
document.querySelector(`[data-index="${index}"]`)?.classList.add('selected');
// Render sidebar
renderSidebar(entry);
// auto-show sidebar on small screens when selecting
document.getElementById('sidebar').classList.remove('hidden');
}
function renderSidebar(entry) {
const levelColors = {
'DEBUG': 'text-warning',
'INFO': 'text-info',
'ERROR': 'text-error',
'WARN': 'text-warning'
};
let html = `
<div class="space-y-4">
<div>
<h2 class="text-xl font-bold mb-2 flex items-center gap-2">
<span class="${levelColors[entry.level] || ''}">${entry.level}</span>
<span class="text-sm opacity-60">#${entry.index}</span>
</h2>
<div class="flex gap-1 flex-wrap">
<span class="badge badge-sm">${entry.source}</span>
${entry.specialType ? `<span class="badge badge-sm badge-primary">${entry.specialType}</span>` : ''}
</div>
</div>
<div class="divider my-2"></div>`;
if (entry.url) {
html += `
<div>
<h3 class="font-semibold text-sm mb-2 flex items-center justify-between">
URL
<button onclick="copyToClipboard('${escapeHtml(entry.url)}')" class="btn btn-xs btn-ghost"><i data-lucide="clipboard" class="w-4 h-4"></i></button>
</h3>
<div class="group space-y-1">
<div class="bg-base-200 p-2 rounded text-xs break-all">${escapeHtml(entry.url)}</div>
${entry.domain ? `<div class="text-xs opacity-60 mt-1">Domain: ${entry.domain}</div>` : ''}
<a class="text-xs opacity-60 mt-1 flex items-center gap-1" href="${entry.url}" target="_blank" rel="noopener noreferrer"><i data-lucide="external-link" class="w-4 h-4"></i><span>Open in new tab</span></a>
</div>
</div>`;
}
if (entry.status) {
html += `
<div>
<h3 class="font-semibold text-sm mb-2">Status Code</h3>
<div class="badge ${entry.status === 200 ? 'badge-success' : entry.status >= 400 ? 'badge-error' : 'badge-warning'}">${entry.status}</div>
</div>`;
}
if (entry.headers) {
const headersStr = typeof entry.headers === 'object' ? JSON.stringify(entry.headers, null, 2) : entry.headers;
html += `
<div class="copyable relative">
<h3 class="font-semibold text-sm mb-2">Request Headers</h3>
<button class="btn btn-xs btn-ghost copy-headers-btn"><i data-lucide="clipboard" class="w-4 h-4"></i></button>
<pre class="bg-base-200 p-3 rounded text-xs overflow-x-auto"><code class="language-json">${highlightCode(headersStr, 'json')}</code></pre>
</div>`;
}
// Show parsed JSON arrays (prefer arrays found on this entry, fall back to aggregated arrays)
const arraysToShow = (entry && entry.parsedJsonArrays && entry.parsedJsonArrays.length) ? entry.parsedJsonArrays : (parsedJsonArrays || []);
if (arraysToShow.length) {
html += `
<div>
<h3 class="font-semibold text-sm mb-2">Parsed JSON Arrays</h3>
<div class="space-y-2">
`;
arraysToShow.forEach(arr => {
html += `<pre class="bg-base-200 p-2 rounded text-xs overflow-auto max-h-100">${escapeHtml(JSON.stringify(arr, null, 2))}</pre>`;
});
html += `</div></div>`;
}
if (entry.body) {
const truncated = entry.body.length > 15000;
const displayBody = truncated ? entry.body.substring(0, 15000) + '...TRUNCATED...' : entry.body;
html += `
<div class="copyable">
<h3 class="font-semibold text-sm mb-2 flex items-center justify-between">
Response Body
<div class="flex gap-1">
${truncated ? '<span class="badge badge-xs badge-warning">Truncated</span>' : ''}
<span class="badge badge-xs">${entry.body.length} chars</span>
</div>
</h3>
<button class="btn btn-xs btn-ghost copy-body-btn"><i data-lucide="clipboard" class="w-4 h-4"></i></button>
<pre class="bg-base-200 p-3 rounded text-xs overflow-x-auto max-h-96"><code class="language-${entry.contentType || 'html'} wrap-anywhere text-wrap">${highlightCode(displayBody, entry.contentType)}</code></pre>
</div>`;
}
html += `
<div>
<h3 class="font-semibold text-sm mb-2">Raw Log</h3>
<pre class="bg-base-200 p-2 rounded text-xs overflow-auto max-h-150 log-entry-raw wrap-anywhere">${escapeHtml(entry.raw)}</pre>
</div>
</div>`;
// Insert main sidebar HTML first
const sidebarContentEl = document.getElementById('sidebarContent');
sidebarContentEl.innerHTML = html;
// attach copy listeners safely (avoid embedding raw content into inline handlers)
try {
if (entry.headers) {
const headersBtn = sidebarContentEl.querySelector('.copy-headers-btn');
if (headersBtn) headersBtn.addEventListener('click', () => copyToClipboard(typeof entry.headers === 'object' ? JSON.stringify(entry.headers, null, 2) : entry.headers));
}
if (entry.body) {
const bodyBtn = sidebarContentEl.querySelector('.copy-body-btn');
if (bodyBtn) bodyBtn.addEventListener('click', () => copyToClipboard(entry.body));
}
} catch (e) {}
// Extract URLs from the raw log and include them in the sidebar as DOM nodes
const urls = extractUrlsFromText(entry.raw || entry.message || '');
// If this is a fetch request, add cURL generator UI
if (entry.specialType === 'FETCH REQUEST') {
const curlPanel = document.createElement('div');
curlPanel.className = 'mb-3';
const title = document.createElement('div'); title.className = 'font-semibold text-sm mb-2'; title.textContent = 'cURL';
curlPanel.appendChild(title);
const pre = document.createElement('pre'); pre.className = 'bg-base-200 p-2 rounded text-xs overflow-auto'; pre.textContent = generateCurlForEntry(entry);
const btnRow = document.createElement('div'); btnRow.className = 'flex gap-2 mt-2';
const copyBtn = document.createElement('button'); copyBtn.className = 'btn btn-xs btn-ghost flex items-center gap-2'; copyBtn.innerHTML = '<i data-lucide="clipboard" class="w-4 h-4"></i> Copy cURL'; copyBtn.addEventListener('click', () => copyCurlForEntry(entry));
const dlBtn = document.createElement('button'); dlBtn.className = 'btn btn-xs btn-ghost flex items-center gap-2'; dlBtn.innerHTML = '<i data-lucide="download" class="w-4 h-4"></i> Download'; dlBtn.addEventListener('click', () => downloadCurlForEntry(entry));
btnRow.appendChild(copyBtn); btnRow.appendChild(dlBtn);
curlPanel.appendChild(pre); curlPanel.appendChild(btnRow);
sidebarContentEl.appendChild(curlPanel);
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
}
if (urls.length) {
const divider = document.createElement('div'); divider.className = 'divider my-2';
sidebarContentEl.appendChild(divider);
const container = document.createElement('div');
const title = document.createElement('h3'); title.className = 'font-semibold text-sm mb-2'; title.textContent = 'Extracted URLs';
container.appendChild(title);
const list = document.createElement('div'); list.className = 'space-y-2';
urls.forEach(u => {
const row = document.createElement('div'); row.className = 'flex items-center justify-between gap-2';
const txt = document.createElement('div'); txt.className = 'truncate text-xs break-all'; txt.textContent = u;
const controls = document.createElement('div'); controls.className = 'flex items-center gap-2';
const setBtn = document.createElement('button'); setBtn.className = 'btn btn-xs btn-ghost'; setBtn.title = 'Filter by this URL'; setBtn.setAttribute('aria-label', 'Filter by URL');
setBtn.addEventListener('click', () => setUrlFilter(u));
setBtn.innerHTML = '<i data-lucide="link-2" class="w-4 h-4"></i>';
const copyBtn = document.createElement('button'); copyBtn.className = 'btn btn-xs btn-ghost'; copyBtn.title = 'Copy URL'; copyBtn.setAttribute('aria-label', 'Copy URL');
copyBtn.addEventListener('click', () => copyToClipboard(u));
copyBtn.innerHTML = '<i data-lucide="clipboard" class="w-4 h-4"></i>';
const openA = document.createElement('a'); openA.className = 'btn btn-xs btn-ghost'; openA.href = u; openA.target = '_blank'; openA.rel = 'noopener noreferrer'; openA.title = 'Open in new tab'; openA.setAttribute('aria-label', 'Open URL');
openA.innerHTML = '<i data-lucide="external-link" class="w-4 h-4"></i>';
controls.appendChild(setBtn); controls.appendChild(copyBtn); controls.appendChild(openA);
row.appendChild(txt); row.appendChild(controls);
list.appendChild(row);
});
container.appendChild(list);
sidebarContentEl.appendChild(container);
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
}
}
// Find URLs in a text string (simple heuristic)
function extractUrlsFromText(text) {
if (!text) return [];
try {
const re = /https?:\/\/[^\s"'<>]+/g;
const matches = text.match(re) || [];
// dedupe
return Array.from(new Set(matches));
} catch (e) { return []; }
}
// Generate a curl command string from a parsed entry (fetch request)
function escapeForSingleQuotes(s) {
return String(s).replace(/'/g, "'\"'\"'");
}
function generateCurlForEntry(entry) {
if (!entry || !entry.url) return '';
try {
const method = (entry.method || 'GET').toUpperCase();
let headers = {};
if (entry.headers && typeof entry.headers === 'object') headers = entry.headers;
else if (entry.headers && typeof entry.headers === 'string') {
try { headers = JSON.parse(entry.headers); } catch (e) { /* leave as string */ }
}
const parts = ['curl -s'];
if (method && method !== 'GET') parts.push('-X ' + method);
// add headers
if (headers && typeof headers === 'object') {
Object.keys(headers).forEach(k => {
const v = headers[k];
parts.push('-H ' + "'" + k + ': ' + String(v).replace(/'/g, "'\"'\"'") + "'");
});
}
// body (if present)
if (entry.body) {
const body = typeof entry.body === 'object' ? JSON.stringify(entry.body) : String(entry.body);
parts.push("--data-raw '" + escapeForSingleQuotes(body) + "'");
}
parts.push("'" + entry.url + "'");
return parts.join(' ');
} catch (e) { return ''; }
}
function copyCurlForEntry(entry) {
const curl = generateCurlForEntry(entry);
if (!curl) { showToast('No cURL available', 'warning'); return; }
navigator.clipboard.writeText(curl).then(() => showToast('Copied cURL to clipboard', 'success'));
}
function downloadCurlForEntry(entry) {
const curl = generateCurlForEntry(entry);
if (!curl) { showToast('No cURL available', 'warning'); return; }
const script = '#!/usr/bin/env bash\nset -e\n\n' + curl + '\n';
const blob = new Blob([script], { type: 'text/x-shellscript' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const name = 'curl_' + (entry.index ?? 'log') + '.sh';
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
}
function updateStats() {
const requests = parsedLogs.filter(l => l.specialType === 'FETCH REQUEST');
const responses = parsedLogs.filter(l => l.specialType === 'FETCH RESPONSE');
const errors = parsedLogs.filter(l => l.level === 'ERROR' || (l.status && l.status >= 400));
const successResponses = responses.filter(r => r.status === 200);
const uniqueUrlsSet = new Set(parsedLogs.filter(l => l.url).map(l => l.url));
document.getElementById('totalLogs').textContent = parsedLogs.length;
document.getElementById('fetchRequests').textContent = requests.length;
document.getElementById('fetchResponses').textContent = responses.length;
document.getElementById('errorCount').textContent = errors.length;
document.getElementById('uniqueUrls').textContent = uniqueUrlsSet.size;
const successRate = responses.length > 0 ? Math.round((successResponses.length / responses.length) * 100) : 0;
document.getElementById('successRate').textContent = `${successRate}% success`;
const errorRate = parsedLogs.length > 0 ? Math.round((errors.length / parsedLogs.length) * 100) : 0;
document.getElementById('errorRate').textContent = `${errorRate}% errors`;
}
// Debounce wrapper for filters to improve responsiveness
function debounce(fn, wait = 150) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
}
const debouncedApplyFilters = debounce(() => { applyFilters(true); });
function applyFilters(save = false) {
const level = document.getElementById('levelFilter').value;
const source = document.getElementById('sourceFilter').value;
const type = document.getElementById('typeFilter').value;
const status = document.getElementById('statusFilter').value;
const urlFilter = document.getElementById('urlFilter').value.toLowerCase();
const search = document.getElementById('searchFilter').value;
const regexMode = document.getElementById('regexMode').checked;
let searchRegex;
if (regexMode && search) {
try {
searchRegex = new RegExp(search, 'i');
} catch (e) {
showToast('⚠️ Invalid regex pattern', 'warning');
}
}
filteredLogs = parsedLogs.filter(entry => {
if (level && entry.level !== level) return false;
if (source && entry.source !== source) return false;
if (type) {
if (type === 'GENERAL' && entry.specialType) return false;
if (type !== 'GENERAL' && entry.specialType !== type) return false;
}
if (status && entry.status?.toString() !== status) return false;
if (urlFilter && (!entry.url || !entry.url.toLowerCase().includes(urlFilter))) return false;
if (search) {
if (regexMode && searchRegex) {
if (!searchRegex.test(entry.raw)) return false;
} else {
if (!entry.raw.toLowerCase().includes(search.toLowerCase())) return false;
}
}
return true;
});
// reset pointer and chunked render
renderPointer = 0;
clearRenderedLogs();
renderLogsChunked();
const activeFilters = [level, source, type, status, urlFilter, search].filter(Boolean).length;
document.getElementById('activeFilters').textContent = `${activeFilters} active`;
document.getElementById('resultCount').textContent = `${filteredLogs.length} entries`;
document.getElementById('noResults').classList.toggle('hidden', filteredLogs.length > 0);
// ensure the noResults overlay sits above empty list when no entries
if (filteredLogs.length === 0) document.getElementById('logContainer').scrollTop = 0;
if (save) {
saveFiltersToStorage();
}
// always reflect current filters in the URL so they persist across reloads
updateUrlFromFilters();
}
function clearFilters() {
document.getElementById('levelFilter').value = '';
document.getElementById('sourceFilter').value = '';
document.getElementById('typeFilter').value = '';
document.getElementById('statusFilter').value = '';
document.getElementById('urlFilter').value = '';
document.getElementById('searchFilter').value = '';
document.getElementById('regexMode').checked = false;
applyFilters(true);
updateUrlFromFilters();
}
function clearRenderedLogs() {
document.getElementById('logList').innerHTML = '';
// hide no results until current render completes
document.getElementById('noResults').classList.add('hidden');
}
function renderLogsChunked() {
const container = (currentView === 'table') ? document.getElementById('tableBody') : document.getElementById('logList');
if (renderPointer >= filteredLogs.length) return;
const end = Math.min(renderPointer + RENDER_CHUNK, filteredLogs.length);
let chunk = '';
const slice = filteredLogs.slice(renderPointer, end);
if (currentView === 'table') {
chunk = slice.map(entry => renderTableRow(entry)).join('');
} else {
chunk = slice.map(entry => renderLogEntry(entry)).join('');
}
if (container) container.insertAdjacentHTML('beforeend', chunk);
renderPointer = end;
// schedule next chunk to avoid blocking
if (renderPointer < filteredLogs.length) requestAnimationFrame(() => renderLogsChunked());
else {
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
// show/hide no results overlay now that rendering finished
document.getElementById('noResults').classList.toggle('hidden', filteredLogs.length > 0);
}
}
// Table rendering helpers
function renderTableInit() {
const list = document.getElementById('logList');
if (!list) return;
list.innerHTML = `
<div class="overflow-auto">
<table class="table w-full text-sm">
<thead>
<tr class="text-left">
<th class="px-2">#</th>
<th class="px-2">Time</th>
<th class="px-2">Level</th>
<th class="px-2">Source</th>
<th class="px-2">Type</th>
<th class="px-2">URL / Message</th>
<th class="px-2">Status</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>`;
}
function renderTableRow(entry) {
const time = formatTime(entry.timestamp);
const url = entry.url ? escapeHtml(entry.url) : '';
const msg = escapeHtml(entry.message).replace(/\n/g, ' ');
return `
<tr class="align-top border-t">
<td class="px-2 py-2">${entry.index}</td>
<td class="px-2 py-2 text-xs text-muted">${time}</td>
<td class="px-2 py-2"><span class="text-xs px-2 py-0.5 rounded bg-base-200">${entry.level || ''}</span></td>
<td class="px-2 py-2 text-xs">${entry.source || ''}</td>
<td class="px-2 py-2 text-xs">${entry.specialType || ''}</td>
<td class="px-2 py-2 truncate">${url || msg}</td>
<td class="px-2 py-2">${entry.status || ''}</td>
</tr>`;
}
function formatTime(ts) {
try { return new Date(ts).toLocaleTimeString(); } catch (e) { return '-'; }
}
// Grouped view (by URL or type)
function renderGroupedView() {
const groups = {};
filteredLogs.forEach(entry => {
const key = entry.url || entry.specialType || 'General';
if (!groups[key]) groups[key] = [];
groups[key].push(entry);
});
const list = document.getElementById('logList');
if (!list) return;
let html = '';
Object.keys(groups).forEach(key => {
const items = groups[key];
html += `<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="font-semibold truncate">${escapeHtml(key)}</div>
<div class="text-xs opacity-60">${items.length} entries</div>
</div>
<div class="space-y-2">`;
items.slice(0, 200).forEach(it => { html += renderLogEntry(it); });
html += `</div></div>`;
});
list.innerHTML = html || '<div class="text-sm opacity-60">No groups</div>';
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
}
function loadData(data) {
if (Array.isArray(data)) {
steps = data;
// honor requested step from URL if present
if (requestedStepIndex !== null && Number.isInteger(requestedStepIndex)) {
currentStepIndex = Math.max(0, Math.min(requestedStepIndex, data.length - 1));
requestedStepIndex = null;
} else {
currentStepIndex = 0;
}
document.getElementById('stepNavigation').style.display = steps.length > 1 ? 'flex' : 'none';
updateStepIndicator();
} else {
steps = [data];
currentStepIndex = 0;
document.getElementById('stepNavigation').style.display = 'none';
}
loadCurrentStep();
}
function loadCurrentStep() {
const step = steps[currentStepIndex];
rawData = step;
// update data source
const baseSource = document.getElementById('dataSource').textContent.replace(/ - .*$/, '');
document.getElementById('dataSource').textContent = baseSource + ' - ' + (step.method || 'Step ' + (currentStepIndex + 1));
// render returned data
try { renderReturnedData(step); } catch (e) { console.warn('renderReturnedData failed', e); }
if (step.debug && Array.isArray(step.debug)) {
parsedLogs = step.debug.map((log, index) => parseLogEntry(log, index));
// aggregate arrays found across logs
parsedJsonArrays = extractJsonArraysFromLogs(step.debug);
filteredLogs = [...parsedLogs];
// render URL list and filters based on logs
renderUrlList();
// if URL contains filters, apply them (URL takes precedence)
parseFiltersFromUrl();
document.getElementById('resultCount').textContent = `${filteredLogs.length} entries`;
renderPointer = 0;
clearRenderedLogs();
if (currentView === 'grouped') renderGroupedView();
else {
if (currentView === 'table') renderTableInit();
renderLogsChunked();
}
}
}
function prevStep() {
if (currentStepIndex > 0) {
currentStepIndex--;
updateStepIndicator();
loadCurrentStep();
}
}
function nextStep() {
if (currentStepIndex < steps.length - 1) {
currentStepIndex++;
updateStepIndicator();
loadCurrentStep();
}
}
function updateStepIndicator() {
document.getElementById('stepIndicator').textContent = `Step ${currentStepIndex + 1} of ${steps.length}`;
}
function setView(view) {
currentView = view;
document.querySelectorAll('[id^="view"]').forEach(btn => btn.classList.remove('btn-active'));
document.getElementById('view' + view.charAt(0).toUpperCase() + view.slice(1))?.classList.add('btn-active');
// render depending on selected view
renderPointer = 0;
clearRenderedLogs();
if (view === 'grouped') {
renderGroupedView();
} else if (view === 'table') {
renderTableInit();
renderLogsChunked();
} else {
renderLogsChunked();
}
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
}
// Render the returned data payload from the runner (if any).
function renderReturnedData(root) {
const jsonContainer = document.getElementById('returnedDataJson');
const visualContainer = document.getElementById('returnedDataVisual');
const summary = document.getElementById('returnedDataSummary');
if (!jsonContainer || !summary || !visualContainer) return;
try {
const methodRaw = root && (root.method || root.methodName || root.func || root.function || '');
const method = String(methodRaw).toLowerCase();
const returned = root && (root.data !== undefined ? root.data : (root.returned !== undefined ? root.returned : (root.payload !== undefined ? root.payload : null)));
if (!returned) {
summary.textContent = 'No returned data';
jsonContainer.textContent = '{}';
visualContainer.innerHTML = '';
return;
}
const pretty = typeof returned === 'string' ? (() => {
try { return JSON.stringify(JSON.parse(returned), null, 2); } catch (e) { return returned; }
})() : JSON.stringify(returned, null, 2);
jsonContainer.textContent = pretty;
// Auto-detect by explicit method first (provided by runner), then fallback to shape heuristics
visualContainer.innerHTML = '';
if (method) {
if (method.includes('search')) {
// search results: expect array
const list = Array.isArray(returned) ? returned : (returned.items || []);
summary.textContent = `Search results — ${list.length} items`;
visualContainer.appendChild(renderSearchResults(list));
} else if (method.includes('detail')) {
const obj = (returned && !Array.isArray(returned)) ? returned : (Array.isArray(returned) && returned[0] ? returned[0] : {});
summary.textContent = 'Details object';
visualContainer.appendChild(renderDetails(obj));
} else if (method.includes('episode')) {
const list = Array.isArray(returned) ? returned : (returned.episodes || []);
summary.textContent = `Episodes — ${list.length} items`;
visualContainer.appendChild(renderEpisodes(list));
} else if (method.includes('stream')) {
// streams may be under data.streams or returned itself could be an array
const streams = (returned && returned.streams) ? returned.streams : (Array.isArray(returned) ? returned : (returned.stream ? [returned.stream] : []));
summary.textContent = `Stream — ${streams.length} servers`;
visualContainer.appendChild(renderStreams(streams));
} else {
// unknown method — fall back to heuristics below
}
}
// If visualContainer is still empty, fall back to previous heuristics
if (!visualContainer.hasChildNodes()) {
if (Array.isArray(returned) && returned.length && returned[0].title && returned[0].href) {
summary.textContent = `Search results — ${returned.length} items`;
visualContainer.appendChild(renderSearchResults(returned));
} else if (returned && typeof returned === 'object' && !Array.isArray(returned) && returned.description) {
summary.textContent = 'Details object';
visualContainer.appendChild(renderDetails(returned));
} else if (Array.isArray(returned) && returned.length && (returned[0].href || returned[0].number !== undefined)) {
summary.textContent = `Episodes — ${returned.length} items`;
visualContainer.appendChild(renderEpisodes(returned));
} else if (returned && typeof returned === 'object' && returned.streams && Array.isArray(returned.streams)) {
summary.textContent = `Stream — ${returned.streams.length} servers`;
visualContainer.appendChild(renderStreams(returned.streams));
} else if (Array.isArray(returned)) {
summary.textContent = `Array — ${returned.length} items`;
visualContainer.appendChild(renderGenericArray(returned));
} else if (returned && typeof returned === 'object') {
summary.textContent = `Object — ${Object.keys(returned).length} keys`;
visualContainer.appendChild(renderDetails(returned));
} else {
summary.textContent = `Type: ${typeof returned}`;
visualContainer.appendChild(renderGeneric(returned));
}
}
} catch (e) {
summary.textContent = 'Error rendering returned data';
jsonContainer.textContent = String(e);
visualContainer.innerHTML = '';
}
}
// Visual renderers
function renderSearchResults(list) {
const wrap = document.createElement('div');
wrap.className = 'grid grid-cols-2 md:grid-cols-4 gap-3';
list.forEach(item => {
const card = document.createElement('div'); card.className = 'bg-base-200 p-2 rounded';
const img = document.createElement('img'); img.src = 'https://wsrv.nl/?url=' + (item.image || ''); img.alt = item.title || ''; img.style.width = '100%'; img.style.height = '120px'; img.style.objectFit = 'cover'; img.className = 'rounded';
const title = document.createElement('div'); title.className = 'font-semibold text-sm mt-2 truncate'; title.textContent = item.title || '—';
const href = document.createElement('a'); href.className = 'text-xs opacity-60 block truncate'; href.href = item.href || '#'; href.target = '_blank'; href.rel = 'noopener noreferrer'; href.textContent = item.href || '';
card.appendChild(img); card.appendChild(title); card.appendChild(href);
wrap.appendChild(card);
});
return wrap;
}
function renderDetails(obj) {
const box = document.createElement('div'); box.className = 'space-y-2';
if (obj.image) {
const img = document.createElement('img'); img.src = 'https://wsrv.nl/?url=' + (obj.image || ''); img.alt = obj.title || 'image'; img.style.maxWidth = '180px'; img.className = 'rounded'; box.appendChild(img);
}
const meta = document.createElement('div'); meta.className = 'space-y-1';
if (obj.title) { const t = document.createElement('div'); t.className = 'font-bold'; t.textContent = obj.title; meta.appendChild(t); }
if (obj.aliases) { const a = document.createElement('div'); a.className = 'text-sm opacity-70'; a.textContent = `Aliases: ${obj.aliases}`; meta.appendChild(a); }
if (obj.airdate) { const d = document.createElement('div'); d.className = 'text-sm opacity-70'; d.textContent = `Airdate: ${obj.airdate}`; meta.appendChild(d); }
if (obj.description) { const desc = document.createElement('div'); desc.className = 'text-sm mt-2'; desc.textContent = obj.description; meta.appendChild(desc); }
box.appendChild(meta);
// fallback: show other keys
const keys = Object.keys(obj).filter(k => !['image','title','aliases','airdate','description'].includes(k));
if (keys.length) {
const extra = document.createElement('div'); extra.className = 'mt-2 text-xs opacity-70'; extra.textContent = keys.map(k => `${k}: ${JSON.stringify(obj[k])}`).join(' | ');
box.appendChild(extra);
}
return box;
}
function renderEpisodes(list) {
const wrap = document.createElement('div'); wrap.className = 'space-y-2';
list.slice(0,200).forEach(ep => {
const row = document.createElement('div'); row.className = 'flex items-center justify-between gap-2 bg-base-200 p-2 rounded';
const left = document.createElement('div'); left.className = 'text-sm'; left.textContent = `#${ep.number ?? '-'} ${ep.title ?? ''}`;
const right = document.createElement('div');
if (ep.href) { const a = document.createElement('a'); a.href = ep.href; a.target = '_blank'; a.rel='noopener noreferrer'; a.className = 'btn btn-xs btn-ghost'; a.textContent = 'Open'; right.appendChild(a); }
row.appendChild(left); row.appendChild(right); wrap.appendChild(row);
});
return wrap;
}
function renderStreams(streams) {
const wrap = document.createElement('div'); wrap.className = 'space-y-2';
streams.forEach(s => {
const row = document.createElement('div'); row.className = 'bg-base-200 p-2 rounded';
const title = document.createElement('div'); title.className = 'font-semibold'; title.textContent = s.title || 'Stream';
const url = document.createElement('div'); url.className = 'text-xs opacity-70'; url.textContent = s.streamUrl || s.url || '';
const controls = document.createElement('div'); controls.className = 'mt-2 flex gap-2';
if (s.streamUrl) { const open = document.createElement('a'); open.className = 'btn btn-xs btn-ghost'; open.href = s.streamUrl; open.target = '_blank'; open.rel='noopener noreferrer'; open.textContent = 'Open'; controls.appendChild(open); }
if (s.headers) { const copy = document.createElement('button'); copy.className = 'btn btn-xs btn-ghost'; copy.textContent = 'Copy Headers'; copy.addEventListener('click', () => copyToClipboard(JSON.stringify(s.headers, null, 2))); controls.appendChild(copy); }
row.appendChild(title); row.appendChild(url); row.appendChild(controls); wrap.appendChild(row);
});
return wrap;
}
function renderGenericArray(list) {
const pre = document.createElement('pre'); pre.className = 'max-h-60 overflow-auto bg-base-200 p-2 rounded text-xs'; pre.textContent = JSON.stringify(list, null, 2); return pre;
}
function renderGeneric(val) { const pre = document.createElement('pre'); pre.className = 'bg-base-200 p-2 rounded text-xs'; pre.textContent = typeof val === 'string' ? val : JSON.stringify(val, null, 2); return pre; }
// copy / download handlers for returned data
(function attachReturnedDataControls() {
try {
const copyBtn = document.getElementById('copyReturnedDataBtn');
const dlBtn = document.getElementById('downloadReturnedDataBtn');
const viewJsonBtn = document.getElementById('viewJsonBtn');
const viewVisualBtn = document.getElementById('viewVisualBtn');
const jsonEl = document.getElementById('returnedDataJson');
const visualEl = document.getElementById('returnedDataVisual');
if (copyBtn) copyBtn.addEventListener('click', () => {
try {
const data = rawData && (rawData.data !== undefined ? rawData.data : (rawData.returned !== undefined ? rawData.returned : rawData));
if (!data) { showToast('No returned data to copy', 'warning'); return; }
const s = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
navigator.clipboard.writeText(s).then(() => showToast('Copied returned data', 'success'));
} catch (e) { showToast('Failed to copy returned data', 'error'); }
});
if (dlBtn) dlBtn.addEventListener('click', () => {
try {
const data = rawData && (rawData.data !== undefined ? rawData.data : (rawData.returned !== undefined ? rawData.returned : rawData));
if (!data) { showToast('No returned data to download', 'warning'); return; }
const blob = new Blob([typeof data === 'string' ? data : JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `returned_data_${Date.now()}.json`; a.click(); URL.revokeObjectURL(url);
} catch (e) { showToast('Failed to download returned data', 'error'); }
});
if (viewJsonBtn && viewVisualBtn && jsonEl && visualEl) {
viewJsonBtn.addEventListener('click', () => { viewJsonBtn.classList.add('btn-active'); viewVisualBtn.classList.remove('btn-active'); jsonEl.style.display='block'; visualEl.style.display='none'; });
viewVisualBtn.addEventListener('click', () => { viewVisualBtn.classList.add('btn-active'); viewJsonBtn.classList.remove('btn-active'); jsonEl.style.display='none'; visualEl.style.display='block'; });
}
// collapse / expand returned data panel and persist state
const toggleBtn = document.getElementById('toggleReturnedDataBtn');
function setReturnedDataCollapsed(collapsed) {
const panel = document.getElementById('returnedDataPanel');
const json = document.getElementById('returnedDataJson');
const visual = document.getElementById('returnedDataVisual');
const summaryEl = document.getElementById('returnedDataSummary');
if (!panel || !json || !visual || !summaryEl) return;
if (collapsed) {
json.style.display = 'none';
visual.style.display = 'none';
summaryEl.style.display = 'none';
if (toggleBtn) toggleBtn.innerHTML = '<i data-lucide="chevron-down" class="w-4 h-4"></i>';
panel.classList.add('collapsed');
} else {
summaryEl.style.display = 'block';
if (viewJsonBtn && viewJsonBtn.classList.contains('btn-active')) { json.style.display='block'; visual.style.display='none'; }
else { json.style.display='none'; visual.style.display='block'; }
if (toggleBtn) toggleBtn.innerHTML = '<i data-lucide="chevron-up" class="w-4 h-4"></i>';
panel.classList.remove('collapsed');
}
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
try { localStorage.setItem('returnedDataCollapsed', collapsed ? '1' : '0'); } catch (e) {}
}
if (toggleBtn) toggleBtn.addEventListener('click', () => {
try {
const cur = localStorage.getItem('returnedDataCollapsed') === '1';
setReturnedDataCollapsed(!cur);
} catch (e) { setReturnedDataCollapsed(true); }
});
// expose restore function for initial load
window.restoreReturnedDataCollapsedState = function() {
try {
const val = localStorage.getItem('returnedDataCollapsed');
setReturnedDataCollapsed(val === '1');
} catch (e) { /* ignore */ }
};
} catch (e) {}
})();
// --- Sidebar resizer logic ---
function restoreSidebarWidth() {
try {
const saved = localStorage.getItem('debugViewerSidebarWidth');
if (saved) {
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.style.width = saved;
}
} catch (e) {}
}
function attachResizer() {
const resizer = document.getElementById('resizer');
const sidebar = document.getElementById('sidebar');
if (!resizer || !sidebar) return;
// show resizer on md+ screens
resizer.classList.remove('hidden');
let isResizing = false;
let startX = 0;
let startWidth = 0;
const minW = 200; // px
const maxW = Math.min(window.innerWidth - 300, 1000);
function onDown(e) {
isResizing = true;
startX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX;
startWidth = sidebar.getBoundingClientRect().width;
document.body.style.userSelect = 'none';
}
function onMove(e) {
if (!isResizing) return;
const clientX = e.type.startsWith('touch') ? e.touches[0].clientX : e.clientX;
const delta = clientX - startX;
// invert direction so dragging feels natural when resizer is between panels
let newWidth = Math.round(startWidth - delta);
if (newWidth < minW) newWidth = minW;
if (newWidth > maxW) newWidth = maxW;
sidebar.style.width = newWidth + 'px';
}
function onUp() {
if (!isResizing) return;
isResizing = false;
document.body.style.userSelect = '';
try { localStorage.setItem('debugViewerSidebarWidth', sidebar.style.width); } catch (e) {}
}
resizer.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
// touch events
resizer.addEventListener('touchstart', onDown, {passive:false});
window.addEventListener('touchmove', onMove, {passive:false});
window.addEventListener('touchend', onUp);
}
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('hidden');
saveSidebarVisibilityState();
}
// keyboard navigation: j/k or arrows to move selection
document.addEventListener('keydown', (e) => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.key === 'j' || e.key === 'ArrowDown') selectNext();
if (e.key === 'k' || e.key === 'ArrowUp') selectPrev();
});
function selectNext() {
if (filteredLogs.length === 0) return;
const currentIndex = selectedLog === null ? -1 : filteredLogs.findIndex(l => l.index === selectedLog);
const nextPos = Math.min(currentIndex + 1, filteredLogs.length - 1);
if (nextPos >= 0) { selectLog(filteredLogs[nextPos].index); scrollIntoViewForIndex(filteredLogs[nextPos].index); }
}
function selectPrev() {
if (filteredLogs.length === 0) return;
const currentIndex = selectedLog === null ? 0 : filteredLogs.findIndex(l => l.index === selectedLog);
const prevPos = Math.max(currentIndex - 1, 0);
selectLog(filteredLogs[prevPos].index); scrollIntoViewForIndex(filteredLogs[prevPos].index);
}
function scrollIntoViewForIndex(index) {
const el = document.querySelector(`[data-index='${index}']`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Save/load filter state for developer convenience
function saveFiltersToStorage() {
const state = {
level: document.getElementById('levelFilter').value,
source: document.getElementById('sourceFilter').value,
type: document.getElementById('typeFilter').value,
status: document.getElementById('statusFilter').value,
url: document.getElementById('urlFilter').value,
search: document.getElementById('searchFilter').value,
regex: document.getElementById('regexMode').checked
};
try { localStorage.setItem('debugViewerFilters', JSON.stringify(state)); } catch (e) {}
}
function loadFiltersFromStorage() {
try {
const raw = localStorage.getItem('debugViewerFilters');
if (!raw) return;
const s = JSON.parse(raw);
document.getElementById('levelFilter').value = s.level || '';
document.getElementById('sourceFilter').value = s.source || '';
document.getElementById('typeFilter').value = s.type || '';
document.getElementById('statusFilter').value = s.status || '';
document.getElementById('urlFilter').value = s.url || '';
document.getElementById('searchFilter').value = s.search || '';
document.getElementById('regexMode').checked = !!s.regex;
applyFilters(false);
} catch (e) {}
}
// Persist and restore some UI state: sidebar visibility and url list collapsed
function loadUiStateFromStorage() {
try {
const hidden = localStorage.getItem('debugViewerSidebarHidden');
const sidebar = document.getElementById('sidebar');
if (hidden === '1') sidebar.classList.add('hidden');
const urlCollapsed = localStorage.getItem('debugViewerUrlListCollapsed');
const urlCheckbox = document.getElementById('urlListCollapse');
if (urlCheckbox) {
urlCheckbox.checked = urlCollapsed === '1';
// ensure the collapse UI reflects the checked state
}
// listen for changes to persist
if (urlCheckbox) urlCheckbox.addEventListener('change', (e) => {
try { localStorage.setItem('debugViewerUrlListCollapsed', e.target.checked ? '1' : '0'); } catch (e) {}
});
} catch (e) {}
}
function saveSidebarVisibilityState() {
try {
const sidebar = document.getElementById('sidebar');
const hidden = sidebar.classList.contains('hidden') ? '1' : '0';
localStorage.setItem('debugViewerSidebarHidden', hidden);
} catch (e) {}
}
function renderSidebarDefault() {
document.getElementById('sidebarContent').innerHTML = `
<div class="text-center py-20 opacity-40">
<div class="mb-3"><i data-lucide="chevron-left" class="w-10 h-10 inline-block"></i></div>
<div class="text-sm">Select a log entry to view details</div>
</div>`;
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
}
// Extract unique URLs and render them inside the sidebar for quick access/copy
function extractUrlsToSidebar() {
try {
const counts = {};
parsedLogs.forEach(l => { if (l.url) counts[l.url] = (counts[l.url]||0) + 1; });
const items = Object.keys(counts).sort((a,b) => counts[b] - counts[a]);
const container = document.createElement('div');
container.className = 'space-y-3';
const header = document.createElement('div');
header.className = 'flex items-center justify-between';
const title = document.createElement('div'); title.className = 'font-semibold'; title.textContent = 'Extracted URLs';
const meta = document.createElement('div'); meta.className = 'text-xs opacity-60'; meta.textContent = `${items.length} found`;
header.appendChild(title); header.appendChild(meta);
container.appendChild(header);
const list = document.createElement('div'); list.className = 'space-y-2';
items.slice(0,500).forEach(u => {
const row = document.createElement('div'); row.className = 'flex items-center justify-between gap-2';
const txt = document.createElement('div'); txt.className = 'truncate text-xs break-all'; txt.textContent = u;
const controls = document.createElement('div'); controls.className = 'flex items-center gap-2';
const setBtn = document.createElement('button'); setBtn.className = 'btn btn-xs btn-ghost'; setBtn.title = 'Filter by this URL'; setBtn.onclick = () => setUrlFilter(u); setBtn.innerHTML = '<i data-lucide="link-2" class="w-4 h-4"></i>';
const copyBtn = document.createElement('button'); copyBtn.className = 'btn btn-xs btn-ghost'; copyBtn.title = 'Copy URL'; copyBtn.onclick = () => copyToClipboard(u); copyBtn.innerHTML = '<i data-lucide="clipboard" class="w-4 h-4"></i>';
controls.appendChild(setBtn); controls.appendChild(copyBtn);
row.appendChild(txt); row.appendChild(controls);
list.appendChild(row);
});
container.appendChild(list);
const sidebarContent = document.getElementById('sidebarContent');
if (sidebarContent) {
sidebarContent.innerHTML = '';
sidebarContent.appendChild(container);
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
}
} catch (e) {}
}
// Update URL search params to reflect current filters and view
function updateUrlFromFilters() {
try {
const params = new URLSearchParams();
const level = document.getElementById('levelFilter').value;
const source = document.getElementById('sourceFilter').value;
const type = document.getElementById('typeFilter').value;
const status = document.getElementById('statusFilter').value;
const url = document.getElementById('urlFilter').value;
const search = document.getElementById('searchFilter').value;
const regex = document.getElementById('regexMode').checked;
if (level) params.set('level', level);
if (source) params.set('source', source);
if (type) params.set('type', type);
if (status) params.set('status', status);
if (url) params.set('url', url);
if (search) params.set('search', search);
if (regex) params.set('regex', '1');
if (currentView) params.set('view', currentView);
if (steps && steps.length > 1) params.set('step', String(currentStepIndex));
const newUrl = window.location.pathname + (params.toString() ? ('?' + params.toString()) : '');
history.replaceState(null, '', newUrl);
} catch (e) {}
}
// Render list of unique URLs from parsedLogs for quick filtering
function renderUrlList() {
try {
const panel = document.getElementById('urlListPanel');
if (!panel) return;
panel.innerHTML = '';
const counts = {};
parsedLogs.forEach(l => { if (l.url) counts[l.url] = (counts[l.url]||0) + 1; });
const items = Object.keys(counts).sort((a,b) => counts[b] - counts[a]);
items.slice(0, 200).forEach(url => {
const btn = document.createElement('button');
btn.className = 'btn btn-xs btn-ghost flex items-center gap-2';
btn.title = url;
btn.onclick = () => { setUrlFilter(url); };
const span = document.createElement('span');
span.className = 'truncate';
span.style.maxWidth = '260px';
span.textContent = url;
btn.innerHTML = `<i data-lucide="link-2" class="w-4 h-4"></i>`;
btn.appendChild(span);
const badge = document.createElement('span');
badge.className = 'badge badge-xs ml-1';
badge.textContent = counts[url];
btn.appendChild(badge);
panel.appendChild(btn);
});
try { if (window.lucide) lucide.createIcons(); } catch (e) {}
} catch (e) {}
}
function setUrlFilter(url) {
const el = document.getElementById('urlFilter');
if (!el) return;
el.value = url;
applyFilters(true);
}
function scrollToTop() {
const container = document.getElementById('logContainer');
if (container) container.scrollTo({ top: 0, behavior: 'smooth' });
else window.scrollTo({ top: 0, behavior: 'smooth' });
}
function scrollToBottom() {
const container = document.getElementById('logContainer');
if (container) container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
else window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
function exportFiltered() {
const data = {
exported: new Date().toISOString(),
filters: {
level: document.getElementById('levelFilter').value,
source: document.getElementById('sourceFilter').value,
type: document.getElementById('typeFilter').value
},
logs: filteredLogs.map(l => l.raw)
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `filtered_logs_${Date.now()}.json`;
a.click();
showToast('✅ Exported ' + filteredLogs.length + ' logs', 'success');
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showToast('📋 Copied to clipboard', 'success');
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} fixed bottom-4 right-4 w-auto z-50 shadow-lg`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Keyboard shortcuts for developer convenience
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'f') { e.preventDefault(); document.getElementById('searchFilter').focus(); }
if (e.key === 'k') { e.preventDefault(); clearFilters(); }
}
});
// attach debounced listeners
document.getElementById('searchFilter')?.addEventListener('input', debouncedApplyFilters);
document.getElementById('urlFilter')?.addEventListener('input', debouncedApplyFilters);
document.getElementById('levelFilter')?.addEventListener('change', () => applyFilters(true));
document.getElementById('sourceFilter')?.addEventListener('change', () => applyFilters(true));
document.getElementById('typeFilter')?.addEventListener('change', () => applyFilters(true));
document.getElementById('statusFilter')?.addEventListener('change', () => applyFilters(true));
</script>
</body>
</html>