Files
LogViewer/index.html
2026-04-29 10:30:49 +07:00

1458 lines
43 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>
<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: 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-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="log-container">
<!-- 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="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>
</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;
// ---- 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
/^(\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,
// 10:23:43.333 [INF] message
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s\[(INF|WRN|ERR|CRT|DBG|TRC)\]\s(.*)$/
];
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) {
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;
}
levelStr = translateLogLevel(levelStr)
let time
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: 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),
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 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: {
// Grouping: Limit the number of labels shown to prevent overlapping
maxTicksLimit: 15,
// Formatting
// callback: function (val, index) {
// // Assuming your data labels are Date objects or Date strings
// const date = new Date(this.getLabelForValue(val));
// // Returns "HH:mm:ss" in local time
// return date.toLocaleTimeString('sv-SE');
// }
callback: function (val, index) {
const labels = this.chart.data.labels;
const date = new Date(labels[index]);
// Check if the first and last labels are on the same day
const firstDate = new Date(labels[0]).toDateString();
const lastDate = new Date(labels[labels.length - 1]).toDateString();
const isSameDay = firstDate === lastDate;
// Format based on the result
if (isSameDay) {
// Returns: HH:mm:ss
return date.toLocaleTimeString('sv-SE');
} else {
// Returns: DD/MM HH:mm:ss
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const time = date.toLocaleTimeString('sv-SE');
return `${day}/${month} ${time}`;
}
}
},
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()
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()
timeFilter = null;
document.getElementById('time-badge').classList.remove('visible');
if (chart) chart.resetZoom();
applyFilters();
updateLevelCounts();
hideLoading()
}
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">${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);
});
// 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.fullmessage) {
sections += `<div class="detail-section"><div class="detail-label">Full Message</div><div class="detail-val">${esc(log.fullmessage)}</div></div>`;
}
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 = { 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);
});
// Load demo on start
//loadDemo();
</script>
</body>
</html>