850 lines
23 KiB
HTML
850 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ja">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>TAKT Debug Log Viewer</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #1e1e1e;
|
|
color: #d4d4d4;
|
|
padding: 20px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 24px;
|
|
margin-bottom: 20px;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.drop-zone {
|
|
border: 2px dashed #007acc;
|
|
border-radius: 8px;
|
|
padding: 40px;
|
|
text-align: center;
|
|
background: #252526;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.drop-zone:hover,
|
|
.drop-zone.drag-over {
|
|
background: #2d2d30;
|
|
border-color: #0098ff;
|
|
}
|
|
|
|
.drop-zone-text {
|
|
font-size: 16px;
|
|
color: #858585;
|
|
}
|
|
|
|
.quick-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.action-btn {
|
|
padding: 10px 20px;
|
|
background: #007acc;
|
|
color: #ffffff;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: #005a9e;
|
|
}
|
|
|
|
.action-btn:disabled {
|
|
background: #3e3e42;
|
|
color: #858585;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.action-btn.secondary {
|
|
background: #252526;
|
|
border: 1px solid #3e3e42;
|
|
}
|
|
|
|
.action-btn.secondary:hover:not(:disabled) {
|
|
background: #2d2d30;
|
|
border-color: #007acc;
|
|
}
|
|
|
|
.navigation {
|
|
display: none;
|
|
background: #252526;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.navigation.active {
|
|
display: flex;
|
|
}
|
|
|
|
.current-file {
|
|
flex: 1;
|
|
text-align: center;
|
|
font-size: 14px;
|
|
color: #9cdcfe;
|
|
}
|
|
|
|
.nav-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.nav-btn {
|
|
padding: 6px 12px;
|
|
background: #1e1e1e;
|
|
color: #d4d4d4;
|
|
border: 1px solid #3e3e42;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.nav-btn:hover:not(:disabled) {
|
|
background: #2d2d30;
|
|
border-color: #007acc;
|
|
}
|
|
|
|
.nav-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.controls {
|
|
background: #252526;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
display: none;
|
|
}
|
|
|
|
.controls.active {
|
|
display: block;
|
|
}
|
|
|
|
.filter-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.filter-label {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #9cdcfe;
|
|
margin-bottom: 8px;
|
|
display: block;
|
|
}
|
|
|
|
.filter-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filter-btn {
|
|
padding: 6px 12px;
|
|
border: 1px solid #3e3e42;
|
|
border-radius: 4px;
|
|
background: #1e1e1e;
|
|
color: #d4d4d4;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.filter-btn:hover {
|
|
background: #2d2d30;
|
|
border-color: #007acc;
|
|
}
|
|
|
|
.filter-btn.active {
|
|
background: #007acc;
|
|
border-color: #007acc;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.search-box {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
background: #1e1e1e;
|
|
border: 1px solid #3e3e42;
|
|
border-radius: 4px;
|
|
color: #d4d4d4;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.search-box:focus {
|
|
outline: none;
|
|
border-color: #007acc;
|
|
}
|
|
|
|
.stats {
|
|
display: flex;
|
|
gap: 20px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.stat {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
color: #858585;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.logs {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.log-entry {
|
|
background: #252526;
|
|
padding: 8px 12px;
|
|
border-left: 3px solid #3e3e42;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.log-entry.DEBUG {
|
|
border-left-color: #858585;
|
|
}
|
|
|
|
.log-entry.INFO {
|
|
border-left-color: #4ec9b0;
|
|
}
|
|
|
|
.log-entry.WARN {
|
|
border-left-color: #dcdcaa;
|
|
}
|
|
|
|
.log-entry.ERROR {
|
|
border-left-color: #f48771;
|
|
}
|
|
|
|
.log-entry.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.log-timestamp {
|
|
color: #858585;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.log-level {
|
|
font-weight: 600;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.log-level.DEBUG {
|
|
color: #858585;
|
|
}
|
|
|
|
.log-level.INFO {
|
|
color: #4ec9b0;
|
|
}
|
|
|
|
.log-level.WARN {
|
|
color: #dcdcaa;
|
|
}
|
|
|
|
.log-level.ERROR {
|
|
color: #f48771;
|
|
}
|
|
|
|
.log-category {
|
|
color: #9cdcfe;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.log-message {
|
|
color: #d4d4d4;
|
|
}
|
|
|
|
.log-json {
|
|
background: #1e1e1e;
|
|
border-radius: 4px;
|
|
padding: 8px;
|
|
margin-top: 4px;
|
|
white-space: pre-wrap;
|
|
overflow-x: auto;
|
|
border: 1px solid #3e3e42;
|
|
color: #ce9178;
|
|
}
|
|
|
|
.highlight {
|
|
background-color: #ffff0044;
|
|
}
|
|
|
|
.error {
|
|
background: #f48771;
|
|
color: #1e1e1e;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: #1e1e1e;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #3e3e42;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #4e4e52;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>TAKT Debug Log Viewer</h1>
|
|
|
|
<div class="drop-zone" id="dropZone">
|
|
<div class="drop-zone-text">
|
|
ここにデバッグログファイルをドラッグ&ドロップ<br>
|
|
またはクリックしてファイルを選択
|
|
</div>
|
|
<input type="file" id="fileInput" accept=".log,.txt" style="display: none;">
|
|
</div>
|
|
|
|
<div class="quick-actions">
|
|
<button class="action-btn" id="loadLatestBtn">📂 ログディレクトリを指定する</button>
|
|
<button class="action-btn secondary" id="clearDirBtn" style="display: none;">🗑️ 保存したディレクトリをクリア</button>
|
|
</div>
|
|
|
|
<div class="navigation" id="navigation">
|
|
<div class="nav-buttons">
|
|
<button class="nav-btn" id="oldestBtn">⏮️ 最古</button>
|
|
<button class="nav-btn" id="prevBtn">◀️ Prev</button>
|
|
</div>
|
|
<div class="current-file" id="currentFile">-</div>
|
|
<div class="nav-buttons">
|
|
<button class="nav-btn" id="nextBtn">Next ▶️</button>
|
|
<button class="nav-btn" id="latestBtn">最新 ⏭️</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls" id="controls">
|
|
<div class="stats" id="stats"></div>
|
|
|
|
<div class="filter-group">
|
|
<label class="filter-label">ログレベル</label>
|
|
<div class="filter-buttons" id="levelFilters"></div>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label class="filter-label">カテゴリ</label>
|
|
<div class="filter-buttons" id="categoryFilters"></div>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label class="filter-label">検索</label>
|
|
<input type="text" class="search-box" id="searchBox" placeholder="メッセージを検索...">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="logs" id="logs"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const dropZone = document.getElementById('dropZone');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const loadLatestBtn = document.getElementById('loadLatestBtn');
|
|
const clearDirBtn = document.getElementById('clearDirBtn');
|
|
const navigation = document.getElementById('navigation');
|
|
const currentFileDiv = document.getElementById('currentFile');
|
|
const oldestBtn = document.getElementById('oldestBtn');
|
|
const prevBtn = document.getElementById('prevBtn');
|
|
const nextBtn = document.getElementById('nextBtn');
|
|
const latestBtn = document.getElementById('latestBtn');
|
|
const controls = document.getElementById('controls');
|
|
const statsDiv = document.getElementById('stats');
|
|
const logsDiv = document.getElementById('logs');
|
|
const levelFiltersDiv = document.getElementById('levelFilters');
|
|
const categoryFiltersDiv = document.getElementById('categoryFilters');
|
|
const searchBox = document.getElementById('searchBox');
|
|
|
|
let logEntries = [];
|
|
let activeLevels = new Set(['DEBUG', 'INFO', 'WARN', 'ERROR']);
|
|
let activeCategories = new Set();
|
|
let searchTerm = '';
|
|
let lastLogDirectory = null;
|
|
let logFiles = [];
|
|
let currentFileIndex = -1;
|
|
|
|
// IndexedDB setup
|
|
const DB_NAME = 'TaktLogViewerDB';
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = 'directories';
|
|
let db = null;
|
|
|
|
async function initDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
db = request.result;
|
|
resolve(db);
|
|
};
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
db.createObjectStore(STORE_NAME);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
async function saveDirectoryHandle(handle) {
|
|
if (!db) await initDB();
|
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
store.put(handle, 'logDirectory');
|
|
return tx.complete;
|
|
}
|
|
|
|
async function getDirectoryHandle() {
|
|
if (!db) await initDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const request = store.get('logDirectory');
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async function clearDirectoryHandle() {
|
|
if (!db) await initDB();
|
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
const store = tx.objectStore(STORE_NAME);
|
|
store.delete('logDirectory');
|
|
return tx.complete;
|
|
}
|
|
|
|
dropZone.addEventListener('click', () => fileInput.click());
|
|
dropZone.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.add('drag-over');
|
|
});
|
|
dropZone.addEventListener('dragleave', () => {
|
|
dropZone.classList.remove('drag-over');
|
|
});
|
|
dropZone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('drag-over');
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) handleFile(file);
|
|
});
|
|
fileInput.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) handleFile(file);
|
|
});
|
|
|
|
searchBox.addEventListener('input', (e) => {
|
|
searchTerm = e.target.value.toLowerCase();
|
|
applyFilters();
|
|
});
|
|
|
|
// Initialize
|
|
initDB().then(async () => {
|
|
const savedHandle = await getDirectoryHandle();
|
|
if (savedHandle) {
|
|
clearDirBtn.style.display = 'inline-block';
|
|
loadLatestBtn.textContent = '🔄 最新のログを読み込む';
|
|
// Try to load automatically
|
|
try {
|
|
await loadFromDirectoryHandle(savedHandle);
|
|
} catch (err) {
|
|
console.log('Saved directory handle is no longer valid:', err);
|
|
}
|
|
}
|
|
});
|
|
|
|
oldestBtn.addEventListener('click', () => loadFileByIndex(logFiles.length - 1));
|
|
prevBtn.addEventListener('click', () => loadFileByIndex(currentFileIndex + 1));
|
|
nextBtn.addEventListener('click', () => loadFileByIndex(currentFileIndex - 1));
|
|
latestBtn.addEventListener('click', () => loadFileByIndex(0));
|
|
|
|
clearDirBtn.addEventListener('click', async () => {
|
|
await clearDirectoryHandle();
|
|
clearDirBtn.style.display = 'none';
|
|
loadLatestBtn.textContent = '📂 ログディレクトリを指定する';
|
|
logFiles = [];
|
|
currentFileIndex = -1;
|
|
navigation.classList.remove('active');
|
|
logsDiv.innerHTML = '';
|
|
controls.classList.remove('active');
|
|
});
|
|
|
|
loadLatestBtn.addEventListener('click', async () => {
|
|
try {
|
|
// Check if File System Access API is supported
|
|
if (!('showDirectoryPicker' in window)) {
|
|
alert('お使いのブラウザはこの機能に対応していません。Chrome、Edge、Brave、またはOperaをご使用ください。');
|
|
return;
|
|
}
|
|
|
|
// Check if we have a saved directory handle
|
|
const savedHandle = await getDirectoryHandle();
|
|
|
|
let dirHandle;
|
|
if (savedHandle) {
|
|
// Try to use saved handle
|
|
try {
|
|
// Verify we still have permission
|
|
const permission = await savedHandle.queryPermission({ mode: 'read' });
|
|
if (permission === 'granted') {
|
|
dirHandle = savedHandle;
|
|
} else {
|
|
// Request permission again
|
|
const newPermission = await savedHandle.requestPermission({ mode: 'read' });
|
|
if (newPermission === 'granted') {
|
|
dirHandle = savedHandle;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log('Saved handle is invalid, requesting new directory');
|
|
}
|
|
}
|
|
|
|
// If no saved handle or permission denied, request new directory
|
|
if (!dirHandle) {
|
|
dirHandle = await window.showDirectoryPicker({
|
|
id: 'takt-logs',
|
|
startIn: 'documents',
|
|
mode: 'read'
|
|
});
|
|
|
|
// Save the new handle
|
|
await saveDirectoryHandle(dirHandle);
|
|
clearDirBtn.style.display = 'inline-block';
|
|
loadLatestBtn.textContent = '🔄 最新のログを読み込む';
|
|
}
|
|
|
|
await loadFromDirectoryHandle(dirHandle);
|
|
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') {
|
|
console.log('User cancelled directory selection');
|
|
} else {
|
|
console.error('Error loading latest log:', err);
|
|
logsDiv.innerHTML = `<div class="error">Error loading latest log: ${err.message}</div>`;
|
|
}
|
|
}
|
|
});
|
|
|
|
async function loadFromDirectoryHandle(dirHandle) {
|
|
lastLogDirectory = dirHandle;
|
|
|
|
// Find all .log files
|
|
logFiles = [];
|
|
for await (const entry of dirHandle.values()) {
|
|
if (entry.kind === 'file' && (entry.name.endsWith('.log') || entry.name.endsWith('.txt'))) {
|
|
const fileHandle = entry;
|
|
const file = await fileHandle.getFile();
|
|
logFiles.push({ name: entry.name, file, modifiedTime: file.lastModified });
|
|
}
|
|
}
|
|
|
|
if (logFiles.length === 0) {
|
|
alert('ログファイルが見つかりませんでした。');
|
|
return;
|
|
}
|
|
|
|
// Sort by modified time (newest first)
|
|
logFiles.sort((a, b) => b.modifiedTime - a.modifiedTime);
|
|
|
|
// Load the latest file (index 0)
|
|
loadFileByIndex(0);
|
|
}
|
|
|
|
function loadFileByIndex(index) {
|
|
if (index < 0 || index >= logFiles.length) return;
|
|
|
|
currentFileIndex = index;
|
|
const fileData = logFiles[index];
|
|
|
|
handleFile(fileData.file);
|
|
updateNavigation();
|
|
}
|
|
|
|
function updateNavigation() {
|
|
if (logFiles.length === 0) {
|
|
navigation.classList.remove('active');
|
|
return;
|
|
}
|
|
|
|
navigation.classList.add('active');
|
|
|
|
const fileData = logFiles[currentFileIndex];
|
|
const date = new Date(fileData.modifiedTime).toLocaleString('ja-JP');
|
|
currentFileDiv.textContent = `${fileData.name} (${currentFileIndex + 1}/${logFiles.length}) - ${date}`;
|
|
|
|
// Update button states
|
|
oldestBtn.disabled = currentFileIndex === logFiles.length - 1;
|
|
prevBtn.disabled = currentFileIndex === logFiles.length - 1;
|
|
nextBtn.disabled = currentFileIndex === 0;
|
|
latestBtn.disabled = currentFileIndex === 0;
|
|
}
|
|
|
|
function handleFile(file) {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const content = e.target.result;
|
|
parseLog(content);
|
|
} catch (err) {
|
|
logsDiv.innerHTML = `<div class="error">Error parsing log: ${err.message}</div>`;
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function parseLog(content) {
|
|
const lines = content.split('\n');
|
|
logEntries = [];
|
|
const categories = new Set();
|
|
const levels = { DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0 };
|
|
|
|
let currentEntry = null;
|
|
let jsonBuffer = [];
|
|
let inJson = false;
|
|
|
|
for (let line of lines) {
|
|
// Skip header lines
|
|
if (line.startsWith('=====') || line.startsWith('TAKT Debug Log') ||
|
|
line.startsWith('Started:') || line.startsWith('Project:')) {
|
|
continue;
|
|
}
|
|
|
|
// Match log line: [timestamp] [level] [category] message
|
|
const match = line.match(/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/);
|
|
|
|
if (match) {
|
|
// Save previous entry if exists
|
|
if (currentEntry) {
|
|
if (jsonBuffer.length > 0) {
|
|
currentEntry.json = jsonBuffer.join('\n');
|
|
jsonBuffer = [];
|
|
}
|
|
logEntries.push(currentEntry);
|
|
}
|
|
|
|
const [, timestamp, level, category, message] = match;
|
|
currentEntry = {
|
|
timestamp,
|
|
level: level.toUpperCase(),
|
|
category,
|
|
message,
|
|
json: null
|
|
};
|
|
|
|
categories.add(category);
|
|
levels[level.toUpperCase()]++;
|
|
inJson = false;
|
|
|
|
} else if (currentEntry) {
|
|
// Check if this is a JSON line
|
|
if (line.trim().startsWith('{') || line.trim().startsWith('[')) {
|
|
inJson = true;
|
|
}
|
|
|
|
if (inJson || line.trim().match(/^[}\],]/) || line.trim().match(/^"[\w-]+":/) ||
|
|
(jsonBuffer.length > 0 && line.trim())) {
|
|
jsonBuffer.push(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save last entry
|
|
if (currentEntry) {
|
|
if (jsonBuffer.length > 0) {
|
|
currentEntry.json = jsonBuffer.join('\n');
|
|
}
|
|
logEntries.push(currentEntry);
|
|
}
|
|
|
|
activeCategories = new Set(categories);
|
|
displayStats(levels, categories.size);
|
|
createFilters(categories);
|
|
displayLogs();
|
|
}
|
|
|
|
function displayStats(levels, categoryCount) {
|
|
statsDiv.innerHTML = `
|
|
<div class="stat">
|
|
<span class="stat-label">Total Logs</span>
|
|
<span class="stat-value">${logEntries.length}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">DEBUG</span>
|
|
<span class="stat-value">${levels.DEBUG}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">INFO</span>
|
|
<span class="stat-value">${levels.INFO}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">WARN</span>
|
|
<span class="stat-value">${levels.WARN}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">ERROR</span>
|
|
<span class="stat-value">${levels.ERROR}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-label">Categories</span>
|
|
<span class="stat-value">${categoryCount}</span>
|
|
</div>
|
|
`;
|
|
controls.classList.add('active');
|
|
}
|
|
|
|
function createFilters(categories) {
|
|
// Level filters
|
|
levelFiltersDiv.innerHTML = ['DEBUG', 'INFO', 'WARN', 'ERROR'].map(level => `
|
|
<button class="filter-btn active" data-level="${level}" onclick="toggleLevel('${level}')">
|
|
${level}
|
|
</button>
|
|
`).join('');
|
|
|
|
// Category filters
|
|
const sortedCategories = Array.from(categories).sort();
|
|
categoryFiltersDiv.innerHTML = sortedCategories.map(cat => `
|
|
<button class="filter-btn active" data-category="${cat}" onclick="toggleCategory('${cat}')">
|
|
${cat}
|
|
</button>
|
|
`).join('');
|
|
}
|
|
|
|
function toggleLevel(level) {
|
|
if (activeLevels.has(level)) {
|
|
activeLevels.delete(level);
|
|
} else {
|
|
activeLevels.add(level);
|
|
}
|
|
const btn = document.querySelector(`[data-level="${level}"]`);
|
|
btn.classList.toggle('active');
|
|
applyFilters();
|
|
}
|
|
|
|
function toggleCategory(category) {
|
|
if (activeCategories.has(category)) {
|
|
activeCategories.delete(category);
|
|
} else {
|
|
activeCategories.add(category);
|
|
}
|
|
const btn = document.querySelector(`[data-category="${category}"]`);
|
|
btn.classList.toggle('active');
|
|
applyFilters();
|
|
}
|
|
|
|
function applyFilters() {
|
|
const entries = document.querySelectorAll('.log-entry');
|
|
entries.forEach((entry, index) => {
|
|
const log = logEntries[index];
|
|
const levelMatch = activeLevels.has(log.level);
|
|
const categoryMatch = activeCategories.has(log.category);
|
|
const searchMatch = !searchTerm ||
|
|
log.message.toLowerCase().includes(searchTerm) ||
|
|
(log.json && log.json.toLowerCase().includes(searchTerm));
|
|
|
|
if (levelMatch && categoryMatch && searchMatch) {
|
|
entry.classList.remove('hidden');
|
|
} else {
|
|
entry.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
function highlightText(text, term) {
|
|
if (!term) return escapeHtml(text);
|
|
const regex = new RegExp(`(${escapeRegex(term)})`, 'gi');
|
|
return escapeHtml(text).replace(regex, '<span class="highlight">$1</span>');
|
|
}
|
|
|
|
function escapeRegex(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function displayLogs() {
|
|
logsDiv.innerHTML = logEntries.map((log, index) => {
|
|
const messageHtml = searchTerm ?
|
|
highlightText(log.message, searchTerm) :
|
|
escapeHtml(log.message);
|
|
|
|
const jsonHtml = log.json ?
|
|
`<div class="log-json">${searchTerm ? highlightText(log.json, searchTerm) : escapeHtml(log.json)}</div>` :
|
|
'';
|
|
|
|
return `
|
|
<div class="log-entry ${log.level}">
|
|
<span class="log-timestamp">${escapeHtml(log.timestamp)}</span>
|
|
<span class="log-level ${log.level}">[${log.level}]</span>
|
|
<span class="log-category">[${escapeHtml(log.category)}]</span>
|
|
<span class="log-message">${messageHtml}</span>
|
|
${jsonHtml}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (typeof text !== 'string') return String(text);
|
|
const map = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
return text.replace(/[&<>"']/g, m => map[m]);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|