06/01 Add log viewer
This commit is contained in:
+42
-24
@@ -8,38 +8,54 @@ from app.utils.formatters import format_currency, format_percent, format_large_n
|
|||||||
|
|
||||||
def _setup_logging(app):
|
def _setup_logging(app):
|
||||||
"""
|
"""
|
||||||
Ensure app.* module loggers emit at INFO level.
|
Configure the 'app' namespace logger to write to a rotating file AND stderr.
|
||||||
|
|
||||||
Under Gunicorn the root logger already has handlers (pointing to Gunicorn's
|
Uses a pipe-delimited format so the log viewer can parse each field easily:
|
||||||
error log / stderr) but its level is WARNING, so INFO records are dropped
|
2026-06-01 12:00:00|INFO|app.services.teller_service|message text
|
||||||
before they reach any handler. We fix that by:
|
|
||||||
1. Reusing Gunicorn's handlers on the 'app' namespace logger so records
|
The file path comes from LOG_FILE_PATH config (defaults to logs/app.log
|
||||||
go to the same destination as Gunicorn's own logs.
|
next to the project root). The directory is created automatically.
|
||||||
2. Falling back to a plain stderr StreamHandler in dev / direct-run mode.
|
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
app_log = logging.getLogger('app')
|
app_log = logging.getLogger('app')
|
||||||
app_log.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
if app_log.handlers:
|
if app_log.handlers:
|
||||||
return # already configured (e.g. running tests)
|
return # already configured (avoids duplicate handlers on reload)
|
||||||
|
|
||||||
gunicorn_handlers = logging.getLogger('gunicorn.error').handlers
|
app_log.setLevel(logging.INFO)
|
||||||
if gunicorn_handlers:
|
app_log.propagate = False # don't double-emit through the root logger
|
||||||
# Running under Gunicorn — attach its handlers so our logs land in the
|
|
||||||
# same error log file that Gunicorn writes to.
|
|
||||||
for h in gunicorn_handlers:
|
|
||||||
app_log.addHandler(h)
|
|
||||||
else:
|
|
||||||
# Dev / direct python run — stderr is fine.
|
|
||||||
handler = logging.StreamHandler(sys.stderr)
|
|
||||||
handler.setFormatter(logging.Formatter(
|
|
||||||
'%(asctime)s %(levelname)s %(name)s: %(message)s'
|
|
||||||
))
|
|
||||||
app_log.addHandler(handler)
|
|
||||||
|
|
||||||
app_log.propagate = False # avoid double-printing via root
|
fmt = logging.Formatter(
|
||||||
|
'%(asctime)s|%(levelname)s|%(name)s|%(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S',
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- rotating file handler (primary — always on) ---
|
||||||
|
log_file = app.config.get('LOG_FILE_PATH', '')
|
||||||
|
if log_file:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||||
|
fh = RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=app.config.get('LOG_MAX_BYTES', 10 * 1024 * 1024),
|
||||||
|
backupCount=app.config.get('LOG_BACKUP_COUNT', 5),
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
fh.setLevel(logging.INFO)
|
||||||
|
fh.setFormatter(fmt)
|
||||||
|
app_log.addHandler(fh)
|
||||||
|
except Exception as exc:
|
||||||
|
# Can't open file (permissions, bad path) — fall through to stderr only
|
||||||
|
print(f'[pfm] WARNING: could not open log file {log_file!r}: {exc}', file=sys.stderr)
|
||||||
|
|
||||||
|
# --- stderr handler (secondary — also always on so Gunicorn captures it) ---
|
||||||
|
sh = logging.StreamHandler(sys.stderr)
|
||||||
|
sh.setLevel(logging.INFO)
|
||||||
|
sh.setFormatter(fmt)
|
||||||
|
app_log.addHandler(sh)
|
||||||
|
|
||||||
|
app_log.info('Logging initialised — file=%s', log_file or '(none)')
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name=None):
|
def create_app(config_name=None):
|
||||||
@@ -73,6 +89,7 @@ def create_app(config_name=None):
|
|||||||
from app.routes.reports import reports_bp
|
from app.routes.reports import reports_bp
|
||||||
from app.routes.settings import settings_bp
|
from app.routes.settings import settings_bp
|
||||||
from app.routes.teller import teller_bp
|
from app.routes.teller import teller_bp
|
||||||
|
from app.routes.logs import logs_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
@@ -86,6 +103,7 @@ def create_app(config_name=None):
|
|||||||
app.register_blueprint(reports_bp)
|
app.register_blueprint(reports_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(teller_bp)
|
app.register_blueprint(teller_bp)
|
||||||
|
app.register_blueprint(logs_bp)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ class Config:
|
|||||||
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
|
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
|
||||||
TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '')
|
TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '')
|
||||||
|
|
||||||
|
# Application log file (rotating, shared by all app.* loggers)
|
||||||
|
LOG_FILE_PATH = os.environ.get(
|
||||||
|
'LOG_FILE_PATH',
|
||||||
|
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs', 'app.log')
|
||||||
|
)
|
||||||
|
LOG_MAX_BYTES = int(os.environ.get('LOG_MAX_BYTES', 10 * 1024 * 1024)) # 10 MB
|
||||||
|
LOG_BACKUP_COUNT = int(os.environ.get('LOG_BACKUP_COUNT', 5))
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from flask import (Blueprint, render_template, request, jsonify,
|
||||||
|
current_app, send_file, abort)
|
||||||
|
from flask_login import login_required
|
||||||
|
|
||||||
|
logs_bp = Blueprint('logs', __name__, url_prefix='/logs')
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_lines(filepath, n=2000):
|
||||||
|
"""Return the last n lines from a file without loading it all into memory."""
|
||||||
|
try:
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
size = f.tell()
|
||||||
|
if size == 0:
|
||||||
|
return []
|
||||||
|
buf = bytearray()
|
||||||
|
pos = size
|
||||||
|
while len(buf) < 256 * 1024 and pos > 0: # max 256 KB scan
|
||||||
|
chunk = min(65536, pos)
|
||||||
|
pos -= chunk
|
||||||
|
f.seek(pos)
|
||||||
|
buf = bytearray(f.read(chunk)) + buf
|
||||||
|
lines = buf.decode('utf-8', errors='replace').splitlines()
|
||||||
|
return lines[-n:]
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
except Exception as exc:
|
||||||
|
log.error('log viewer _tail_lines error: %s', exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_line(raw):
|
||||||
|
"""Parse pipe-delimited log line: timestamp|level|name|message"""
|
||||||
|
parts = raw.split('|', 3)
|
||||||
|
if len(parts) == 4:
|
||||||
|
return {
|
||||||
|
'ts': parts[0],
|
||||||
|
'level': parts[1],
|
||||||
|
'name': parts[2],
|
||||||
|
'message': parts[3],
|
||||||
|
}
|
||||||
|
# Fallback for lines that don't match the format (e.g. tracebacks)
|
||||||
|
return {'ts': '', 'level': 'RAW', 'name': '', 'message': raw}
|
||||||
|
|
||||||
|
|
||||||
|
@logs_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
log_file = current_app.config.get('LOG_FILE_PATH', '')
|
||||||
|
file_exists = bool(log_file) and os.path.isfile(log_file)
|
||||||
|
file_size = os.path.getsize(log_file) if file_exists else 0
|
||||||
|
return render_template('logs/index.html',
|
||||||
|
log_file=log_file,
|
||||||
|
file_exists=file_exists,
|
||||||
|
file_size=file_size)
|
||||||
|
|
||||||
|
|
||||||
|
@logs_bp.route('/api')
|
||||||
|
@login_required
|
||||||
|
def api():
|
||||||
|
"""JSON endpoint used by the log viewer to fetch and filter entries."""
|
||||||
|
log_file = current_app.config.get('LOG_FILE_PATH', '')
|
||||||
|
if not log_file:
|
||||||
|
return jsonify({'entries': [], 'error': 'LOG_FILE_PATH not configured'})
|
||||||
|
|
||||||
|
level_filter = request.args.get('level', 'ALL').upper()
|
||||||
|
search = request.args.get('search', '').lower()
|
||||||
|
limit = min(int(request.args.get('limit', 200)), 2000)
|
||||||
|
module = request.args.get('module', '').lower()
|
||||||
|
|
||||||
|
raw_lines = _tail_lines(log_file, n=5000)
|
||||||
|
entries = [_parse_line(l) for l in raw_lines if l.strip()]
|
||||||
|
|
||||||
|
if level_filter not in ('ALL', ''):
|
||||||
|
entries = [e for e in entries if e['level'] == level_filter]
|
||||||
|
|
||||||
|
if module:
|
||||||
|
entries = [e for e in entries if module in e['name'].lower()]
|
||||||
|
|
||||||
|
if search:
|
||||||
|
entries = [e for e in entries
|
||||||
|
if search in e['message'].lower() or search in e['name'].lower()]
|
||||||
|
|
||||||
|
# Most-recent first, capped at limit
|
||||||
|
entries = list(reversed(entries))[:limit]
|
||||||
|
|
||||||
|
# Count per level across ALL unfiltered lines (for the stats bar)
|
||||||
|
all_entries = [_parse_line(l) for l in raw_lines if l.strip()]
|
||||||
|
counts = {}
|
||||||
|
for e in all_entries:
|
||||||
|
counts[e['level']] = counts.get(e['level'], 0) + 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'entries': entries,
|
||||||
|
'counts': counts,
|
||||||
|
'total_raw': len(raw_lines),
|
||||||
|
'log_file': log_file,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@logs_bp.route('/download')
|
||||||
|
@login_required
|
||||||
|
def download():
|
||||||
|
log_file = current_app.config.get('LOG_FILE_PATH', '')
|
||||||
|
if not log_file or not os.path.isfile(log_file):
|
||||||
|
abort(404)
|
||||||
|
return send_file(log_file, as_attachment=True, download_name='pfm-app.log')
|
||||||
|
|
||||||
|
|
||||||
|
@logs_bp.route('/clear', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def clear():
|
||||||
|
log_file = current_app.config.get('LOG_FILE_PATH', '')
|
||||||
|
if not log_file:
|
||||||
|
return jsonify({'error': 'LOG_FILE_PATH not configured'}), 400
|
||||||
|
try:
|
||||||
|
with open(log_file, 'w', encoding='utf-8'):
|
||||||
|
pass
|
||||||
|
log.info('Log file cleared by user')
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 500
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||||
<title>{% block title %}PFM{% endblock %} — Personal Finance</title>
|
<title>{% block title %}PFM{% endblock %} — Personal Finance</title>
|
||||||
<link
|
<link
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||||
@@ -260,6 +261,12 @@
|
|||||||
>
|
>
|
||||||
<i class="bi bi-tags"></i><span class="lt">Categories</span>
|
<i class="bi bi-tags"></i><span class="lt">Categories</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{ url_for('logs.index') }}"
|
||||||
|
class="sb-link {% if request.blueprint == 'logs' %}active{% endif %}"
|
||||||
|
>
|
||||||
|
<i class="bi bi-terminal"></i><span class="lt">System Logs</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('settings.index') }}"
|
href="{{ url_for('settings.index') }}"
|
||||||
class="sb-link {% if request.blueprint == 'settings' %}active{% endif %}"
|
class="sb-link {% if request.blueprint == 'settings' %}active{% endif %}"
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}System Logs{% endblock %}
|
||||||
|
{% block page_title %}System Logs{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.log-toolbar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.log-toolbar .sep { flex: 1; }
|
||||||
|
|
||||||
|
.level-badge {
|
||||||
|
display: inline-block; font-size: 11px; font-weight: 600;
|
||||||
|
padding: 2px 7px; border-radius: 4px; font-family: 'DM Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.level-INFO { background: #dbeafe; color: #1e40af; }
|
||||||
|
.level-WARNING { background: #fef9c3; color: #854d0e; }
|
||||||
|
.level-ERROR { background: #fee2e2; color: #991b1b; }
|
||||||
|
.level-CRITICAL { background: #fce7f3; color: #9d174d; }
|
||||||
|
.level-DEBUG { background: #f0fdf4; color: #166534; }
|
||||||
|
.level-RAW { background: #f1f5f9; color: #475569; }
|
||||||
|
|
||||||
|
.stat-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 3px 10px; border-radius: 20px; font-size: 12px;
|
||||||
|
font-weight: 600; cursor: pointer; border: 1.5px solid transparent;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.stat-pill:hover, .stat-pill.active { border-color: currentColor; opacity: 1; }
|
||||||
|
.stat-pill { opacity: .75; }
|
||||||
|
.pill-ALL { background: #f1f5f9; color: #475569; }
|
||||||
|
.pill-INFO { background: #dbeafe; color: #1e40af; }
|
||||||
|
.pill-WARNING { background: #fef9c3; color: #854d0e; }
|
||||||
|
.pill-ERROR { background: #fee2e2; color: #991b1b; }
|
||||||
|
.pill-CRITICAL { background: #fce7f3; color: #9d174d; }
|
||||||
|
.pill-DEBUG { background: #f0fdf4; color: #166534; }
|
||||||
|
|
||||||
|
#log-table-wrap {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
#log-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
#log-table thead th {
|
||||||
|
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||||||
|
letter-spacing: .07em; color: var(--muted); padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
#log-table tbody td {
|
||||||
|
padding: 7px 14px; border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top; word-break: break-word;
|
||||||
|
}
|
||||||
|
#log-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
#log-table tbody tr:hover { background: #f8fafc; }
|
||||||
|
#log-table tbody tr.row-ERROR td { background: #fff5f5; }
|
||||||
|
#log-table tbody tr.row-CRITICAL td { background: #fdf2f8; }
|
||||||
|
#log-table tbody tr.row-WARNING td { background: #fffbeb; }
|
||||||
|
|
||||||
|
.ts-col { white-space: nowrap; color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 148px; }
|
||||||
|
.name-col { color: var(--muted); font-family: 'DM Mono', monospace; font-size: 11px; width: 220px; }
|
||||||
|
.msg-col { font-family: 'DM Mono', monospace; }
|
||||||
|
|
||||||
|
#empty-state {
|
||||||
|
text-align: center; padding: 60px 20px; color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-refresh-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: #94a3b8; display: inline-block;
|
||||||
|
transition: background .3s;
|
||||||
|
}
|
||||||
|
.auto-refresh-dot.on { background: #10b981; animation: pulse-dot 2s infinite; }
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; } 50% { opacity: .4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#spinner { display: none; }
|
||||||
|
#spinner.on { display: inline-block; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar_actions %}
|
||||||
|
<a href="{{ url_for('logs.download') }}" class="btn btn-sm btn-outline-secondary" title="Download log file">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<h5 class="mb-0 fw-semibold">Application Logs</h5>
|
||||||
|
<span id="spinner" class="spinner-border spinner-border-sm text-secondary ms-1"></span>
|
||||||
|
<span class="small text-muted ms-auto mono" id="log-file-path">
|
||||||
|
{% if file_exists %}{{ log_file }}{% else %}Log file not found{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats bar -->
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3" id="stats-bar">
|
||||||
|
<span class="stat-pill pill-ALL active" data-level="ALL">All <span class="ms-1" id="cnt-ALL">—</span></span>
|
||||||
|
<span class="stat-pill pill-ERROR" data-level="ERROR">Error <span class="ms-1" id="cnt-ERROR">0</span></span>
|
||||||
|
<span class="stat-pill pill-WARNING" data-level="WARNING">Warning <span class="ms-1" id="cnt-WARNING">0</span></span>
|
||||||
|
<span class="stat-pill pill-INFO" data-level="INFO">Info <span class="ms-1" id="cnt-INFO">0</span></span>
|
||||||
|
<span class="stat-pill pill-DEBUG" data-level="DEBUG">Debug <span class="ms-1" id="cnt-DEBUG">0</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="log-toolbar">
|
||||||
|
<input id="search-input" type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="Search message or module…" style="max-width:240px;">
|
||||||
|
<input id="module-input" type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="Module filter (e.g. teller)" style="max-width:180px;">
|
||||||
|
<select id="limit-select" class="form-select form-select-sm" style="max-width:110px;">
|
||||||
|
<option value="100">Last 100</option>
|
||||||
|
<option value="200" selected>Last 200</option>
|
||||||
|
<option value="500">Last 500</option>
|
||||||
|
<option value="1000">Last 1000</option>
|
||||||
|
</select>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<label class="d-flex align-items-center gap-2 small text-muted mb-0" style="cursor:pointer;">
|
||||||
|
<span class="auto-refresh-dot" id="ar-dot"></span>
|
||||||
|
<input type="checkbox" id="auto-refresh" class="d-none"> Auto-refresh
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" id="clear-btn" title="Clear log file">
|
||||||
|
<i class="bi bi-trash"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log table -->
|
||||||
|
<div id="log-table-wrap">
|
||||||
|
<table id="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ts-col">Timestamp</th>
|
||||||
|
<th style="width:90px">Level</th>
|
||||||
|
<th class="name-col">Module</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-body">
|
||||||
|
<tr><td colspan="4" id="empty-state">
|
||||||
|
<i class="bi bi-hourglass-split fs-3 d-block mb-2 opacity-25"></i>
|
||||||
|
Loading…
|
||||||
|
</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2 small text-muted px-1">
|
||||||
|
<span id="result-count"></span>
|
||||||
|
<span id="last-refresh"></span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
let activeLevel = 'ALL';
|
||||||
|
let arTimer = null;
|
||||||
|
|
||||||
|
// ── fetch & render ────────────────────────────────────────────────────────
|
||||||
|
function load() {
|
||||||
|
const search = document.getElementById('search-input').value.trim();
|
||||||
|
const module = document.getElementById('module-input').value.trim();
|
||||||
|
const limit = document.getElementById('limit-select').value;
|
||||||
|
const spinner = document.getElementById('spinner');
|
||||||
|
spinner.classList.add('on');
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ level: activeLevel, search, module, limit });
|
||||||
|
fetch(`{{ url_for('logs.api') }}?${params}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
spinner.classList.remove('on');
|
||||||
|
renderTable(data.entries || []);
|
||||||
|
renderCounts(data.counts || {});
|
||||||
|
document.getElementById('last-refresh').textContent =
|
||||||
|
'Updated ' + new Date().toLocaleTimeString();
|
||||||
|
document.getElementById('result-count').textContent =
|
||||||
|
`Showing ${(data.entries||[]).length} of ${data.total_raw || 0} lines`;
|
||||||
|
})
|
||||||
|
.catch(() => spinner.classList.remove('on'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(entries) {
|
||||||
|
const tbody = document.getElementById('log-body');
|
||||||
|
if (!entries.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="4" id="empty-state">
|
||||||
|
<i class="bi bi-inbox fs-3 d-block mb-2 opacity-25"></i>
|
||||||
|
No log entries match your filters.
|
||||||
|
</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = entries.map(e => {
|
||||||
|
const lvl = e.level || 'RAW';
|
||||||
|
const safe = t => (t||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return `<tr class="row-${lvl}">
|
||||||
|
<td class="ts-col">${safe(e.ts)}</td>
|
||||||
|
<td><span class="level-badge level-${lvl}">${lvl}</span></td>
|
||||||
|
<td class="name-col" title="${safe(e.name)}">${safe(e.name.split('.').pop())}</td>
|
||||||
|
<td class="msg-col">${safe(e.message)}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
tbody.innerHTML = rows.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCounts(counts) {
|
||||||
|
const total = Object.values(counts).reduce((s,v)=>s+v, 0);
|
||||||
|
document.getElementById('cnt-ALL').textContent = total;
|
||||||
|
document.getElementById('cnt-ERROR').textContent = counts['ERROR'] || 0;
|
||||||
|
document.getElementById('cnt-WARNING').textContent = counts['WARNING'] || 0;
|
||||||
|
document.getElementById('cnt-INFO').textContent = counts['INFO'] || 0;
|
||||||
|
document.getElementById('cnt-DEBUG').textContent = counts['DEBUG'] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── level pills ───────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('stats-bar').addEventListener('click', function (e) {
|
||||||
|
const pill = e.target.closest('.stat-pill');
|
||||||
|
if (!pill) return;
|
||||||
|
document.querySelectorAll('.stat-pill').forEach(p => p.classList.remove('active'));
|
||||||
|
pill.classList.add('active');
|
||||||
|
activeLevel = pill.dataset.level;
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── search / filter inputs ────────────────────────────────────────────────
|
||||||
|
let debounce;
|
||||||
|
['search-input','module-input','limit-select'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('input', function () {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(load, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── auto-refresh ──────────────────────────────────────────────────────────
|
||||||
|
const arCheckbox = document.getElementById('auto-refresh');
|
||||||
|
const arDot = document.getElementById('ar-dot');
|
||||||
|
document.querySelector('label[for="auto-refresh"]') ||
|
||||||
|
document.querySelector('label').addEventListener; // noop
|
||||||
|
|
||||||
|
// toggle by clicking the label area
|
||||||
|
document.querySelector('.auto-refresh-dot').parentElement.addEventListener('click', function () {
|
||||||
|
arCheckbox.checked = !arCheckbox.checked;
|
||||||
|
arDot.classList.toggle('on', arCheckbox.checked);
|
||||||
|
if (arCheckbox.checked) {
|
||||||
|
arTimer = setInterval(load, 5000);
|
||||||
|
} else {
|
||||||
|
clearInterval(arTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── clear ─────────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('clear-btn').addEventListener('click', function () {
|
||||||
|
if (!confirm('Clear all log entries from the file? This cannot be undone.')) return;
|
||||||
|
fetch('{{ url_for("logs.clear") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': CSRF, 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.status === 'ok') load();
|
||||||
|
else alert('Clear failed: ' + d.error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── init ──────────────────────────────────────────────────────────────────
|
||||||
|
load();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user