1520 lines
46 KiB
HTML
1520 lines
46 KiB
HTML
<!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="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 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()
|
||
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, '"')})"
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
// ---- 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> |