Skip to content

Commit 46c48fc

Browse files
authored
Merge pull request #140 from thawn/copilot/add-logs-panel-to-frontend
Add server logs panel to configuration page with dynamic log level control
2 parents f18e283 + 511231d commit 46c48fc

File tree

5 files changed

+267
-1
lines changed

5 files changed

+267
-1
lines changed

src/config.html

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
'open_browser' : testCheckBox('#open_browser'),
88
'audio_format' : $('#audio_format').val(),
99
'pen_language' : $('#pen_language').val(),
10-
'library_path' : $('#library_path').val()
10+
'library_path' : $('#library_path').val(),
11+
'log_level' : $('#log_level').val()
1112
};
1213
$.post('/config',
1314
'action=update&data=' + encodeURIComponent(JSON.stringify(configVars)))
@@ -59,6 +60,45 @@
5960
window.location.href = '/download_oid_images';
6061
}
6162

63+
var loadLogs = function(){
64+
$.get('/logs?lines=100')
65+
.done(function(data) {
66+
if (data.success) {
67+
var logsText = data.logs.join('\n');
68+
$('#log-display').text(logsText);
69+
// Auto-scroll to bottom
70+
var logDisplay = document.getElementById('log-display');
71+
logDisplay.scrollTop = logDisplay.scrollHeight;
72+
}
73+
})
74+
.fail(function() {
75+
$('#log-display').text('Failed to load logs');
76+
});
77+
}
78+
79+
var changeLogLevel = function(){
80+
var level = $('#log_level').val();
81+
$.ajax({
82+
url: '/logs/level',
83+
type: 'POST',
84+
contentType: 'application/json',
85+
data: JSON.stringify({level: level})
86+
})
87+
.done(function(data) {
88+
if (data.success) {
89+
notify($('#log_level'), '', 'Log level changed to ' + level, 'bg-success', 2000);
90+
// Refresh logs after a short delay to show the change
91+
setTimeout(loadLogs, 500);
92+
}
93+
})
94+
.fail(function() {
95+
notify($('#log_level'), '', 'Failed to change log level', 'bg-danger', 4000);
96+
});
97+
}
98+
99+
// Auto-refresh logs every 3 seconds
100+
var logRefreshInterval;
101+
62102
$(function(){
63103
loadConfig();
64104
$('[data-toggle="tooltip"]').tooltip();
@@ -68,6 +108,18 @@
68108
$('#download-oid-images').click(function(){
69109
downloadOidImages();
70110
});
111+
$('#log_level').change(function(){
112+
changeLogLevel();
113+
});
114+
$('#refresh-logs').click(function(){
115+
loadLogs();
116+
});
117+
118+
// Initial log load
119+
loadLogs();
120+
121+
// Auto-refresh logs every 3 seconds
122+
logRefreshInterval = setInterval(loadLogs, 3000);
71123
});
72124

73125
</script>
@@ -115,6 +167,17 @@ <h4 class="panel-title">ttmp32gme configuration:</h4>
115167
<option value="ITALIAN">ITALIAN</option>
116168
</select>
117169
</div>
170+
<div class="form-group">
171+
<label for="log_level">Log Level:</label>
172+
<select id="log_level" class="custom-select form-control" data-toggle="tooltip"
173+
title="Set the logging level for the application. Changes take effect immediately.">
174+
<option value="DEBUG">DEBUG</option>
175+
<option value="INFO">INFO</option>
176+
<option value="WARNING">WARNING</option>
177+
<option value="ERROR">ERROR</option>
178+
<option value="CRITICAL">CRITICAL</option>
179+
</select>
180+
</div>
118181
<button type="button" id="submit" class="btn btn-primary" data-toggle="popover" disabled>Save
119182
Configuration</button>
120183
</div>
@@ -131,3 +194,21 @@ <h4 class="panel-title">Download OID Images:</h4>
131194
</button>
132195
</div>
133196
</div>
197+
<div class="panel panel-default">
198+
<div class="panel-heading">
199+
<h4 class="panel-title">Server Logs:</h4>
200+
</div>
201+
<div class="panel-body">
202+
<p>View recent server logs. Logs are automatically refreshed every 3 seconds. Change the log level above to control verbosity.</p>
203+
<button type="button" id="refresh-logs" class="btn btn-info btn-sm" data-toggle="tooltip"
204+
title="Manually refresh the log display">
205+
<span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Refresh Now
206+
</button>
207+
<div style="margin-top: 10px;">
208+
<textarea id="log-display" class="form-control" readonly
209+
style="font-family: monospace; font-size: 12px; height: 400px; overflow-y: scroll; background-color: #f5f5f5; color: #333;">
210+
Loading logs...
211+
</textarea>
212+
</div>
213+
</div>
214+
</div>

src/ttmp32gme/db_handler.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ class ConfigUpdateModel(BaseModel):
118118
audio_format: Optional[str] = Field(None, pattern="^(mp3|ogg)$")
119119
pen_language: Optional[str] = Field(None, max_length=50)
120120
library_path: Optional[str] = Field(None, max_length=500)
121+
log_level: Optional[str] = Field(
122+
None, pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$"
123+
)
121124

122125
# Allow other config fields
123126
model_config = {"extra": "allow"}
@@ -379,6 +382,7 @@ def initialize(self):
379382
INSERT OR IGNORE INTO config VALUES('print_preset','list');
380383
INSERT OR IGNORE INTO config VALUES('pen_language','GERMAN');
381384
INSERT OR IGNORE INTO config VALUES('library_path','');
385+
INSERT OR IGNORE INTO config VALUES('log_level','WARNING');
382386
"""
383387
)
384388
cursor.executescript(
@@ -1470,6 +1474,10 @@ def _fix_text_encoding(
14701474
),
14711475
"UPDATE config SET value='2.0.1' WHERE param='version';",
14721476
],
1477+
"2.0.2": [
1478+
"UPDATE config SET value='2.0.2' WHERE param='version';",
1479+
"INSERT OR IGNORE INTO config (param, value) VALUES ('log_level', 'WARNING');",
1480+
],
14731481
}
14741482
version_str = self.get_config_value("version")
14751483
if version_str is None:

src/ttmp32gme/log_handler.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Log handler for capturing logs in memory for frontend display."""
2+
3+
import logging
4+
from collections import deque
5+
from threading import Lock
6+
from typing import List
7+
8+
9+
class MemoryLogHandler(logging.Handler):
10+
"""Custom log handler that stores recent log records in memory."""
11+
12+
def __init__(self, max_records: int = 1000):
13+
super().__init__()
14+
self.max_records = max_records
15+
self.records: deque[str] = deque(maxlen=max_records)
16+
self._lock: Lock = Lock()
17+
18+
def emit(self, record: logging.LogRecord) -> None:
19+
"""Store log record in memory."""
20+
try:
21+
msg = self.format(record)
22+
with self._lock:
23+
self.records.append(msg)
24+
except Exception:
25+
self.handleError(record)
26+
27+
def get_logs(self, num_lines: int = 100) -> List[str]:
28+
"""Get recent log entries.
29+
30+
Args:
31+
num_lines: Number of recent log lines to return
32+
33+
Returns:
34+
List of formatted log messages
35+
"""
36+
with self._lock:
37+
return list(self.records)[-num_lines:]

src/ttmp32gme/ttmp32gme.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
DBHandler,
3737
LibraryActionModel,
3838
)
39+
from ttmp32gme.log_handler import MemoryLogHandler
3940
from ttmp32gme.print_handler import create_pdf, create_print_layout, format_print_button
4041
from ttmp32gme.tttool_handler import copy_gme, delete_gme_tiptoi, make_gme
4142

@@ -45,6 +46,13 @@
4546
)
4647
logger = logging.getLogger(__name__)
4748

49+
# Add memory handler to capture logs for frontend
50+
memory_handler = MemoryLogHandler(max_records=1000)
51+
memory_handler.setFormatter(
52+
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
53+
)
54+
logging.getLogger().addHandler(memory_handler)
55+
4856
# Create Flask app
4957
# Configure paths for both development and PyInstaller
5058
if getattr(sys, "frozen", False):
@@ -166,6 +174,13 @@ def save_config(config_params: Dict[str, Any]) -> tuple[Dict[str, Any], str]:
166174

167175
shutil.rmtree(new_path, ignore_errors=True)
168176

177+
# Handle log level changes
178+
if "log_level" in config_params:
179+
level_str = config_params["log_level"]
180+
level = getattr(logging, level_str, logging.WARNING)
181+
logging.getLogger().setLevel(level)
182+
logger.info(f"Log level changed to {level_str}")
183+
169184
# Validate DPI and pixel size
170185
if "tt_dpi" in config_params and "tt_pixel-size" in config_params:
171186
dpi = int(config_params["tt_dpi"])
@@ -560,6 +575,7 @@ def config_post():
560575
"audio_format": new_config["audio_format"],
561576
"pen_language": new_config["pen_language"],
562577
"library_path": new_config["library_path"],
578+
"log_level": new_config.get("log_level", "WARNING"),
563579
},
564580
}
565581
)
@@ -577,6 +593,7 @@ def config_post():
577593
"audio_format": config["audio_format"],
578594
"pen_language": config["pen_language"],
579595
"library_path": config["library_path"],
596+
"log_level": config.get("log_level", "WARNING"),
580597
},
581598
}
582599
)
@@ -600,6 +617,40 @@ def help_page():
600617
)
601618

602619

620+
@app.route("/logs")
621+
def get_logs():
622+
"""Get recent log entries."""
623+
num_lines = request.args.get("lines", default=100, type=int)
624+
logs = memory_handler.get_logs(num_lines)
625+
return jsonify({"success": True, "logs": logs})
626+
627+
628+
@app.route("/logs/level", methods=["POST"])
629+
def set_log_level():
630+
"""Set the log level dynamically."""
631+
if not request.json:
632+
return jsonify({"success": False, "error": "Invalid request"}), 400
633+
634+
level_str = request.json.get("level", "WARNING")
635+
636+
# Validate log level
637+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
638+
if level_str not in valid_levels:
639+
return jsonify({"success": False, "error": "Invalid log level"}), 400
640+
641+
# Set the log level
642+
level = getattr(logging, level_str)
643+
logging.getLogger().setLevel(level)
644+
logger.info(f"Log level changed to {level_str}")
645+
646+
# Save to config
647+
db = get_db()
648+
db.insert_or_replace_config("log_level", level_str)
649+
config["log_level"] = level_str
650+
651+
return jsonify({"success": True, "level": level_str})
652+
653+
603654
@app.route("/images/<path:filename>")
604655
def serve_dynamic_image(filename: str):
605656
"""Serve dynamically generated images (OID codes, covers, etc.)."""
@@ -747,6 +798,13 @@ def main():
747798
logger.info("Update successful.")
748799
config = fetch_config()
749800

801+
# Apply log level from config if not overridden by command line
802+
if args.verbose == 0: # Only apply config if -v/-vv not used
803+
log_level_str = config.get("log_level", "WARNING")
804+
log_level = getattr(logging, log_level_str, logging.WARNING)
805+
logging.getLogger().setLevel(log_level)
806+
logger.info(f"Log level set to {log_level_str} from config")
807+
750808
# Override config with command-line args
751809
if args.port:
752810
config["port"] = args.port

tests/unit/test_log_handler.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Unit tests for MemoryLogHandler."""
2+
3+
import logging
4+
import sys
5+
from pathlib import Path
6+
7+
# Add src to path to import ttmp32gme
8+
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
9+
10+
11+
def test_memory_log_handler_stores_logs():
12+
"""Test that MemoryLogHandler stores log records."""
13+
from ttmp32gme.ttmp32gme import MemoryLogHandler
14+
15+
handler = MemoryLogHandler(max_records=10)
16+
handler.setFormatter(
17+
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
18+
)
19+
20+
logger = logging.getLogger("test_handler")
21+
logger.addHandler(handler)
22+
logger.setLevel(logging.DEBUG)
23+
24+
# Log some messages
25+
logger.debug("Debug message")
26+
logger.info("Info message")
27+
logger.warning("Warning message")
28+
29+
# Get logs
30+
logs = handler.get_logs()
31+
32+
assert len(logs) == 3
33+
assert "Debug message" in logs[0]
34+
assert "Info message" in logs[1]
35+
assert "Warning message" in logs[2]
36+
37+
38+
def test_memory_log_handler_respects_max_records():
39+
"""Test that MemoryLogHandler respects max_records limit."""
40+
from ttmp32gme.ttmp32gme import MemoryLogHandler
41+
42+
handler = MemoryLogHandler(max_records=5)
43+
handler.setFormatter(logging.Formatter("%(message)s"))
44+
45+
logger = logging.getLogger("test_max_records")
46+
logger.addHandler(handler)
47+
logger.setLevel(logging.DEBUG)
48+
49+
# Log more than max_records
50+
for i in range(10):
51+
logger.info(f"Message {i}")
52+
53+
# Get logs
54+
logs = handler.get_logs()
55+
56+
# Should only have last 5 messages
57+
assert len(logs) == 5
58+
assert "Message 5" in logs[0]
59+
assert "Message 9" in logs[4]
60+
61+
62+
def test_memory_log_handler_get_logs_with_limit():
63+
"""Test that get_logs respects num_lines parameter."""
64+
from ttmp32gme.ttmp32gme import MemoryLogHandler
65+
66+
handler = MemoryLogHandler(max_records=100)
67+
handler.setFormatter(logging.Formatter("%(message)s"))
68+
69+
logger = logging.getLogger("test_limit")
70+
logger.addHandler(handler)
71+
logger.setLevel(logging.DEBUG)
72+
73+
# Log 20 messages
74+
for i in range(20):
75+
logger.info(f"Message {i}")
76+
77+
# Get only last 5 logs
78+
logs = handler.get_logs(num_lines=5)
79+
80+
assert len(logs) == 5
81+
assert "Message 15" in logs[0]
82+
assert "Message 19" in logs[4]

0 commit comments

Comments
 (0)