logviewer 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +16 -4
- data/lib/logviewer/version.rb +2 -2
- data/lib/logviewer.rb +83 -34
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 30beebf819f5a18b9bb45348834b44355863b4ed2bb79919bf6f4a41d53197ad
|
4
|
+
data.tar.gz: 6bb17ee176a9073103ff445b41d1bc5eb9ee692454d8f0352bfa1244749935d0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 83a9e3b2764aeb230c253f57fde5ea4c9d9f1175f7fdb3cadd8d8bd11a3d79ff412a7eecf0033ba4cccd77c877247034711a0229abe480cf1cbfbe94589bc7d9
|
7
|
+
data.tar.gz: f1eb44fce63eca302764c1066e4c17f27d26483e8fd8190aa80e13eb5f44025550140a1976485a15b029ed88d181948eecc9eacab7c18598f1154ead441fac29
|
data/README.md
CHANGED
@@ -11,7 +11,7 @@ A Ruby gem that converts NDJSON log files into a readable HTML format for easy v
|
|
11
11
|
- Simplified file paths (shows only filename, not full path)
|
12
12
|
- Color-coded log levels for easy identification
|
13
13
|
- Large, readable fonts throughout the interface (18px base size)
|
14
|
-
- Interactive dynamic filtering by log level in the browser
|
14
|
+
- Interactive dynamic filtering by log level and tags in the browser
|
15
15
|
- Dark mode interface with optimized colors for comfortable viewing
|
16
16
|
- Responsive design that works well in any browser
|
17
17
|
- Automatically opens the generated HTML file in your default browser
|
@@ -107,6 +107,7 @@ The generated HTML file will be saved in `/tmp/` with a timestamp and automatica
|
|
107
107
|
|
108
108
|
- A wide, responsive table layout (1800px max width) with columns in order: date, level, tag, file, function, text
|
109
109
|
- Interactive log level filtering dropdown for dynamic filtering in the browser
|
110
|
+
- Multi-select tag filter to show only specific subsystem/category combinations
|
110
111
|
- Dark mode theme with comfortable dark backgrounds and light text
|
111
112
|
- Human-readable timestamps (MM/DD HH:MM:SS format)
|
112
113
|
- Color-coded log levels optimized for dark backgrounds
|
@@ -121,12 +122,23 @@ The generated HTML file will be saved in `/tmp/` with a timestamp and automatica
|
|
121
122
|
## Interactive Features
|
122
123
|
|
123
124
|
Once the HTML file opens in your browser, you can:
|
124
|
-
|
125
|
+
|
126
|
+
### Log Level Filtering
|
127
|
+
- Use the level dropdown in the header to dynamically filter log entries by minimum level
|
125
128
|
- Filter changes are applied instantly without page reload
|
126
|
-
- Entry counts update automatically to show how many entries match the current filter
|
127
129
|
- Command line level controls what entries are included in the HTML file
|
128
130
|
- Browser initially shows debug+ level by default, regardless of command line level
|
129
|
-
|
131
|
+
|
132
|
+
### Tag Filtering
|
133
|
+
- Use the multi-select tag filter to choose which subsystem/category combinations to display
|
134
|
+
- All tags are selected by default (showing all entries)
|
135
|
+
- Use "Select All" and "Clear All" buttons for quick selection changes
|
136
|
+
- Tags are built from subsystem/category fields (e.g., "Play/avPlayer", "Database/queue")
|
137
|
+
|
138
|
+
### Combined Filtering
|
139
|
+
- Log level and tag filters work together - entries must match both criteria to be visible
|
140
|
+
- Entry counts update automatically to show how many entries match the current filters
|
141
|
+
- Header shows selected filter status including number of tags when filtered
|
130
142
|
|
131
143
|
## Development
|
132
144
|
|
data/lib/logviewer/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
1
|
module LogViewer
|
2
|
-
VERSION = "2.
|
3
|
-
end
|
2
|
+
VERSION = "2.2.0"
|
3
|
+
end
|
data/lib/logviewer.rb
CHANGED
@@ -2,6 +2,7 @@ require 'json'
|
|
2
2
|
require 'optparse'
|
3
3
|
require 'fileutils'
|
4
4
|
require 'time'
|
5
|
+
require 'set'
|
5
6
|
require_relative 'logviewer/version'
|
6
7
|
|
7
8
|
module LogViewer
|
@@ -25,7 +26,7 @@ module LogViewer
|
|
25
26
|
def parse_options
|
26
27
|
OptionParser.new do |opts|
|
27
28
|
opts.banner = "Usage: logviewer [options] [ndjson_file]"
|
28
|
-
|
29
|
+
|
29
30
|
opts.on('-l', '--level LEVEL', 'Minimum log level (trace, debug, info, notice, warning, error, critical)') do |level|
|
30
31
|
level = level.downcase
|
31
32
|
if LOG_LEVELS.key?(level)
|
@@ -36,18 +37,18 @@ module LogViewer
|
|
36
37
|
exit 1
|
37
38
|
end
|
38
39
|
end
|
39
|
-
|
40
|
+
|
40
41
|
opts.on('-v', '--version', 'Show version') do
|
41
42
|
puts "logviewer #{LogViewer::VERSION}"
|
42
43
|
exit
|
43
44
|
end
|
44
|
-
|
45
|
+
|
45
46
|
opts.on('-h', '--help', 'Show this help message') do
|
46
47
|
puts opts
|
47
48
|
exit
|
48
49
|
end
|
49
50
|
end.parse!(@args)
|
50
|
-
|
51
|
+
|
51
52
|
if @args.empty?
|
52
53
|
@input_file = find_most_recent_ndjson_file
|
53
54
|
if @input_file.nil?
|
@@ -59,7 +60,7 @@ module LogViewer
|
|
59
60
|
else
|
60
61
|
@input_file = @args[0]
|
61
62
|
end
|
62
|
-
|
63
|
+
|
63
64
|
unless File.exist?(@input_file)
|
64
65
|
puts "Error: File not found: #{@input_file}"
|
65
66
|
exit 1
|
@@ -69,7 +70,7 @@ module LogViewer
|
|
69
70
|
def find_most_recent_ndjson_file
|
70
71
|
ndjson_files = Dir.glob('*.ndjson')
|
71
72
|
return nil if ndjson_files.empty?
|
72
|
-
|
73
|
+
|
73
74
|
# Sort by modification time (most recent first) and return the first one
|
74
75
|
ndjson_files.max_by { |file| File.mtime(file) }
|
75
76
|
end
|
@@ -81,18 +82,20 @@ module LogViewer
|
|
81
82
|
|
82
83
|
def parse_logs
|
83
84
|
logs = []
|
84
|
-
|
85
|
+
@all_tags = Set.new
|
86
|
+
|
85
87
|
File.foreach(@input_file) do |line|
|
86
88
|
begin
|
87
89
|
log_entry = JSON.parse(line.strip)
|
88
|
-
|
90
|
+
|
89
91
|
if should_include_log?(log_entry['levelName'])
|
90
92
|
# Build tag from subsystem/category
|
91
93
|
tag = []
|
92
94
|
tag << log_entry['subsystem'] if log_entry['subsystem']
|
93
95
|
tag << log_entry['category'] if log_entry['category']
|
94
96
|
tag_string = tag.join('/')
|
95
|
-
|
97
|
+
@all_tags.add(tag_string) unless tag_string.empty?
|
98
|
+
|
96
99
|
logs << {
|
97
100
|
timestamp: log_entry['timestamp'] || '',
|
98
101
|
level: log_entry['levelName'] || 'unknown',
|
@@ -106,7 +109,7 @@ module LogViewer
|
|
106
109
|
puts "Warning: Skipping invalid JSON line: #{e.message}"
|
107
110
|
end
|
108
111
|
end
|
109
|
-
|
112
|
+
|
110
113
|
logs
|
111
114
|
end
|
112
115
|
|
@@ -133,7 +136,7 @@ module LogViewer
|
|
133
136
|
|
134
137
|
def format_timestamp(timestamp)
|
135
138
|
return '' if timestamp.nil? || timestamp == ''
|
136
|
-
|
139
|
+
|
137
140
|
begin
|
138
141
|
# Convert milliseconds to seconds for Time.at
|
139
142
|
time = Time.at(timestamp / 1000.0)
|
@@ -284,6 +287,17 @@ module LogViewer
|
|
284
287
|
|
285
288
|
html += <<~HTML
|
286
289
|
</select>
|
290
|
+
|
291
|
+
<div style="margin-top: 10px;">
|
292
|
+
<label for="tagFilter" style="color: white; margin-right: 10px;">Filter by tags:</label>
|
293
|
+
<select id="tagFilter" multiple style="padding: 5px; font-size: 14px; border-radius: 4px; border: none; background-color: #3a3a3a; color: #f0f0f0; min-height: 100px; width: 300px;">
|
294
|
+
#{@all_tags.sort.map { |tag| " <option value=\"#{tag}\" selected>#{tag}</option>" }.join("\n")}
|
295
|
+
</select>
|
296
|
+
<div style="margin-top: 5px; font-size: 12px; color: #ccc;">
|
297
|
+
<button id="selectAllTags" style="padding: 3px 8px; margin-right: 5px; background: #5a5a5a; color: white; border: none; border-radius: 3px; cursor: pointer;">Select All</button>
|
298
|
+
<button id="clearAllTags" style="padding: 3px 8px; background: #5a5a5a; color: white; border: none; border-radius: 3px; cursor: pointer;">Clear All</button>
|
299
|
+
</div>
|
300
|
+
</div>
|
287
301
|
</div>
|
288
302
|
</div>
|
289
303
|
<div class="table-container">
|
@@ -310,9 +324,9 @@ module LogViewer
|
|
310
324
|
filename = extract_filename(log[:file])
|
311
325
|
file_content = filename.empty? ? '<span class="empty">-</span>' : filename
|
312
326
|
method_content = log[:method].empty? ? '<span class="empty">-</span>' : log[:method]
|
313
|
-
|
327
|
+
|
314
328
|
html += <<~HTML
|
315
|
-
<tr data-level="#{log[:level].downcase}" data-level-num="#{LOG_LEVELS[log[:level].downcase] || 0}">
|
329
|
+
<tr data-level="#{log[:level].downcase}" data-level-num="#{LOG_LEVELS[log[:level].downcase] || 0}" data-tag="#{log[:tag]}">
|
316
330
|
<td class="timestamp">#{timestamp_content}</td>
|
317
331
|
<td class="level" style="#{level_style}">#{log[:level]}</td>
|
318
332
|
<td class="tag">#{tag_content}</td>
|
@@ -328,7 +342,7 @@ module LogViewer
|
|
328
342
|
</table>
|
329
343
|
</div>
|
330
344
|
</div>
|
331
|
-
|
345
|
+
|
332
346
|
<script>
|
333
347
|
const LOG_LEVELS = {
|
334
348
|
'trace': 0,
|
@@ -339,40 +353,75 @@ module LogViewer
|
|
339
353
|
'error': 5,
|
340
354
|
'critical': 6
|
341
355
|
};
|
342
|
-
|
356
|
+
|
343
357
|
const levelFilter = document.getElementById('levelFilter');
|
358
|
+
const tagFilter = document.getElementById('tagFilter');
|
359
|
+
const selectAllTagsBtn = document.getElementById('selectAllTags');
|
360
|
+
const clearAllTagsBtn = document.getElementById('clearAllTags');
|
344
361
|
const tableRows = document.querySelectorAll('tbody tr');
|
345
|
-
|
362
|
+
|
346
363
|
// Set initial filter to debug (default UI filter)
|
347
364
|
levelFilter.value = 'debug';
|
348
|
-
|
349
|
-
function
|
365
|
+
|
366
|
+
function getSelectedTags() {
|
367
|
+
const selected = [];
|
368
|
+
for (let option of tagFilter.selectedOptions) {
|
369
|
+
selected.push(option.value);
|
370
|
+
}
|
371
|
+
return selected;
|
372
|
+
}
|
373
|
+
|
374
|
+
function applyFilters() {
|
350
375
|
const selectedLevel = levelFilter.value;
|
351
376
|
const selectedLevelNum = LOG_LEVELS[selectedLevel];
|
377
|
+
const selectedTags = getSelectedTags();
|
352
378
|
let visibleCount = 0;
|
353
|
-
|
379
|
+
|
354
380
|
tableRows.forEach(row => {
|
355
381
|
const rowLevelNum = parseInt(row.dataset.levelNum);
|
356
|
-
|
382
|
+
const rowTag = row.dataset.tag;
|
383
|
+
|
384
|
+
const levelMatch = rowLevelNum >= selectedLevelNum;
|
385
|
+
const tagMatch = selectedTags.length === 0 || selectedTags.includes(rowTag) || rowTag === '';
|
386
|
+
|
387
|
+
if (levelMatch && tagMatch) {
|
357
388
|
row.style.display = '';
|
358
389
|
visibleCount++;
|
359
390
|
} else {
|
360
391
|
row.style.display = 'none';
|
361
392
|
}
|
362
393
|
});
|
363
|
-
|
394
|
+
|
364
395
|
// Update the header count
|
365
396
|
const header = document.querySelector('.header p');
|
366
397
|
const originalText = header.textContent.split(' • ');
|
367
398
|
originalText[1] = visibleCount + ' entries';
|
368
399
|
originalText[2] = 'Level: ' + selectedLevel.toUpperCase() + '+';
|
400
|
+
if (selectedTags.length > 0 && selectedTags.length < tagFilter.options.length) {
|
401
|
+
originalText[2] += ' • Tags: ' + selectedTags.length + ' selected';
|
402
|
+
}
|
369
403
|
header.textContent = originalText.join(' • ');
|
370
404
|
}
|
371
|
-
|
372
|
-
|
373
|
-
|
405
|
+
|
406
|
+
selectAllTagsBtn.addEventListener('click', function() {
|
407
|
+
for (let option of tagFilter.options) {
|
408
|
+
option.selected = true;
|
409
|
+
}
|
410
|
+
applyFilters();
|
411
|
+
});
|
412
|
+
|
413
|
+
clearAllTagsBtn.addEventListener('click', function() {
|
414
|
+
for (let option of tagFilter.options) {
|
415
|
+
option.selected = false;
|
416
|
+
}
|
417
|
+
applyFilters();
|
418
|
+
});
|
419
|
+
|
420
|
+
levelFilter.addEventListener('change', applyFilters);
|
421
|
+
tagFilter.addEventListener('change', applyFilters);
|
422
|
+
|
374
423
|
// Apply initial filter
|
375
|
-
|
424
|
+
applyFilters();
|
376
425
|
</script>
|
377
426
|
</body>
|
378
427
|
</html>
|
@@ -383,35 +432,35 @@ module LogViewer
|
|
383
432
|
|
384
433
|
def run
|
385
434
|
parse_options
|
386
|
-
|
435
|
+
|
387
436
|
puts "Parsing log file: #{@input_file}"
|
388
437
|
puts "Minimum log level: #{@min_level}"
|
389
|
-
|
438
|
+
|
390
439
|
logs = parse_logs
|
391
440
|
puts "Found #{logs.length} log entries matching criteria"
|
392
|
-
|
441
|
+
|
393
442
|
if logs.empty?
|
394
443
|
puts "No log entries found matching the specified criteria."
|
395
444
|
exit 0
|
396
445
|
end
|
397
|
-
|
446
|
+
|
398
447
|
html_content = generate_html(logs)
|
399
|
-
|
448
|
+
|
400
449
|
# Use /tmp directory for HTML files
|
401
450
|
tmp_dir = '/tmp'
|
402
|
-
|
451
|
+
|
403
452
|
# Generate output filename
|
404
453
|
base_name = File.basename(@input_file, '.*')
|
405
454
|
timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
|
406
455
|
output_file = File.join(tmp_dir, "#{base_name}_#{timestamp}.html")
|
407
|
-
|
456
|
+
|
408
457
|
# Write HTML file
|
409
458
|
File.write(output_file, html_content)
|
410
459
|
puts "HTML file created: #{output_file}"
|
411
|
-
|
460
|
+
|
412
461
|
# Open in browser
|
413
462
|
system('open', output_file)
|
414
463
|
puts "Opening in browser..."
|
415
464
|
end
|
416
465
|
end
|
417
|
-
end
|
466
|
+
end
|