2026-04-29 08:16:15 +07:00
<!DOCTYPE html>
< html lang = "en" >
2026-04-29 09:31:32 +07:00
2026-04-29 08:16:15 +07:00
< head >
2026-04-29 09:31:32 +07:00
< 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
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
: root {
--bg : #0d0f12 ;
--surface : #131619 ;
--surface2 : #191c21 ;
--surface3 : #1e2128 ;
--border : #ffffff 0 f ;
--border2 : #ffffff 18 ;
--border3 : #ffffff 28 ;
--text : #e8eaf0 ;
--text2 : #8b8f9a ;
--text3 : #555966 ;
--accent : #4f8ef7 ;
--accent-dim : #4f8ef7 20 ;
2026-04-29 09:43:59 +07:00
--trace : #808080 ;
2026-04-29 09:31:32 +07:00
--debug : #555966 ;
--info : #4f8ef7 ;
--warn : #f5a623 ;
--error : #e8504a ;
--fatal : #c72b2b ;
--trace-bg : #555966 10 ;
--debug-bg : #555966 10 ;
--info-bg : #4f8ef7 10 ;
--warn-bg : #f5a623 10 ;
--error-bg : #e8504a 10 ;
--fatal-bg : #c72b2b 18 ;
--radius : 6 px ;
--radius-lg : 10 px ;
--font-mono : 'JetBrains Mono' , monospace ;
--font-sans : 'DM Sans' , sans-serif ;
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
html ,
body {
height : 100 % ;
overflow : hidden
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
body {
background : var ( -- bg );
color : var ( -- text );
font-family : var ( -- font - sans );
font-size : 13 px ;
display : flex ;
flex-direction : column
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
/* Drop overlay */
# drop-overlay {
position : fixed ;
inset : 0 ;
z-index : 9999 ;
background : #0d0f12 f0 ;
backdrop-filter : blur ( 8 px );
display : none ;
align-items : center ;
justify-content : center ;
flex-direction : column ;
gap : 20 px ;
border : 2 px dashed var ( -- accent );
pointer-events : none ;
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
# drop-overlay . active {
display : flex ;
pointer-events : all
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
# drop-overlay . drop-icon {
width : 64 px ;
height : 64 px ;
opacity : .6 ;
animation : pulse 1.4 s ease-in-out infinite
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
@ keyframes pulse {
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
0 %,
100 % {
transform : scale ( 1 );
opacity : .6
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
50 % {
transform : scale ( 1.08 );
opacity : 1
}
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
# drop-overlay . drop-label {
font-size : 18 px ;
font-weight : 500 ;
color : var ( -- text );
letter-spacing : .01 em
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
# drop-overlay . drop-sub {
font-size : 13 px ;
color : var ( -- text2 )
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
/* Header */
# header {
display : flex ;
align-items : center ;
gap : 16 px ;
padding : 10 px 20 px ;
border-bottom : 1 px solid var ( -- border );
background : var ( -- surface );
flex-shrink : 0 ;
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
# logo {
font-family : var ( -- font - mono );
font-size : 15 px ;
font-weight : 500 ;
color : var ( -- text );
letter-spacing : -.02 em
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
# logo span {
color : var ( -- accent )
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
# file-info {
font-family : var ( -- font - mono );
font-size : 11 px ;
color : var ( -- text3 );
flex : 1 ;
white-space : nowrap ;
overflow : hidden ;
text-overflow : ellipsis
}
# file-btn {
display : flex ;
align-items : center ;
gap : 7 px ;
background : var ( -- surface3 );
border : 1 px solid var ( -- border2 );
border-radius : var ( -- radius );
color : var ( -- text2 );
font-family : var ( -- font - sans );
font-size : 12 px ;
font-weight : 500 ;
padding : 6 px 12 px ;
cursor : pointer ;
transition : all .15 s ;
white-space : nowrap ;
}
# file-btn : hover {
border-color : var ( -- border3 );
color : var ( -- text );
background : var ( -- surface2 )
}
# file-input {
display : none
}
. hdr-sep {
width : 1 px ;
height : 20 px ;
background : var ( -- border )
}
/* Filter bar */
# filter-bar {
display : flex ;
align-items : center ;
gap : 10 px ;
padding : 8 px 20 px ;
border-bottom : 1 px solid var ( -- border );
background : var ( -- surface );
flex-shrink : 0 ;
}
# search-wrap {
position : relative ;
flex : 1 ;
max-width : 420 px
}
# search-wrap svg {
position : absolute ;
left : 10 px ;
top : 50 % ;
transform : translateY ( -50 % );
opacity : .4 ;
pointer-events : none
}
# search {
width : 100 % ;
background : var ( -- surface3 );
border : 1 px solid var ( -- border2 );
border-radius : var ( -- radius );
color : var ( -- text );
font-family : var ( -- font - mono );
font-size : 12 px ;
padding : 6 px 10 px 6 px 32 px ;
outline : none ;
transition : border-color .15 s ;
}
# search : focus {
border-color : var ( -- accent );
box-shadow : 0 0 0 3 px var ( -- accent - dim )
}
# search . invalid {
border-color : var ( -- error );
box-shadow : 0 0 0 3 px #e8504a 18
}
# search :: placeholder {
color : var ( -- text3 )
}
# regex-badge {
position : absolute ;
right : 8 px ;
top : 50 % ;
transform : translateY ( -50 % );
font-family : var ( -- font - mono );
font-size : 10 px ;
padding : 2 px 5 px ;
border-radius : 3 px ;
background : var ( -- accent - dim );
color : var ( -- accent );
opacity : .7 ;
}
/* Level pills */
. level-pills {
display : flex ;
gap : 6 px ;
flex-wrap : wrap
}
. level-pill {
display : flex ;
align-items : center ;
gap : 6 px ;
padding : 5 px 10 px ;
border-radius : 20 px ;
border : 1 px solid var ( -- border2 );
background : transparent ;
cursor : pointer ;
font-family : var ( -- font - sans );
font-size : 11 px ;
font-weight : 500 ;
color : var ( -- text2 );
transition : all .15 s ;
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 : 6 px ;
height : 6 px ;
border-radius : 50 % ;
background : var ( -- lc , var ( -- text3 ));
flex-shrink : 0
}
. pill-count {
font-family : var ( -- font - mono );
font-size : 10 px ;
opacity : .7
}
. filter-actions {
display : flex ;
gap : 6 px ;
margin-left : auto
}
# clear-filters {
padding : 5 px 10 px ;
border-radius : var ( -- radius );
border : 1 px solid var ( -- border2 );
background : transparent ;
color : var ( -- text3 );
font-size : 11 px ;
font-family : var ( -- font - sans );
cursor : pointer ;
transition : all .15 s ;
}
# clear-filters : hover {
color : var ( -- text );
border-color : var ( -- border3 )
}
# time-badge {
display : none ;
align-items : center ;
gap : 5 px ;
padding : 5 px 10 px ;
border-radius : var ( -- radius );
background : var ( -- accent - dim );
border : 1 px solid var ( -- accent );
color : var ( -- accent );
font-size : 11 px ;
font-family : var ( -- font - mono );
cursor : pointer ;
}
# time-badge . visible {
display : flex
}
# time-badge : hover {
background : #4f8ef7 30
}
# time-badge . tx-close {
opacity : .6 ;
margin-left : 2 px
}
/* Main layout */
# main {
display : flex ;
flex-direction : column ;
flex : 1 ;
overflow : hidden
}
/* Chart panel */
# chart-panel {
flex-shrink : 0 ;
padding : 16 px 20 px 12 px ;
border-bottom : 1 px solid var ( -- border );
}
# chart-wrap {
position : relative ;
height : 100 px
}
# chart-hint {
position : absolute ;
top : 4 px ;
right : 0 ;
font-size : 10 px ;
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 20 px 20 px
}
table # log-table {
width : 100 % ;
border-collapse : collapse ;
font-family : var ( -- font - mono );
font-size : 11.5 px
}
table # log-table thead th {
position : sticky ;
top : 0 ;
z-index : 10 ;
background : var ( -- surface );
border-bottom : 1 px solid var ( -- border2 );
padding : 10 px 12 px ;
text-align : left ;
font-weight : 500 ;
color : var ( -- text2 );
font-size : 11 px ;
letter-spacing : .05 em ;
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 : 1 px solid var ( -- border );
cursor : pointer ;
transition : background .1 s
}
table # log-table tbody tr : hover > td {
background : var ( -- surface2 )
}
table # log-table tbody td {
padding : 7 px 12 px ;
vertical-align : top
}
. col-time {
width : 180 px ;
color : var ( -- text3 );
white-space : nowrap
}
. col-level {
width : 72 px
}
. col-msg {
word-break : break-word
}
/* Level badges */
. lv {
display : inline-block ;
padding : 2 px 7 px ;
border-radius : 3 px ;
font-size : 10 px ;
font-weight : 500 ;
letter-spacing : .04 em
}
. 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 : 2 px solid var ( -- accent ) !important
}
. row-detail td {
padding : 0 !important
}
. detail-inner {
padding : 12 px 16 px 16 px ;
display : flex ;
flex-direction : column ;
gap : 12 px
}
. detail-section {
display : flex ;
flex-direction : column ;
gap : 6 px
}
. detail-label {
font-size : 10 px ;
font-weight : 500 ;
letter-spacing : .08 em ;
text-transform : uppercase ;
color : var ( -- text3 )
}
. detail-val {
background : var ( -- surface3 );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius );
padding : 10 px 12 px ;
font-family : var ( -- font - mono );
font-size : 11 px ;
color : var ( -- text2 );
white-space : pre-wrap ;
word-break : break-all ;
max-height : 200 px ;
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 : 16 px ;
color : var ( -- text3 );
}
# empty-state svg {
opacity : .3
}
# empty-state . es-title {
font-size : 15 px ;
font-weight : 500 ;
color : var ( -- text2 )
}
# empty-state . es-sub {
font-size : 12 px ;
text-align : center ;
max-width : 280 px ;
line-height : 1.6
}
# empty-state . es-btn {
margin-top : 8 px ;
padding : 8 px 20 px ;
border-radius : var ( -- radius );
background : var ( -- accent - dim );
border : 1 px solid var ( -- accent );
color : var ( -- accent );
font-size : 13 px ;
font-family : var ( -- font - sans );
cursor : pointer ;
transition : all .15 s ;
}
# empty-state . es-btn : hover {
background : #4f8ef7 30
}
/* Table status bar */
# status-bar {
display : flex ;
align-items : center ;
gap : 12 px ;
padding : 7 px 20 px ;
border-top : 1 px solid var ( -- border );
background : var ( -- surface );
flex-shrink : 0 ;
font-size : 11 px ;
color : var ( -- text3 );
font-family : var ( -- font - mono );
}
# status-bar . status-count {
color : var ( -- text2 )
}
. sb-sep {
color : var ( -- border3 )
}
/* Scrollbar */
:: -webkit-scrollbar {
width : 6 px ;
height : 6 px
}
:: -webkit-scrollbar-track {
background : transparent
}
:: -webkit-scrollbar-thumb {
background : var ( -- border2 );
border-radius : 3 px
}
:: -webkit-scrollbar-thumb : hover {
background : var ( -- border3 )
}
/* Animations */
@ keyframes fadeIn {
from {
opacity : 0 ;
transform : translateY ( 4 px )
}
to {
opacity : 1 ;
transform : translateY ( 0 )
}
}
. fade-in {
animation : fadeIn .2 s ease forwards
}
2026-04-29 10:30:49 +07:00
. log-container {
position : relative ;
/* Crucial: pins the loader to this div */
width : 100 % ;
height : 400 px ;
border : 1 px 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.3 s ease ;
visibility : hidden ;
/* Hidden by default */
opacity : 0 ;
}
/* Show state */
. loading-overlay . is-active {
visibility : visible ;
opacity : 1 ;
}
/* Simple Spinner Animation */
. spinner {
width : 40 px ;
height : 40 px ;
border : 4 px solid rgba ( 255 , 255 , 255 , 0.3 );
border-top-color : #3498db ;
border-radius : 50 % ;
animation : spin 1 s linear infinite ;
margin-bottom : 10 px ;
}
@ keyframes spin {
to {
transform : rotate ( 360 deg );
}
}
2026-04-29 09:31:32 +07:00
</ style >
2026-04-29 08:16:15 +07:00
</ head >
2026-04-29 09:31:32 +07:00
2026-04-29 08:16:15 +07:00
< body >
2026-04-29 09:31:32 +07:00
<!-- 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" />
2026-04-29 08:16:15 +07:00
</ svg >
2026-04-29 09:31:32 +07:00
< div class = "drop-label" > Drop log file here</ div >
< div class = "drop-sub" > Supports .log, .txt, .json and plain text</ div >
2026-04-29 08:16:15 +07:00
</ div >
2026-04-29 09:31:32 +07:00
<!-- Header -->
< div id = "header" >
2026-04-29 10:30:49 +07:00
< div id = "logo" > log< span > viewer</ span ></ div >
2026-04-29 09:31:32 +07:00
< 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 >
2026-04-29 11:08:00 +07:00
< 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" />
2026-04-29 09:31:32 +07:00
</ svg >
2026-04-29 11:08:00 +07:00
< span id = "time-badge-label" > time range</ span >
< span class = "tx-close" > ✕</ span >
2026-04-29 09:31:32 +07:00
</ div >
2026-04-29 11:08:00 +07:00
< 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 >
2026-04-29 09:31:32 +07:00
</ div >
</ div >
2026-04-29 11:08:00 +07:00
<!-- 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 >
2026-04-29 09:31:32 +07:00
</ div >
</ div >
2026-04-29 11:08:00 +07:00
<!-- 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 >
2026-04-29 09:31:32 +07:00
</ div >
2026-04-29 08:16:15 +07:00
</ div >
2026-04-29 11:08:00 +07:00
< div class = "loading-overlay" id = "loader" >
< div class = "spinner" ></ div >
< p > Loading Data...</ p >
</ div >
2026-04-29 09:31:32 +07:00
< 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 = [];
2026-04-29 10:30:49 +07:00
const msg_len_limit = 100 ;
2026-04-29 11:08:00 +07:00
let currentPage = 1 ;
const PAGE_SIZE = 500 ;
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
// ---- 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 );
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
// ---- Log parsing ----
2026-04-29 09:43:59 +07:00
function parseLogs ( text , filedate = null ) {
const lines = text . split ( /\r?\n/ ). filter ( l => l . trim ());
2026-04-29 09:31:32 +07:00
const logs = [];
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
// Try JSON Lines
const jsonLogs = tryParseJSONLines ( lines );
if ( jsonLogs . length > lines . length * 0.5 ) return jsonLogs ;
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
// Try common log formats
const patterns = [
// 2024-01-15T10:23:45.123Z INFO message
2026-04-29 11:46:23 +07:00
/^(?<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 ,
2026-04-29 09:31:32 +07:00
// [2024-01-15 10:23:45] [INFO] message
2026-04-29 11:46:23 +07:00
/^\[(?<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 ,
2026-04-29 09:31:32 +07:00
// Jan 15 10:23:45 INFO message
2026-04-29 11:46:23 +07:00
/^(?<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 ,
2026-04-29 09:31:32 +07:00
// INFO 2024-01-15 10:23:45 message
2026-04-29 11:46:23 +07:00
/^(?<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
2026-04-29 09:31:32 +07:00
];
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
let i = 0 ;
2026-04-29 11:46:23 +07:00
debugger
2026-04-29 09:31:32 +07:00
while ( i < lines . length ) {
const line = lines [ i ]. trim ();
let matched = false ;
for ( const pat of patterns ) {
const m = line . match ( pat );
2026-04-29 11:46:23 +07:00
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
2026-04-29 09:43:59 +07:00
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 )
}
2026-04-29 09:31:32 +07:00
if ( ! isNaN ( time )) {
2026-04-29 11:54:00 +07:00
2026-04-29 09:31:32 +07:00
// 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 ++ ;
}
2026-04-29 11:46:23 +07:00
2026-04-29 09:31:32 +07:00
i = j - 1 ;
2026-04-29 10:30:49 +07:00
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 ,
2026-04-29 11:46:23 +07:00
props : props ,
2026-04-29 10:30:49 +07:00
exception : exception || null
});
2026-04-29 09:31:32 +07:00
matched = true ;
break ;
2026-04-29 08:16:15 +07:00
}
}
}
2026-04-29 09:31:32 +07:00
if ( ! matched ) {
// Plain line, treat as INFO
if ( line . length > 0 ) {
logs . push ({ time : new Date (), level : 'INFO' , message : line , props : null , exception : null });
}
2026-04-29 08:16:15 +07:00
}
2026-04-29 09:31:32 +07:00
i ++ ;
2026-04-29 08:16:15 +07:00
}
2026-04-29 09:31:32 +07:00
return logs . sort (( a , b ) => a . time - b . time );
2026-04-29 08:16:15 +07:00
}
2026-04-29 09:31:32 +07:00
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 ;
2026-04-29 10:30:49 +07:00
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
});
2026-04-29 09:31:32 +07:00
} catch { }
}
return logs ;
2026-04-29 08:16:15 +07:00
}
2026-04-29 09:31:32 +07:00
// ---- 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 ;
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
const buckets = Array . from ({ length : BUCKETS }, ( _ , i ) => ({
t : new Date ( minT + i * bucketSize ),
2026-04-29 09:43:59 +07:00
TRACE : 0 , DEBUG : 0 , INFO : 0 , WARN : 0 , ERROR : 0 , FATAL : 0 , total : 0
2026-04-29 09:31:32 +07:00
}));
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
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 ;
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
if ( chart ) chart . destroy ();
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
const colorMap = {
2026-04-29 09:43:59 +07:00
TRACE : '#80808080' , DEBUG : '#55596640' , INFO : '#4f8ef760' , WARN : '#f5a62370' , ERROR : '#e8504a80' , FATAL : '#c72b2b90'
2026-04-29 09:31:32 +07:00
};
chart = new Chart ( canvas , {
type : 'bar' ,
data : {
labels : buckets . map ( b => b . t ),
2026-04-29 09:43:59 +07:00
datasets : [ 'FATAL' , 'ERROR' , 'WARN' , 'INFO' , 'DEBUG' , 'TRACE' ]. map ( level => ({
2026-04-29 09:31:32 +07:00
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 ,
2026-04-29 10:30:49 +07:00
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 } ` ;
}
}
},
2026-04-29 09:31:32 +07:00
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 ();
2026-04-29 10:30:49 +07:00
showLoading ()
2026-04-29 09:31:32 +07:00
clearTimeFilter ();
});
}
function updateChartHighlight () {
if ( ! chart || ! timeFilter ) return ;
// highlight handled by zoom plugin selection
}
// ---- Filtering ----
function applyTimeFilter ( start , end ) {
2026-04-29 10:30:49 +07:00
showLoading ()
2026-04-29 11:08:00 +07:00
currentPage = 1 ;
2026-04-29 09:31:32 +07:00
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 ();
2026-04-29 10:30:49 +07:00
updateFilteredLevelCounts ();
hideLoading ()
2026-04-29 09:31:32 +07:00
}
function clearTimeFilter () {
2026-04-29 10:30:49 +07:00
showLoading ()
2026-04-29 11:08:00 +07:00
currentPage = 1 ;
2026-04-29 09:31:32 +07:00
timeFilter = null ;
document . getElementById ( 'time-badge' ). classList . remove ( 'visible' );
if ( chart ) chart . resetZoom ();
applyFilters ();
2026-04-29 10:30:49 +07:00
updateLevelCounts ();
hideLoading ()
2026-04-29 09:31:32 +07:00
}
function applyFilters () {
2026-04-29 11:08:00 +07:00
currentPage = 1 ;
2026-04-29 09:31:32 +07:00
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 );
});
2026-04-29 11:08:00 +07:00
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 );
2026-04-29 09:31:32 +07:00
expandedRows . clear ();
const tbody = document . getElementById ( 'log-tbody' );
tbody . innerHTML = '' ;
2026-04-29 11:08:00 +07:00
pageData . forEach (( log , idx ) => {
2026-04-29 09:31:32 +07:00
const tr = document . createElement ( 'tr' );
tr . className = 'fade-in' ;
tr . dataset . idx = idx ;
tr . innerHTML = `
2026-04-29 10:30:49 +07:00
<td class="col-time"> ${ fmtTimeLocal ( log . time ) } </td>
2026-04-29 08:16:15 +07:00
<td class="col-level"><span class="lv lv- ${ log . level } "> ${ log . level } </span></td>
2026-04-29 10:30:49 +07:00
<td class="col-msg"> ${ esc ( log . message ) }${ log . exception || log . props || log . fullmessage ? ' <span style="color:var(--text3);font-size:10px">▸ details</span>' : '' } </td>
2026-04-29 08:16:15 +07:00
` ;
2026-04-29 09:31:32 +07:00
tr . addEventListener ( 'click' , () => toggleDetail ( tr , log , idx ));
tbody . appendChild ( tr );
});
2026-04-29 08:16:15 +07:00
2026-04-29 11:08:00 +07:00
renderPager ( totalPages );
2026-04-29 09:31:32 +07:00
// 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' );
});
2026-04-29 08:16:15 +07:00
}
2026-04-29 11:08:00 +07:00
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 ;
};
2026-04-29 09:31:32 +07:00
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' ;
2026-04-29 11:54:00 +07:00
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>` ;
2026-04-29 09:31:32 +07:00
let sections = '' ;
2026-04-29 11:54:00 +07:00
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 ));
2026-04-29 09:31:32 +07:00
if ( ! sections ) return ;
2026-04-29 11:54:00 +07:00
2026-04-29 09:31:32 +07:00
detailTr . innerHTML = `<td colspan="3"><div class="detail-inner"> ${ sections } </div></td>` ;
tr . after ( detailTr );
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
// ---- 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 ();
});
}
2026-04-29 08:16:15 +07:00
2026-04-29 10:30:49 +07:00
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 ();
});
}
2026-04-29 09:31:32 +07:00
// ---- 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` ;
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
// ---- Helpers ----
function fmtTime ( d ) {
return d . toISOString (). replace ( 'T' , ' ' ). replace ( /\.\d+Z$/ , '' );
}
2026-04-29 10:30:49 +07:00
function fmtTimeLocal ( d ) {
return d . toLocaleString ( 'sv-SE' ). replace ( 'T' , ' ' ). replace ( /\.\d+Z$/ , '' );
}
2026-04-29 09:31:32 +07:00
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 , '>' );
}
2026-04-29 08:16:15 +07:00
2026-04-29 09:31:32 +07:00
// ---- Load file ----
function loadFile ( file ) {
//debugger
2026-04-29 10:30:49 +07:00
showLoading ();
2026-04-29 09:31:32 +07:00
const reader = new FileReader ();
reader . onload = e => {
const text = e . target . result ;
2026-04-29 09:43:59 +07:00
var date = parseDateFromFilename ( file . name );
allLogs = parseLogs ( text , date );
2026-04-29 09:31:32 +07:00
if ( ! allLogs . length ) { allLogs = generateDemoLogs (); }
initView ( file . name );
};
reader . readAsText ( file );
2026-04-29 09:43:59 +07:00
}
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 ;
}
2026-04-29 09:31:32 +07:00
}
2026-04-29 09:43:59 +07:00
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
}
}
2026-04-29 09:31:32 +07:00
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 ();
2026-04-29 10:30:49 +07:00
hideLoading ();
2026-04-29 09:31:32 +07:00
}
2026-04-29 10:30:49 +07:00
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' );
}
2026-04-29 11:08:00 +07:00
2026-04-29 09:31:32 +07:00
// ---- 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' , () => {
2026-04-29 10:30:49 +07:00
//showLoading()
2026-04-29 09:31:32 +07:00
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 );
});
2026-04-29 09:43:59 +07:00
2026-04-29 11:54:00 +07:00
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 );
});
};
2026-04-29 09:31:32 +07:00
// Load demo on start
2026-04-29 10:30:49 +07:00
//loadDemo();
2026-04-29 09:31:32 +07:00
</ script >
2026-04-29 08:16:15 +07:00
</ body >
2026-04-29 09:31:32 +07:00
</ html >