mirror of
https://git.luna-app.eu/50n50/sources
synced 2025-12-21 13:16:21 +01:00
1555 lines
No EOL
85 KiB
HTML
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, '<').replace(/>/g, '>');
|
|
}
|
|
}
|
|
|
|
// 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> |