Files
LogViewer/index.html
2026-04-29 14:11:31 +07:00

1506 lines
45 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<script
src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.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;
--trace: #808080;
--debug: #555966;
--info: #4f8ef7;
--warn: #f5a623;
--error: #e8504a;
--fatal: #c72b2b;
--trace-bg: #55596610;
--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="TRACE"] {
--lc: var(--trace)
}
.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: 100px;
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-TRACE {
background: var(--trace-bg);
color: var(--trace)
}
.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
}
.log-container {
position: relative;
/* Crucial: pins the loader to this div */
width: 100%;
height: 400px;
border: 1px solid #333;
}
/* The Overlay */
.loading-overlay {
position: absolute;
inset: 0;
/* Modern shorthand for top, left, bottom, right: 0 */
background: rgba(0, 0, 0, 0.7);
/* Dim the background */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
/* Ensures it stays on top of the chart */
color: white;
transition: opacity 0.3s ease;
visibility: hidden;
/* Hidden by default */
opacity: 0;
}
/* Show state */
.loading-overlay.is-active {
visibility: visible;
opacity: 1;
}
/* Simple Spinner Animation */
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</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>viewer</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>
<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="TRACE"><span class="pill-dot"></span>Trace<span class="pill-count"
id="cnt-TRACE">0</span></button>
<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>
<div class="loading-overlay" id="loader">
<div class="spinner"></div>
<p>Loading Data...</p>
</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 = [];
const msg_len_limit = 100;
let currentPage = 1;
const PAGE_SIZE = 500;
// ---- 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, filedate = null) {
const lines = text.split(/\r?\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
/^(?<time>\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(?<lvl>DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\s+(?<msg>.+)$/i,
// [2024-01-15 10:23:45] [INFO] message
/^\[(?<time>\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s*\[(?<lvl>DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|TRACE)\]\s*(?<msg>.+)$/i,
// Jan 15 10:23:45 INFO message
/^(?<time>\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(?<lvl>DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\s+(?<msg>.+)$/i,
// INFO 2024-01-15 10:23:45 message
/^(?<lvl>DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\s+(?<time>\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+(?<msg>.+)$/i,
// 10:23:43.333 [INF] message (props)
/^(?<time>\d{2}:\d{2}:\d{2}\.\d{3})\s\[(?<lvl>INF|WRN|ERR|CRT|DBG|TRC)\]\s(?<msg>.*?)(?:\s\((?<props>".*?")\))?$/i
];
let i = 0;
debugger
while (i < lines.length) {
const line = lines[i].trim();
let matched = false;
for (const pat of patterns) {
const m = line.match(pat);
if (m) {
let timeStr = m.groups.time || null
let levelStr = translateLogLevel(m.groups.lvl || '')
let msg = m.groups.msg || null
let props = m.groups.props || null
if (filedate === null) {
time = new Date(timeStr)
}
else {
const [hours, mins, secs, ms] = timeStr.split(/[:.]/).map(Number);
let temp_date = filedate.setHours(hours, mins, secs, ms)
time = new Date(filedate)
}
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.length > msg_len_limit ? msg.slice(0, msg_len_limit) + "..." : msg.trim(),
fullmessage: msg.length > msg_len_limit ? msg : null,
props: props,
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),
TRACE: 0, 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 = {
TRACE: '#80808080', 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', 'TRACE'].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 timestamp = items[0].parsed.x;
const d = new Date(timestamp);
// Returns local date and time string
return d.toLocaleString();
}
}
},
zoom: {
zoom: {
drag: { enabled: true, backgroundColor: '#4f8ef718', borderColor: '#4f8ef760', borderWidth: 1 },
mode: 'x',
onZoomComplete({ chart }) {
const { min, max } = chart.scales.x;
if (minT && maxT) applyTimeFilter(Math.floor(min), Math.floor(max));
}
}
}
},
scales: {
x: {
type: 'time', // CRITICAL: Change from 'category' to 'time'
offset: true, // This adds space at the start/end and helps bar sizing
stacked: true,
time: {
// Force a unit if your data is consistent (e.g., every minute)
// unit: 'minute',
displayFormats: {
second: 'HH:mm:ss',
minute: 'HH:mm',
hour: 'HH:mm',
day: 'dd/MM HH:mm'
}
},
ticks: {
maxTicksLimit: 8,
color: '#555966',
font: { family: 'JetBrains Mono', size: 10 }
},
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();
showLoading()
clearTimeFilter();
});
}
function updateChartHighlight() {
if (!chart || !timeFilter) return;
// highlight handled by zoom plugin selection
}
// ---- Filtering ----
function applyTimeFilter(start, end) {
showLoading()
currentPage = 1;
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();
updateFilteredLevelCounts();
hideLoading()
}
function clearTimeFilter() {
showLoading()
currentPage = 1;
timeFilter = null;
document.getElementById('time-badge').classList.remove('visible');
if (chart) chart.resetZoom();
applyFilters();
updateLevelCounts();
hideLoading()
}
function applyFilters() {
currentPage = 1;
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) && !regex.test(log.props) && !regex.test(log.exception)) return false;
return true;
});
renderTable();
updateStatus();
updateFilteredLevelCounts();
}
// ---- 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);
});
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
currentPage = Math.min(currentPage, totalPages);
const pageData = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
expandedRows.clear();
const tbody = document.getElementById('log-tbody');
tbody.innerHTML = '';
pageData.forEach((log, idx) => {
const tr = document.createElement('tr');
tr.className = 'fade-in';
tr.dataset.idx = idx;
tr.innerHTML = `
<td class="col-time">${fmtTimeLocal(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 || log.fullmessage ? ' <span style="color:var(--text3);font-size:10px">▸ details</span>' : ''}</td>
`;
tr.addEventListener('click', () => toggleDetail(tr, log, idx));
tbody.appendChild(tr);
});
renderPager(totalPages);
// 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 renderPager(totalPages) {
let el = document.getElementById('pager');
if (!el) {
el = document.createElement('div');
el.id = 'pager';
el.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 20px;border-top:1px solid var(--border);background:var(--surface);font-size:11px;font-family:var(--font-mono);color:var(--text2);flex-shrink:0';
document.getElementById('status-bar').before(el);
}
const btn = (label, page, disabled) =>
`<button onclick="goPage(${page})" ${disabled ? 'disabled' : ''} style="padding:3px 9px;border-radius:var(--radius);border:1px solid var(--border2);background:${page === currentPage ? 'var(--accent-dim)' : 'transparent'};color:${page === currentPage ? 'var(--accent)' : 'var(--text2)'};cursor:${disabled ? 'default' : 'pointer'};font-family:var(--font-mono);font-size:11px">${label}</button>`;
const pages = [];
for (let p = 1; p <= totalPages; p++) {
if (p === 1 || p === totalPages || Math.abs(p - currentPage) <= 1) pages.push(p);
else if (pages[pages.length - 1] !== '…') pages.push('…');
}
el.innerHTML = btn('', currentPage - 1, currentPage === 1)
+ pages.map(p => p === '…' ? `<span style="color:var(--text3)">…</span>` : btn(p, p, false)).join('')
+ btn('', currentPage + 1, currentPage === totalPages)
+ `<span style="margin-left:8px;color:var(--text3)">page ${currentPage} / ${totalPages}</span>`;
}
window.goPage = function (p) {
const totalPages = Math.ceil(filteredLogs.length / PAGE_SIZE);
if (p < 1 || p > totalPages) return;
currentPage = p;
renderTable();
document.getElementById('dt-wrap').scrollTop = 0;
};
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';
const section = (label, content, color = 'var(--text2)') => `
<div class="detail-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div class="detail-label">${label}</div>
<button onclick="copySection(this, ${JSON.stringify(content).replace(/"/g, '&quot;')})"
style="padding:2px 8px;border-radius:3px;border:1px solid var(--border2);background:transparent;
color:var(--text3);font-size:10px;font-family:var(--font-mono);cursor:pointer;transition:all .15s"
onmouseover="this.style.color='var(--text)';this.style.borderColor='var(--border3)'"
onmouseout="this.style.color='var(--text3)';this.style.borderColor='var(--border2)'">
copy
</button>
</div>
<div class="detail-val" style="color:${color}">${esc(content)}</div>
</div>`;
let sections = '';
if (log.fullmessage) sections += section('Full Message', log.fullmessage);
if (log.exception) sections += section('Exception / Stack Trace', log.exception, 'var(--error)');
if (log.props) sections += section('Properties', JSON.stringify(log.props, null, 2));
if (!sections) return;
detailTr.innerHTML = `<td colspan="3"><div class="detail-inner">${sections}</div></td>`;
tr.after(detailTr);
}
// ---- Level counts ----
function updateLevelCounts() {
const counts = { TRACE: 0, 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();
});
}
function updateFilteredLevelCounts() {
const counts = { TRACE: 0, DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0, FATAL: 0 };
filteredLogs.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 fmtTimeLocal(d) {
return d.toLocaleString('sv-SE').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) {
//debugger
showLoading();
const reader = new FileReader();
reader.onload = e => {
const text = e.target.result;
var date = parseDateFromFilename(file.name);
allLogs = parseLogs(text, date);
if (!allLogs.length) { allLogs = generateDemoLogs(); }
initView(file.name);
};
reader.readAsText(file);
}
function parseDateFromFilename(filename) {
const formats = [
{ regex: /(\d{4})[-_](\d{2})[-_](\d{2})/, map: (m) => `${m[1]}-${m[2]}-${m[3]}` }, // YYYY-MM-DD
{ regex: /(\d{2})[-_](\d{2})[-_](\d{4})/, map: (m) => `${m[3]}-${m[2]}-${m[1]}` }, // DD-MM-YYYY
{ regex: /(\d{4})(\d{2})(\d{2})/, map: (m) => `${m[1]}-${m[2]}-${m[3]}` } // YYYYMMDD
];
for (const { regex, map } of formats) {
const match = filename.match(regex);
if (match) {
const isoString = map(match);
const date = new Date(isoString);
if (!isNaN(date.getTime())) return date;
}
}
return null;
}
function translateLogLevel(level) {
//DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0, FATAL: 0, total: 0
// Normalize input to handle lowercase or uppercase variations
switch (level.toLowerCase()) {
case 'trc':
return "TRACE";
case 'dbg':
return "DEBUG";
case 'inf':
return "INFO";
case 'wrn':
case 'warning': // Multiple cases can map to the same result
return "WARN";
case 'error':
return "ERROR";
case 'fatal':
case 'crt':
return "FATAL";
default:
return level; // Unknown level
}
}
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();
hideLoading();
}
const loader = document.getElementById('loader');
function showLoading() {
if (!loader.classList.contains('is-active')) {
loader.classList.add('is-active');
}
}
function hideLoading() {
loader.classList.remove('is-active');
}
// ---- 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', () => {
//showLoading()
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);
});
window.copySection = function (btn, text) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = 'copied ✓';
btn.style.color = 'var(--info)';
btn.style.borderColor = 'var(--info)';
setTimeout(() => {
btn.textContent = orig;
btn.style.color = 'var(--text3)';
btn.style.borderColor = 'var(--border2)';
}, 1500);
});
};
// Load demo on start
//loadDemo();
</script>
</body>
</html>