143 lines
4.6 KiB
Python
143 lines
4.6 KiB
Python
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
|