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(): import datetime 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 file_mtime = None if file_exists: ts = os.path.getmtime(log_file) file_mtime = datetime.datetime.fromtimestamp(ts).strftime('%b %d, %H:%M') def fmt_size(b): if b < 1024: return f'{b} B' if b < 1024**2: return f'{b/1024:.1f} KB' return f'{b/1024**2:.1f} MB' return render_template('logs/index.html', log_file=log_file, file_exists=file_exists, file_size=file_size, file_size_fmt=fmt_size(file_size), file_mtime=file_mtime) @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 + unique modules across ALL unfiltered lines all_entries = [_parse_line(l) for l in raw_lines if l.strip()] counts = {} modules_seen = set() for e in all_entries: counts[e['level']] = counts.get(e['level'], 0) + 1 if e['name']: modules_seen.add(e['name']) return jsonify({ 'entries': entries, 'counts': counts, 'total_raw': len(raw_lines), 'log_file': log_file, 'modules': sorted(modules_seen), }) @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