mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-04 04:22:42 +02:00
feat: add System Log viewer with real-time streaming
In-memory ring buffer (2000 entries) captures all Python log records. New /logs page streams entries via WebSocket in real-time with: - Level filter (DEBUG/INFO/WARNING/ERROR) - Module filter (auto-populated from seen loggers) - Text search with highlighting - Auto-scroll with pause/resume - Dark theme matching Console style Menu entry added under Configuration section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
84
app/log_handler.py
Normal file
84
app/log_handler.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
In-memory ring buffer log handler with WebSocket broadcast.
|
||||
|
||||
Captures Python log records into a fixed-size deque and optionally
|
||||
broadcasts them to connected SocketIO clients in real-time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class MemoryLogHandler(logging.Handler):
|
||||
"""Logging handler that stores records in a ring buffer and broadcasts via SocketIO."""
|
||||
|
||||
def __init__(self, capacity=2000, socketio=None):
|
||||
super().__init__()
|
||||
self.capacity = capacity
|
||||
self.buffer = deque(maxlen=capacity)
|
||||
self.socketio = socketio
|
||||
self._lock = Lock()
|
||||
self._seq = 0 # monotonic sequence for client catch-up
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
entry = self._format_record(record)
|
||||
with self._lock:
|
||||
self._seq += 1
|
||||
entry['seq'] = self._seq
|
||||
self.buffer.append(entry)
|
||||
|
||||
# Broadcast to connected clients
|
||||
if self.socketio:
|
||||
self.socketio.emit('log_entry', entry, namespace='/logs')
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def _format_record(self, record):
|
||||
"""Convert LogRecord to a serializable dict."""
|
||||
return {
|
||||
'timestamp': datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3],
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
}
|
||||
|
||||
def get_entries(self, level=None, logger_filter=None, search=None, limit=None):
|
||||
"""Return filtered log entries from the buffer.
|
||||
|
||||
Args:
|
||||
level: Minimum log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
logger_filter: Logger name prefix filter (e.g. 'app.device_manager')
|
||||
search: Text search in message (case-insensitive)
|
||||
limit: Max entries to return (newest first before limit, returned in chronological order)
|
||||
|
||||
Returns:
|
||||
List of log entry dicts
|
||||
"""
|
||||
level_num = getattr(logging, level.upper(), 0) if level else 0
|
||||
search_lower = search.lower() if search else None
|
||||
|
||||
with self._lock:
|
||||
entries = list(self.buffer)
|
||||
|
||||
# Apply filters
|
||||
if level_num > 0:
|
||||
entries = [e for e in entries if getattr(logging, e['level'], 0) >= level_num]
|
||||
if logger_filter:
|
||||
entries = [e for e in entries if e['logger'].startswith(logger_filter)]
|
||||
if search_lower:
|
||||
entries = [e for e in entries if search_lower in e['message'].lower()]
|
||||
|
||||
# Limit (return newest N, in chronological order)
|
||||
if limit and limit > 0 and len(entries) > limit:
|
||||
entries = entries[-limit:]
|
||||
|
||||
return entries
|
||||
|
||||
def get_loggers(self):
|
||||
"""Return sorted list of unique logger names seen in the buffer."""
|
||||
with self._lock:
|
||||
loggers = sorted({e['logger'] for e in self.buffer})
|
||||
return loggers
|
||||
23
app/main.py
23
app/main.py
@@ -13,6 +13,7 @@ from flask_socketio import SocketIO, emit
|
||||
from app.config import config, runtime_config
|
||||
from app.database import Database
|
||||
from app.device_manager import DeviceManager
|
||||
from app.log_handler import MemoryLogHandler
|
||||
from app.routes.views import views_bp
|
||||
from app.routes.api import api_bp
|
||||
from app.version import VERSION_STRING, GIT_BRANCH
|
||||
@@ -70,6 +71,13 @@ def create_app():
|
||||
# Initialize SocketIO
|
||||
socketio.init_app(app, cors_allowed_origins="*", async_mode='threading')
|
||||
|
||||
# Initialize in-memory log handler (ring buffer + WebSocket broadcast)
|
||||
log_handler = MemoryLogHandler(capacity=2000, socketio=socketio)
|
||||
log_handler.setLevel(logging.DEBUG)
|
||||
log_handler.setFormatter(logging.Formatter('%(message)s'))
|
||||
logging.getLogger().addHandler(log_handler)
|
||||
app.log_handler = log_handler
|
||||
|
||||
# v2: Initialize database
|
||||
db = Database(config.db_path)
|
||||
app.db = db
|
||||
@@ -212,6 +220,21 @@ def handle_send_command(data):
|
||||
socketio.start_background_task(execute_and_respond)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WebSocket handlers for System Log viewer
|
||||
# ============================================================
|
||||
|
||||
@socketio.on('connect', namespace='/logs')
|
||||
def handle_logs_connect():
|
||||
"""Handle log viewer WebSocket connection."""
|
||||
logger.debug("Log viewer WebSocket client connected")
|
||||
|
||||
|
||||
@socketio.on('disconnect', namespace='/logs')
|
||||
def handle_logs_disconnect():
|
||||
logger.debug("Log viewer WebSocket client disconnected")
|
||||
|
||||
|
||||
def _parse_time_arg(value: str) -> int:
|
||||
"""Parse time argument with optional suffix: s (seconds), m (minutes, default), h (hours)."""
|
||||
value = value.strip().lower()
|
||||
|
||||
@@ -3871,3 +3871,50 @@ def download_backup():
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System Log API
|
||||
# =============================================================================
|
||||
|
||||
@api_bp.route('/logs', methods=['GET'])
|
||||
def get_logs_api():
|
||||
"""
|
||||
Get log entries from in-memory ring buffer.
|
||||
|
||||
Query parameters:
|
||||
level: Minimum log level (DEBUG, INFO, WARNING, ERROR)
|
||||
logger: Logger name prefix filter
|
||||
search: Text search in message (case-insensitive)
|
||||
limit: Max entries to return (default 500)
|
||||
|
||||
Returns:
|
||||
JSON with log entries and available loggers
|
||||
"""
|
||||
try:
|
||||
log_handler = getattr(current_app, 'log_handler', None)
|
||||
if not log_handler:
|
||||
return jsonify({'success': False, 'error': 'Log handler not available'}), 500
|
||||
|
||||
level = request.args.get('level')
|
||||
logger_filter = request.args.get('logger')
|
||||
search = request.args.get('search')
|
||||
limit = request.args.get('limit', 500, type=int)
|
||||
|
||||
entries = log_handler.get_entries(
|
||||
level=level,
|
||||
logger_filter=logger_filter,
|
||||
search=search,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'entries': entries,
|
||||
'count': len(entries),
|
||||
'loggers': log_handler.get_loggers(),
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting logs: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@@ -85,6 +85,12 @@ def console():
|
||||
)
|
||||
|
||||
|
||||
@views_bp.route('/logs')
|
||||
def logs():
|
||||
"""System log viewer - real-time log streaming with filters."""
|
||||
return render_template('logs.html')
|
||||
|
||||
|
||||
@views_bp.route('/health')
|
||||
def health():
|
||||
"""
|
||||
|
||||
276
app/static/js/logs.js
Normal file
276
app/static/js/logs.js
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* System Log Viewer
|
||||
*
|
||||
* Real-time log streaming via WebSocket with filtering and search.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// --- DOM refs ---
|
||||
const logEntries = document.getElementById('logEntries');
|
||||
const loadingMsg = document.getElementById('loadingMsg');
|
||||
const logCount = document.getElementById('logCount');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const pauseBtn = document.getElementById('pauseBtn');
|
||||
const pauseIcon = document.getElementById('pauseIcon');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
const levelFilter = document.getElementById('levelFilter');
|
||||
const loggerFilter = document.getElementById('loggerFilter');
|
||||
const searchFilter = document.getElementById('searchFilter');
|
||||
const resetFilters = document.getElementById('resetFilters');
|
||||
|
||||
// --- State ---
|
||||
let paused = false;
|
||||
let autoScroll = true;
|
||||
let entries = []; // all received entries
|
||||
let displayCount = 0;
|
||||
const MAX_DISPLAY = 3000; // max DOM entries before trimming
|
||||
let searchDebounce = null;
|
||||
let knownLoggers = new Set();
|
||||
|
||||
// --- Level ordering ---
|
||||
const LEVEL_ORDER = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3, CRITICAL: 4 };
|
||||
|
||||
// --- WebSocket ---
|
||||
const socket = io('/logs', {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
setStatus('live');
|
||||
// Load initial entries
|
||||
loadInitialLogs();
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setStatus('disconnected');
|
||||
});
|
||||
|
||||
socket.on('log_entry', (entry) => {
|
||||
addEntry(entry);
|
||||
});
|
||||
|
||||
// --- Functions ---
|
||||
|
||||
function setStatus(state) {
|
||||
statusDot.className = 'status-indicator ' + state;
|
||||
}
|
||||
|
||||
function loadInitialLogs() {
|
||||
const level = levelFilter.value;
|
||||
const params = new URLSearchParams();
|
||||
if (level) params.set('level', level);
|
||||
params.set('limit', '1000');
|
||||
|
||||
fetch('/api/logs?' + params.toString())
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.success) return;
|
||||
loadingMsg?.remove();
|
||||
|
||||
// Update logger filter options
|
||||
if (data.loggers) {
|
||||
data.loggers.forEach(l => knownLoggers.add(l));
|
||||
updateLoggerOptions();
|
||||
}
|
||||
|
||||
// Render entries
|
||||
entries = data.entries || [];
|
||||
renderAll();
|
||||
})
|
||||
.catch(err => {
|
||||
if (loadingMsg) loadingMsg.textContent = 'Failed to load logs';
|
||||
console.error('Failed to load logs:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function addEntry(entry) {
|
||||
entries.push(entry);
|
||||
|
||||
// Track new loggers
|
||||
if (!knownLoggers.has(entry.logger)) {
|
||||
knownLoggers.add(entry.logger);
|
||||
updateLoggerOptions();
|
||||
}
|
||||
|
||||
// If paused or filtered out, don't add to DOM
|
||||
if (paused) {
|
||||
updateCount();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesFilter(entry)) {
|
||||
appendEntryDOM(entry);
|
||||
trimDOM();
|
||||
if (autoScroll) scrollToBottom();
|
||||
}
|
||||
updateCount();
|
||||
}
|
||||
|
||||
function matchesFilter(entry) {
|
||||
// Level filter
|
||||
const minLevel = levelFilter.value;
|
||||
if (minLevel && (LEVEL_ORDER[entry.level] || 0) < (LEVEL_ORDER[minLevel] || 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Logger filter
|
||||
const loggerVal = loggerFilter.value;
|
||||
if (loggerVal && !entry.logger.startsWith(loggerVal)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
const search = searchFilter.value.trim().toLowerCase();
|
||||
if (search && !entry.message.toLowerCase().includes(search) &&
|
||||
!entry.logger.toLowerCase().includes(search)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
logEntries.innerHTML = '';
|
||||
displayCount = 0;
|
||||
|
||||
const filtered = entries.filter(e => matchesFilter(e));
|
||||
// Only render last MAX_DISPLAY entries
|
||||
const toRender = filtered.slice(-MAX_DISPLAY);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const entry of toRender) {
|
||||
fragment.appendChild(createEntryElement(entry));
|
||||
displayCount++;
|
||||
}
|
||||
|
||||
logEntries.appendChild(fragment);
|
||||
updateCount();
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function appendEntryDOM(entry) {
|
||||
logEntries.appendChild(createEntryElement(entry));
|
||||
displayCount++;
|
||||
}
|
||||
|
||||
function createEntryElement(entry) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-line';
|
||||
|
||||
// Shorten timestamp to HH:MM:SS.mmm
|
||||
const ts = entry.timestamp.length > 11 ? entry.timestamp.substring(11) : entry.timestamp;
|
||||
|
||||
// Shorten logger name (remove 'app.' prefix)
|
||||
let loggerName = entry.logger;
|
||||
if (loggerName.startsWith('app.')) {
|
||||
loggerName = loggerName.substring(4);
|
||||
}
|
||||
|
||||
// Pad level to 5 chars
|
||||
const levelPad = entry.level.padEnd(5);
|
||||
|
||||
// Build the line with color spans
|
||||
const search = searchFilter.value.trim().toLowerCase();
|
||||
let message = escapeHtml(entry.message);
|
||||
if (search) {
|
||||
message = highlightSearch(message, search);
|
||||
}
|
||||
|
||||
div.innerHTML =
|
||||
`<span class="log-ts">${escapeHtml(ts)}</span> ` +
|
||||
`<span class="log-level-${entry.level}">${escapeHtml(levelPad)}</span> ` +
|
||||
`<span class="log-logger">${escapeHtml(loggerName.padEnd(18).substring(0, 18))}</span> ` +
|
||||
`<span class="log-msg-${entry.level}">${message}</span>`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function trimDOM() {
|
||||
while (logEntries.children.length > MAX_DISPLAY) {
|
||||
logEntries.removeChild(logEntries.firstChild);
|
||||
displayCount--;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
logEntries.scrollTop = logEntries.scrollHeight;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
const total = entries.length;
|
||||
const shown = logEntries.children.length;
|
||||
logCount.textContent = shown === total
|
||||
? `${total} entries`
|
||||
: `${shown} / ${total} entries`;
|
||||
}
|
||||
|
||||
function updateLoggerOptions() {
|
||||
const current = loggerFilter.value;
|
||||
// Group loggers by top-level module
|
||||
const sorted = Array.from(knownLoggers).sort();
|
||||
|
||||
loggerFilter.innerHTML = '<option value="">All modules</option>';
|
||||
for (const name of sorted) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
// Shorten display
|
||||
opt.textContent = name.startsWith('app.') ? name.substring(4) : name;
|
||||
if (name === current) opt.selected = true;
|
||||
loggerFilter.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function highlightSearch(html, search) {
|
||||
if (!search) return html;
|
||||
// Case-insensitive highlight (on already-escaped HTML)
|
||||
const regex = new RegExp('(' + search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
||||
return html.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// --- Auto-scroll detection ---
|
||||
logEntries.addEventListener('scroll', () => {
|
||||
const atBottom = logEntries.scrollHeight - logEntries.scrollTop - logEntries.clientHeight < 50;
|
||||
autoScroll = atBottom;
|
||||
});
|
||||
|
||||
// --- Controls ---
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
paused = !paused;
|
||||
pauseIcon.className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
|
||||
setStatus(paused ? 'paused' : 'live');
|
||||
if (!paused) {
|
||||
// Resume: re-render to catch up
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
entries = [];
|
||||
logEntries.innerHTML = '';
|
||||
displayCount = 0;
|
||||
updateCount();
|
||||
});
|
||||
|
||||
// Filter handlers
|
||||
levelFilter.addEventListener('change', () => renderAll());
|
||||
loggerFilter.addEventListener('change', () => renderAll());
|
||||
searchFilter.addEventListener('input', () => {
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => renderAll(), 250);
|
||||
});
|
||||
resetFilters.addEventListener('click', () => {
|
||||
levelFilter.value = 'INFO';
|
||||
loggerFilter.value = '';
|
||||
searchFilter.value = '';
|
||||
renderAll();
|
||||
});
|
||||
})();
|
||||
@@ -133,6 +133,13 @@
|
||||
<small class="d-block text-muted">Direct meshcli commands</small>
|
||||
</div>
|
||||
</button>
|
||||
<a href="/logs" target="_blank" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-dismiss="offcanvas">
|
||||
<i class="bi bi-journal-text" style="font-size: 1.5rem;"></i>
|
||||
<div>
|
||||
<span>System Log</span>
|
||||
<small class="d-block text-muted">Real-time application logs</small>
|
||||
</div>
|
||||
</a>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3"
|
||||
id="mapBtn" title="Show all contacts with GPS on map">
|
||||
<i class="bi bi-map" style="font-size: 1.5rem;"></i>
|
||||
|
||||
206
app/templates/logs.html
Normal file
206
app/templates/logs.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>System Log - mc-webui</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
|
||||
<!-- Bootstrap 5 CSS (local) -->
|
||||
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (local) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
background-color: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
padding: 0.5rem 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-filters {
|
||||
background-color: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
padding: 0.5rem 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-entries {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background-color: #1a1a2e;
|
||||
min-height: 0;
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 1px 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.log-line:hover {
|
||||
background-color: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.log-ts { color: #6c757d; }
|
||||
.log-logger { color: #4ecdc4; }
|
||||
|
||||
.log-level-DEBUG { color: #6c757d; }
|
||||
.log-level-INFO { color: #e0e0e0; }
|
||||
.log-level-WARNING { color: #ffd93d; }
|
||||
.log-level-ERROR { color: #ff6b6b; font-weight: bold; }
|
||||
.log-level-CRITICAL { color: #ff3333; font-weight: bold; background-color: rgba(255,0,0,0.1); }
|
||||
|
||||
.log-msg-DEBUG { color: #888; }
|
||||
.log-msg-INFO { color: #c8c8c8; }
|
||||
.log-msg-WARNING { color: #e8d44d; }
|
||||
.log-msg-ERROR { color: #ff8888; }
|
||||
.log-msg-CRITICAL { color: #ff6666; }
|
||||
|
||||
/* Filter controls */
|
||||
.filter-select, .filter-input {
|
||||
background-color: #0f3460;
|
||||
border: 1px solid #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.filter-select:focus, .filter-input:focus {
|
||||
background-color: #0f3460;
|
||||
border-color: #4ecdc4;
|
||||
color: #e0e0e0;
|
||||
box-shadow: 0 0 0 0.15rem rgba(78, 205, 196, 0.25);
|
||||
}
|
||||
.filter-select option {
|
||||
background-color: #16213e;
|
||||
}
|
||||
|
||||
.btn-log {
|
||||
background-color: #0f3460;
|
||||
border: 1px solid #1a1a2e;
|
||||
color: #4ecdc4;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.btn-log:hover, .btn-log:focus {
|
||||
background-color: #1a1a4e;
|
||||
border-color: #4ecdc4;
|
||||
color: #4ecdc4;
|
||||
}
|
||||
.btn-log.active {
|
||||
background-color: #4ecdc4;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-indicator.live { background-color: #00ff88; }
|
||||
.status-indicator.paused { background-color: #ffd93d; }
|
||||
.status-indicator.disconnected { background-color: #ff6b6b; }
|
||||
|
||||
.log-count {
|
||||
color: #6c757d;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Highlight search matches */
|
||||
mark {
|
||||
background-color: rgba(255, 217, 61, 0.3);
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.log-header { padding: 0.4rem 0.5rem; }
|
||||
.log-filters { padding: 0.4rem 0.5rem; }
|
||||
.log-entries { font-size: 0.75rem; padding: 0.25rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="log-container">
|
||||
<!-- Header -->
|
||||
<div class="log-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="/" class="btn btn-sm btn-log" title="Back to chat">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h6 class="mb-0 text-light">System Log</h6>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="log-count" id="logCount">0 entries</span>
|
||||
<span class="status-indicator" id="statusDot"></span>
|
||||
<button class="btn btn-sm btn-log" id="pauseBtn" title="Pause/Resume">
|
||||
<i class="bi bi-pause-fill" id="pauseIcon"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-log" id="clearBtn" title="Clear display">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="log-filters">
|
||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||
<select class="form-select form-select-sm filter-select" id="levelFilter" style="width: auto; min-width: 90px;">
|
||||
<option value="">All levels</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO" selected>INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm filter-select" id="loggerFilter" style="width: auto; min-width: 120px;">
|
||||
<option value="">All modules</option>
|
||||
</select>
|
||||
<input type="text" class="form-control form-control-sm filter-input" id="searchFilter"
|
||||
placeholder="Search..." style="width: auto; min-width: 150px; flex: 1;">
|
||||
<button class="btn btn-sm btn-log" id="resetFilters" title="Reset filters">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log entries -->
|
||||
<div class="log-entries" id="logEntries">
|
||||
<div class="text-muted text-center py-3" id="loadingMsg">Loading logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Socket.IO client -->
|
||||
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
|
||||
<!-- Bootstrap JS Bundle (local) -->
|
||||
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- Log Viewer JS -->
|
||||
<script src="{{ url_for('static', filename='js/logs.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user