From acf4d9bb43ae0b7e170882e4ed7e87ade494bf25 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 1 Jun 2026 11:09:06 -0400 Subject: [PATCH] 06/01 Add log viewer --- app/__init__.py | 66 ++++++--- app/config.py | 8 + app/routes/logs.py | 125 ++++++++++++++++ app/templates/base.html | 7 + app/templates/logs/index.html | 272 ++++++++++++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 24 deletions(-) create mode 100644 app/routes/logs.py create mode 100644 app/templates/logs/index.html diff --git a/app/__init__.py b/app/__init__.py index 9ff7e26..b02c291 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,38 +8,54 @@ from app.utils.formatters import format_currency, format_percent, format_large_n 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 - error log / stderr) but its level is WARNING, so INFO records are dropped - before they reach any handler. We fix that by: - 1. Reusing Gunicorn's handlers on the 'app' namespace logger so records - go to the same destination as Gunicorn's own logs. - 2. Falling back to a plain stderr StreamHandler in dev / direct-run mode. + Uses a pipe-delimited format so the log viewer can parse each field easily: + 2026-06-01 12:00:00|INFO|app.services.teller_service|message text + + The file path comes from LOG_FILE_PATH config (defaults to logs/app.log + next to the project root). The directory is created automatically. """ import sys + from logging.handlers import RotatingFileHandler app_log = logging.getLogger('app') - app_log.setLevel(logging.INFO) - 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 - if gunicorn_handlers: - # 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.setLevel(logging.INFO) + app_log.propagate = False # don't double-emit through the root logger - 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): @@ -73,6 +89,7 @@ def create_app(config_name=None): from app.routes.reports import reports_bp from app.routes.settings import settings_bp from app.routes.teller import teller_bp + from app.routes.logs import logs_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -86,6 +103,7 @@ def create_app(config_name=None): app.register_blueprint(reports_bp) app.register_blueprint(settings_bp) app.register_blueprint(teller_bp) + app.register_blueprint(logs_bp) with app.app_context(): from app.models import ( diff --git a/app/config.py b/app/config.py index bc4d62c..d6ef60e 100644 --- a/app/config.py +++ b/app/config.py @@ -30,6 +30,14 @@ class Config: TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem') 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): DEBUG = True diff --git a/app/routes/logs.py b/app/routes/logs.py new file mode 100644 index 0000000..a1e232a --- /dev/null +++ b/app/routes/logs.py @@ -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 diff --git a/app/templates/base.html b/app/templates/base.html index 0777f65..44d318d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,6 +3,7 @@ + {% block title %}PFM{% endblock %} — Personal Finance Categories + + System Logs + + .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; } + +{% endblock %} + +{% block topbar_actions %} + + + +{% endblock %} + +{% block content %} +
+
Application Logs
+ + + {% if file_exists %}{{ log_file }}{% else %}Log file not found{% endif %} + +
+ + +
+ All + Error 0 + Warning 0 + Info 0 + Debug 0 +
+ + +
+ + + +
+ + +
+ + +
+ + + + + + + + + + + + +
TimestampLevelModuleMessage
+ + Loading… +
+
+ +
+ + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %}