This commit is contained in:
2026-04-29 08:16:15 +07:00
commit 5335df26a4
10 changed files with 842 additions and 0 deletions

776
index.html Normal file
View File

@@ -0,0 +1,776 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log Viewer</title>
<link rel="stylesheet" href="css/fonts.css">
<script src="js/chart.umd.min.js"></script>
<script src="js/chartjs-plugin-zoom.min.js"></script>
<script src="js/hammer.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d0f12;--surface:#131619;--surface2:#191c21;--surface3:#1e2128;
--border:#ffffff0f;--border2:#ffffff18;--border3:#ffffff28;
--text:#e8eaf0;--text2:#8b8f9a;--text3:#555966;
--accent:#4f8ef7;--accent-dim:#4f8ef720;
--debug:#555966;--info:#4f8ef7;--warn:#f5a623;--error:#e8504a;--fatal:#c72b2b;
--debug-bg:#55596610;--info-bg:#4f8ef710;--warn-bg:#f5a62310;--error-bg:#e8504a10;--fatal-bg:#c72b2b18;
--radius:6px;--radius-lg:10px;
--font-mono:'JetBrains Mono',monospace;
--font-sans:'DM Sans',sans-serif;
}
html,body{height:100%;overflow:hidden}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);font-size:13px;display:flex;flex-direction:column}
/* Drop overlay */
#drop-overlay{
position:fixed;inset:0;z-index:9999;
background:#0d0f12f0;backdrop-filter:blur(8px);
display:none;align-items:center;justify-content:center;
flex-direction:column;gap:20px;
border:2px dashed var(--accent);
pointer-events:none;
}
#drop-overlay.active{display:flex;pointer-events:all}
#drop-overlay .drop-icon{width:64px;height:64px;opacity:.6;animation:pulse 1.4s ease-in-out infinite}
@keyframes pulse{0%,100%{transform:scale(1);opacity:.6}50%{transform:scale(1.08);opacity:1}}
#drop-overlay .drop-label{font-size:18px;font-weight:500;color:var(--text);letter-spacing:.01em}
#drop-overlay .drop-sub{font-size:13px;color:var(--text2)}
/* Header */
#header{
display:flex;align-items:center;gap:16px;
padding:10px 20px;border-bottom:1px solid var(--border);
background:var(--surface);flex-shrink:0;
}
#logo{font-family:var(--font-mono);font-size:15px;font-weight:500;color:var(--text);letter-spacing:-.02em}
#logo span{color:var(--accent)}
#file-info{font-family:var(--font-mono);font-size:11px;color:var(--text3);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#file-btn{
display:flex;align-items:center;gap:7px;
background:var(--surface3);border:1px solid var(--border2);border-radius:var(--radius);
color:var(--text2);font-family:var(--font-sans);font-size:12px;font-weight:500;
padding:6px 12px;cursor:pointer;transition:all .15s;white-space:nowrap;
}
#file-btn:hover{border-color:var(--border3);color:var(--text);background:var(--surface2)}
#file-input{display:none}
.hdr-sep{width:1px;height:20px;background:var(--border)}
/* Filter bar */
#filter-bar{
display:flex;align-items:center;gap:10px;
padding:8px 20px;border-bottom:1px solid var(--border);
background:var(--surface);flex-shrink:0;
}
#search-wrap{position:relative;flex:1;max-width:420px}
#search-wrap svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);opacity:.4;pointer-events:none}
#search{
width:100%;background:var(--surface3);border:1px solid var(--border2);border-radius:var(--radius);
color:var(--text);font-family:var(--font-mono);font-size:12px;
padding:6px 10px 6px 32px;outline:none;transition:border-color .15s;
}
#search:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}
#search.invalid{border-color:var(--error);box-shadow:0 0 0 3px #e8504a18}
#search::placeholder{color:var(--text3)}
#regex-badge{
position:absolute;right:8px;top:50%;transform:translateY(-50%);
font-family:var(--font-mono);font-size:10px;padding:2px 5px;
border-radius:3px;background:var(--accent-dim);color:var(--accent);opacity:.7;
}
/* Level pills */
.level-pills{display:flex;gap:6px;flex-wrap:wrap}
.level-pill{
display:flex;align-items:center;gap:6px;
padding:5px 10px;border-radius:20px;
border:1px solid var(--border2);
background:transparent;cursor:pointer;font-family:var(--font-sans);font-size:11px;
font-weight:500;color:var(--text2);transition:all .15s;white-space:nowrap;
}
.level-pill:hover{border-color:var(--border3);color:var(--text)}
.level-pill.active{border-color:currentColor}
.level-pill[data-level="DEBUG"]{--lc:var(--debug)}
.level-pill[data-level="INFO"]{--lc:var(--info)}
.level-pill[data-level="WARN"]{--lc:var(--warn)}
.level-pill[data-level="ERROR"]{--lc:var(--error)}
.level-pill[data-level="FATAL"]{--lc:var(--fatal)}
.level-pill.active{color:var(--lc);border-color:var(--lc);background:color-mix(in srgb,var(--lc) 10%,transparent)}
.pill-dot{width:6px;height:6px;border-radius:50%;background:var(--lc,var(--text3));flex-shrink:0}
.pill-count{font-family:var(--font-mono);font-size:10px;opacity:.7}
.filter-actions{display:flex;gap:6px;margin-left:auto}
#clear-filters{
padding:5px 10px;border-radius:var(--radius);border:1px solid var(--border2);
background:transparent;color:var(--text3);font-size:11px;font-family:var(--font-sans);
cursor:pointer;transition:all .15s;
}
#clear-filters:hover{color:var(--text);border-color:var(--border3)}
#time-badge{
display:none;align-items:center;gap:5px;
padding:5px 10px;border-radius:var(--radius);
background:var(--accent-dim);border:1px solid var(--accent);
color:var(--accent);font-size:11px;font-family:var(--font-mono);cursor:pointer;
}
#time-badge.visible{display:flex}
#time-badge:hover{background:#4f8ef730}
#time-badge .tx-close{opacity:.6;margin-left:2px}
/* Main layout */
#main{display:flex;flex-direction:column;flex:1;overflow:hidden}
/* Chart panel */
#chart-panel{
flex-shrink:0;padding:16px 20px 12px;
border-bottom:1px solid var(--border);
}
#chart-wrap{position:relative;height:100px}
#chart-hint{position:absolute;top:4px;right:0;font-size:10px;color:var(--text3);font-family:var(--font-mono)}
/* Table area */
#table-area{flex:1;overflow:hidden;display:flex;flex-direction:column}
/* DataTables custom styles */
#dt-wrap{flex:1;overflow:auto;padding:0 20px 20px}
table#log-table{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:11.5px}
table#log-table thead th{
position:sticky;top:0;z-index:10;
background:var(--surface);border-bottom:1px solid var(--border2);
padding:10px 12px;text-align:left;font-weight:500;
color:var(--text2);font-size:11px;letter-spacing:.05em;text-transform:uppercase;
white-space:nowrap;cursor:pointer;user-select:none;
}
table#log-table thead th:hover{color:var(--text)}
table#log-table thead th.sort-asc::after{content:' ↑'}
table#log-table thead th.sort-desc::after{content:' ↓'}
table#log-table tbody tr{border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s}
table#log-table tbody tr:hover>td{background:var(--surface2)}
table#log-table tbody td{padding:7px 12px;vertical-align:top}
.col-time{width:180px;color:var(--text3);white-space:nowrap}
.col-level{width:72px}
.col-msg{word-break:break-word}
/* Level badges */
.lv{display:inline-block;padding:2px 7px;border-radius:3px;font-size:10px;font-weight:500;letter-spacing:.04em}
.lv-DEBUG{background:var(--debug-bg);color:var(--debug)}
.lv-INFO{background:var(--info-bg);color:var(--info)}
.lv-WARN{background:var(--warn-bg);color:var(--warn)}
.lv-ERROR{background:var(--error-bg);color:var(--error)}
.lv-FATAL{background:var(--fatal-bg);color:var(--fatal)}
/* Row details (expanded) */
.row-detail{background:var(--surface2)!important;border-left:2px solid var(--accent)!important}
.row-detail td{padding:0!important}
.detail-inner{padding:12px 16px 16px;display:flex;flex-direction:column;gap:12px}
.detail-section{display:flex;flex-direction:column;gap:6px}
.detail-label{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)}
.detail-val{
background:var(--surface3);border:1px solid var(--border);border-radius:var(--radius);
padding:10px 12px;font-family:var(--font-mono);font-size:11px;color:var(--text2);
white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;
line-height:1.6;
}
/* Empty state */
#empty-state{
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:16px;color:var(--text3);
}
#empty-state svg{opacity:.3}
#empty-state .es-title{font-size:15px;font-weight:500;color:var(--text2)}
#empty-state .es-sub{font-size:12px;text-align:center;max-width:280px;line-height:1.6}
#empty-state .es-btn{
margin-top:8px;padding:8px 20px;border-radius:var(--radius);
background:var(--accent-dim);border:1px solid var(--accent);
color:var(--accent);font-size:13px;font-family:var(--font-sans);cursor:pointer;
transition:all .15s;
}
#empty-state .es-btn:hover{background:#4f8ef730}
/* Table status bar */
#status-bar{
display:flex;align-items:center;gap:12px;
padding:7px 20px;border-top:1px solid var(--border);
background:var(--surface);flex-shrink:0;font-size:11px;color:var(--text3);
font-family:var(--font-mono);
}
#status-bar .status-count{color:var(--text2)}
.sb-sep{color:var(--border3)}
/* Scrollbar */
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--border3)}
/* Animations */
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
.fade-in{animation:fadeIn .2s ease forwards}
</style>
</head>
<body>
<!-- Drop Overlay -->
<div id="drop-overlay">
<svg class="drop-icon" width="64" height="64" viewBox="0 0 64 64" fill="none">
<rect x="8" y="8" width="48" height="48" rx="8" stroke="#4f8ef7" stroke-width="2" stroke-dasharray="6 4"/>
<path d="M32 20v24M22 34l10 10 10-10" stroke="#4f8ef7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="drop-label">Drop log file here</div>
<div class="drop-sub">Supports .log, .txt, .json and plain text</div>
</div>
<!-- Header -->
<div id="header">
<div id="logo">log<span>scope</span></div>
<div class="hdr-sep"></div>
<div id="file-info">No file loaded — drag & drop or select a file</div>
<button id="file-btn" onclick="document.getElementById('file-input').click()">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><path d="M2 12V4a1 1 0 011-1h5l2 2h3a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1z" stroke="currentColor" stroke-width="1.4"/></svg>
Open File
</button>
<input type="file" id="file-input" accept=".log,.txt,.json,text/plain">
</div>
<!-- Filter bar -->
<div id="filter-bar">
<div id="search-wrap">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<input type="text" id="search" placeholder="Filter messages… (regex supported)" autocomplete="off">
<span id="regex-badge">.*</span>
</div>
<div class="hdr-sep"></div>
<div class="level-pills" id="level-pills">
<button class="level-pill" data-level="DEBUG"><span class="pill-dot"></span>Debug<span class="pill-count" id="cnt-DEBUG">0</span></button>
<button class="level-pill" data-level="INFO"><span class="pill-dot"></span>Info<span class="pill-count" id="cnt-INFO">0</span></button>
<button class="level-pill" data-level="WARN"><span class="pill-dot"></span>Warn<span class="pill-count" id="cnt-WARN">0</span></button>
<button class="level-pill" data-level="ERROR"><span class="pill-dot"></span>Error<span class="pill-count" id="cnt-ERROR">0</span></button>
<button class="level-pill" data-level="FATAL"><span class="pill-dot"></span>Fatal<span class="pill-count" id="cnt-FATAL">0</span></button>
</div>
<div class="filter-actions">
<div id="time-badge">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.4"/><path d="M8 5v3l2 2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<span id="time-badge-label">time range</span>
<span class="tx-close"></span>
</div>
<button id="clear-filters">Clear filters</button>
</div>
</div>
<!-- Main -->
<div id="main">
<!-- Chart -->
<div id="chart-panel">
<div id="chart-wrap">
<canvas id="intensity-chart"></canvas>
<span id="chart-hint">drag to zoom · right-click to reset</span>
</div>
</div>
<!-- Empty state / Table area -->
<div id="empty-state">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="6" y="6" width="36" height="36" rx="6" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 3"/>
<path d="M16 20h16M16 24h10M16 28h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="es-title">No log file loaded</div>
<div class="es-sub">Drag & drop a log file anywhere on the page or click below to browse</div>
<button class="es-btn" onclick="document.getElementById('file-input').click()">Select log file</button>
</div>
<div id="table-area" style="display:none">
<div id="dt-wrap">
<table id="log-table">
<thead>
<tr>
<th class="col-time" data-col="time">Time</th>
<th class="col-level" data-col="level">Level</th>
<th class="col-msg" data-col="message">Message</th>
</tr>
</thead>
<tbody id="log-tbody"></tbody>
</table>
</div>
</div>
<!-- Status bar -->
<div id="status-bar" style="display:none">
<span class="status-count" id="status-showing">0 entries</span>
<span class="sb-sep">·</span>
<span id="status-total">0 total</span>
<span class="sb-sep">·</span>
<span id="status-file">no file</span>
</div>
</div>
<script>
// ---- State ----
let allLogs = [];
let filteredLogs = [];
let levelFilter = null;
let textFilter = '';
let timeFilter = null; // {start, end} timestamps
let sortCol = 'time';
let sortDir = 'asc';
let expandedRows = new Set();
let chart = null;
let chartBuckets = [];
// ---- Demo data generator ----
function generateDemoLogs() {
const levels = ['DEBUG','DEBUG','DEBUG','INFO','INFO','INFO','INFO','WARN','WARN','ERROR','ERROR','FATAL'];
const msgs = [
{level:'INFO', msg:'Server started on port 3000', props:{pid:1234,env:'production'}},
{level:'DEBUG', msg:'Database connection pool initialized', props:{pool_size:10,timeout:5000}},
{level:'INFO', msg:'Request received GET /api/users', props:{method:'GET',path:'/api/users',ip:'10.0.0.5'}},
{level:'DEBUG', msg:'Cache hit for key user:session:8f2a', props:{ttl:3540}},
{level:'INFO', msg:'User authentication successful', props:{user_id:'u_9f3a',roles:['admin','viewer']}},
{level:'WARN', msg:'Response time exceeded threshold 500ms', props:{actual_ms:621,endpoint:'/api/reports'}},
{level:'ERROR', msg:'Failed to connect to Redis', props:{host:'redis:6379',attempt:3}, exception:'RedisConnectionError: Connection refused\n at connect (redis.js:42)\n at retry (pool.js:87)'},
{level:'DEBUG', msg:'Worker thread spawned', props:{worker_id:'w_003',queue:'email'}},
{level:'WARN', msg:'Memory usage above 80%', props:{used_mb:812,total_mb:1024,percent:79.3}},
{level:'INFO', msg:'Scheduled job completed: nightly-backup', props:{duration_ms:12430,records:8912}},
{level:'ERROR', msg:'Unhandled exception in payment processor', props:{order_id:'ord_7f8a',amount:149.99}, exception:'TypeError: Cannot read property \'charge\' of undefined\n at PaymentService.process (payment.js:133)\n at OrderController.checkout (orders.js:78)'},
{level:'FATAL', msg:'Out of memory — process terminating', props:{heap_used_mb:2048,heap_limit_mb:2048}, exception:'FatalError: ENOMEM\n at allocate (heap.js:21)'},
{level:'INFO', msg:'Health check passed', props:{db:'ok',cache:'ok',queue:'ok'}},
{level:'DEBUG', msg:'Config reloaded from environment', props:{changed_keys:['LOG_LEVEL','DB_POOL']}},
{level:'WARN', msg:'Deprecated API endpoint /v1/search called', props:{new_endpoint:'/v2/search',caller_ip:'192.168.1.55'}},
];
const now = Date.now();
const logs = [];
for (let i = 0; i < 200; i++) {
const template = msgs[Math.floor(Math.random() * msgs.length)];
logs.push({
time: new Date(now - (200 - i) * 18000 + Math.random() * 15000),
level: template.level,
message: template.msg,
props: template.props || null,
exception: template.exception || null,
});
}
return logs.sort((a, b) => a.time - b.time);
}
// ---- Log parsing ----
function parseLogs(text) {
const lines = text.split('\n').filter(l => l.trim());
const logs = [];
// Try JSON Lines
const jsonLogs = tryParseJSONLines(lines);
if (jsonLogs.length > lines.length * 0.5) return jsonLogs;
// Try common log formats
const patterns = [
// 2024-01-15T10:23:45.123Z INFO message
/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\s+(.+)$/i,
// [2024-01-15 10:23:45] [INFO] message
/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s*\[(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\]\s*(.+)$/i,
// Jan 15 10:23:45 INFO message
/^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\s+(.+)$/i,
// INFO 2024-01-15 10:23:45 message
/^(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\s+(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(.+)$/i,
];
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
let matched = false;
for (const pat of patterns) {
const m = line.match(pat);
if (m) {
const [, g1, g2, g3] = m;
let timeStr, levelStr, msg;
if (/^(DEBUG|INFO|WARN|ERROR|FATAL)/i.test(g1)) {
timeStr = g2; levelStr = g1.toUpperCase().replace('WARNING','WARN'); msg = g3;
} else {
timeStr = g1; levelStr = g2.toUpperCase().replace('WARNING','WARN'); msg = g3;
}
const time = new Date(timeStr);
if (!isNaN(time)) {
// Collect continuation lines (stack traces etc.)
let exception = '';
let j = i + 1;
while (j < lines.length && !/\d{4}-\d{2}-\d{2}/.test(lines[j]) && !patterns.some(p => p.test(lines[j]))) {
const cl = lines[j].trim();
if (cl) exception += (exception ? '\n' : '') + cl;
j++;
}
i = j - 1;
logs.push({ time, level: levelStr, message: msg.trim(), props: null, exception: exception || null });
matched = true;
break;
}
}
}
if (!matched) {
// Plain line, treat as INFO
if (line.length > 0) {
logs.push({ time: new Date(), level: 'INFO', message: line, props: null, exception: null });
}
}
i++;
}
return logs.sort((a, b) => a.time - b.time);
}
function tryParseJSONLines(lines) {
const logs = [];
for (const line of lines) {
try {
const obj = JSON.parse(line.trim());
const timeKeys = ['timestamp','time','ts','date','@timestamp'];
const levelKeys = ['level','severity','lvl','log_level'];
const msgKeys = ['message','msg','text','body','log'];
const timeVal = timeKeys.map(k => obj[k]).find(v => v);
const levelRaw = levelKeys.map(k => obj[k]).find(v => v) || 'INFO';
const msgVal = msgKeys.map(k => obj[k]).find(v => v) || JSON.stringify(obj);
const level = String(levelRaw).toUpperCase().replace('WARNING','WARN');
const time = timeVal ? new Date(timeVal) : new Date();
const usedKeys = new Set([...timeKeys, ...levelKeys, ...msgKeys].filter(k => obj[k] !== undefined));
const props = Object.fromEntries(Object.entries(obj).filter(([k]) => !usedKeys.has(k)));
const exception = obj.exception || obj.error || obj.stack || null;
logs.push({ time, level: ['DEBUG','INFO','WARN','ERROR','FATAL'].includes(level) ? level : 'INFO', message: String(msgVal), props: Object.keys(props).length ? props : null, exception: exception ? String(exception) : null });
} catch {}
}
return logs;
}
// ---- Chart ----
function buildChart(logs) {
if (!logs.length) return;
const canvas = document.getElementById('intensity-chart');
const BUCKETS = 60;
const times = logs.map(l => l.time.getTime());
const minT = Math.min(...times), maxT = Math.max(...times);
const span = maxT - minT || 1;
const bucketSize = span / BUCKETS;
const buckets = Array.from({length: BUCKETS}, (_, i) => ({
t: new Date(minT + i * bucketSize),
DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0, FATAL: 0, total: 0
}));
for (const log of logs) {
const idx = Math.min(Math.floor((log.time.getTime() - minT) / bucketSize), BUCKETS - 1);
buckets[idx][log.level] = (buckets[idx][log.level] || 0) + 1;
buckets[idx].total++;
}
chartBuckets = buckets;
if (chart) chart.destroy();
const colorMap = {
DEBUG: '#55596640', INFO: '#4f8ef760', WARN: '#f5a62370', ERROR: '#e8504a80', FATAL: '#c72b2b90'
};
chart = new Chart(canvas, {
type: 'bar',
data: {
labels: buckets.map(b => b.t),
datasets: ['FATAL','ERROR','WARN','INFO','DEBUG'].map(level => ({
label: level,
data: buckets.map(b => b[level]),
backgroundColor: colorMap[level],
borderColor: colorMap[level].replace(/[0-9a-f]{2}$/, 'ff'),
borderWidth: 0,
borderRadius: 1,
}))
},
options: {
responsive: true, maintainAspectRatio: false,
animation: false,
plugins: {
legend: {display: false},
tooltip: {
backgroundColor: '#131619',
borderColor: '#ffffff18',
borderWidth: 1,
titleColor: '#8b8f9a',
bodyColor: '#e8eaf0',
titleFont: {family: 'JetBrains Mono', size: 10},
bodyFont: {family: 'JetBrains Mono', size: 11},
callbacks: {
title: items => {
const d = new Date(items[0].label);
return d.toLocaleString();
}
}
},
zoom: {
zoom: {
drag: {enabled: true, backgroundColor: '#4f8ef718', borderColor: '#4f8ef760', borderWidth: 1},
mode: 'x',
onZoomComplete({chart}) {
const {min, max} = chart.scales.x;
const minT = chart.data.labels[Math.round(min)]?.getTime();
const maxT = chart.data.labels[Math.min(Math.round(max), chart.data.labels.length-1)]?.getTime();
if (minT && maxT) applyTimeFilter(minT, maxT);
}
}
}
},
scales: {
x: {
type: 'category',
stacked: true,
ticks: {display: false},
grid: {color: '#ffffff08', drawBorder: false},
border: {color: '#ffffff10'}
},
y: {
stacked: true,
ticks: {color: '#555966', font: {family: 'JetBrains Mono', size: 10}, maxTicksLimit: 4},
grid: {color: '#ffffff08', drawBorder: false},
border: {display: false}
}
}
}
});
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
clearTimeFilter();
});
}
function updateChartHighlight() {
if (!chart || !timeFilter) return;
// highlight handled by zoom plugin selection
}
// ---- Filtering ----
function applyTimeFilter(start, end) {
timeFilter = {start, end};
const startD = new Date(start), endD = new Date(end);
document.getElementById('time-badge-label').textContent =
`${fmtShort(startD)} ${fmtShort(endD)}`;
document.getElementById('time-badge').classList.add('visible');
applyFilters();
}
function clearTimeFilter() {
timeFilter = null;
document.getElementById('time-badge').classList.remove('visible');
if (chart) chart.resetZoom();
applyFilters();
}
function applyFilters() {
let regex = null;
const searchVal = document.getElementById('search').value;
const searchEl = document.getElementById('search');
if (searchVal) {
try {
regex = new RegExp(searchVal, 'i');
searchEl.classList.remove('invalid');
} catch {
searchEl.classList.add('invalid');
regex = null;
}
} else {
searchEl.classList.remove('invalid');
}
filteredLogs = allLogs.filter(log => {
if (levelFilter && log.level !== levelFilter) return false;
if (timeFilter && (log.time.getTime() < timeFilter.start || log.time.getTime() > timeFilter.end)) return false;
if (regex && !regex.test(log.message)) return false;
return true;
});
renderTable();
updateStatus();
}
// ---- Table rendering ----
function renderTable() {
const sorted = [...filteredLogs].sort((a, b) => {
let av = a[sortCol], bv = b[sortCol];
if (sortCol === 'time') { av = av.getTime(); bv = bv.getTime(); }
else { av = String(av).toLowerCase(); bv = String(bv).toLowerCase(); }
return sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
});
expandedRows.clear();
const tbody = document.getElementById('log-tbody');
tbody.innerHTML = '';
sorted.forEach((log, idx) => {
const tr = document.createElement('tr');
tr.className = 'fade-in';
tr.dataset.idx = idx;
tr.innerHTML = `
<td class="col-time">${fmtTime(log.time)}</td>
<td class="col-level"><span class="lv lv-${log.level}">${log.level}</span></td>
<td class="col-msg">${esc(log.message)}${log.exception || log.props ? ' <span style="color:var(--text3);font-size:10px">▸ details</span>' : ''}</td>
`;
tr.addEventListener('click', () => toggleDetail(tr, log, idx));
tbody.appendChild(tr);
});
// Sort indicators
document.querySelectorAll('thead th').forEach(th => {
th.classList.remove('sort-asc','sort-desc');
if (th.dataset.col === sortCol) th.classList.add(sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
});
}
function toggleDetail(tr, log, idx) {
if (expandedRows.has(idx)) {
expandedRows.delete(idx);
const detailRow = tr.nextSibling;
if (detailRow && detailRow.classList.contains('row-detail')) detailRow.remove();
return;
}
expandedRows.add(idx);
const detailTr = document.createElement('tr');
detailTr.className = 'row-detail';
let sections = '';
if (log.exception) {
sections += `<div class="detail-section"><div class="detail-label">Exception / Stack Trace</div><div class="detail-val" style="color:var(--error)">${esc(log.exception)}</div></div>`;
}
if (log.props) {
sections += `<div class="detail-section"><div class="detail-label">Properties</div><div class="detail-val">${esc(JSON.stringify(log.props, null, 2))}</div></div>`;
}
if (!sections) return;
detailTr.innerHTML = `<td colspan="3"><div class="detail-inner">${sections}</div></td>`;
tr.after(detailTr);
}
// ---- Level counts ----
function updateLevelCounts() {
const counts = {DEBUG:0,INFO:0,WARN:0,ERROR:0,FATAL:0};
allLogs.forEach(l => { if (counts[l.level] !== undefined) counts[l.level]++; });
Object.entries(counts).forEach(([lv, cnt]) => {
document.getElementById('cnt-' + lv).textContent = cnt.toLocaleString();
});
}
// ---- Status bar ----
function updateStatus() {
const s = document.getElementById('status-showing');
const t = document.getElementById('status-total');
s.textContent = `${filteredLogs.length.toLocaleString()} entries`;
t.textContent = `${allLogs.length.toLocaleString()} total`;
}
// ---- Helpers ----
function fmtTime(d) {
return d.toISOString().replace('T',' ').replace(/\.\d+Z$/,'');
}
function fmtShort(d) {
return d.toLocaleTimeString('en', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ---- Load file ----
function loadFile(file) {
const reader = new FileReader();
reader.onload = e => {
const text = e.target.result;
allLogs = parseLogs(text);
if (!allLogs.length) { allLogs = generateDemoLogs(); }
initView(file.name);
};
reader.readAsText(file);
}
function loadDemo() {
allLogs = generateDemoLogs();
initView('demo.log');
}
function initView(filename) {
levelFilter = null;
textFilter = '';
timeFilter = null;
expandedRows.clear();
document.getElementById('search').value = '';
document.getElementById('time-badge').classList.remove('visible');
document.querySelectorAll('.level-pill').forEach(p => p.classList.remove('active'));
document.getElementById('file-info').textContent = filename + ' — ' + allLogs.length.toLocaleString() + ' entries';
document.getElementById('status-file').textContent = filename;
document.getElementById('empty-state').style.display = 'none';
document.getElementById('table-area').style.display = 'flex';
document.getElementById('status-bar').style.display = 'flex';
updateLevelCounts();
filteredLogs = [...allLogs];
buildChart(allLogs);
renderTable();
updateStatus();
}
// ---- Events ----
document.getElementById('file-input').addEventListener('change', e => {
if (e.target.files[0]) loadFile(e.target.files[0]);
});
document.getElementById('search').addEventListener('input', () => applyFilters());
document.querySelectorAll('.level-pill').forEach(pill => {
pill.addEventListener('click', () => {
const lv = pill.dataset.level;
if (levelFilter === lv) {
levelFilter = null;
pill.classList.remove('active');
} else {
levelFilter = lv;
document.querySelectorAll('.level-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
}
applyFilters();
});
});
document.getElementById('time-badge').addEventListener('click', () => clearTimeFilter());
document.getElementById('clear-filters').addEventListener('click', () => {
levelFilter = null;
document.getElementById('search').value = '';
document.querySelectorAll('.level-pill').forEach(p => p.classList.remove('active'));
clearTimeFilter();
applyFilters();
});
// Sort headers
document.querySelectorAll('thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (sortCol === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else { sortCol = col; sortDir = 'asc'; }
renderTable();
});
});
// Drag & drop
const dropOverlay = document.getElementById('drop-overlay');
let dragCount = 0;
document.addEventListener('dragenter', e => {
e.preventDefault();
dragCount++;
if (dragCount === 1) dropOverlay.classList.add('active');
});
document.addEventListener('dragleave', e => {
dragCount--;
if (dragCount === 0) dropOverlay.classList.remove('active');
});
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
e.preventDefault();
dragCount = 0;
dropOverlay.classList.remove('active');
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
});
// Load demo on start
//loadDemo();
</script>
</body>
</html>