All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 28s
296 lines
11 KiB
Python
296 lines
11 KiB
Python
import os
|
|
import re
|
|
from pathlib import Path
|
|
from django.conf import settings
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import render
|
|
from rest_framework.views import APIView
|
|
from rest_framework.response import Response
|
|
from rest_framework.permissions import AllowAny
|
|
from rest_framework import status
|
|
|
|
ALLOWED_LOG_FILES = {
|
|
'app.log': 'Application Logs (INFO+)',
|
|
'error.log': 'Error Logs (ERROR+)',
|
|
'requests.log': 'HTTP Request/Response Logs',
|
|
'gunicorn-access.log': 'Gunicorn Access Logs',
|
|
'gunicorn-error.log': 'Gunicorn Error Logs',
|
|
}
|
|
|
|
LOG_LEVEL_REGEX = re.compile(r'\b(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\b', re.IGNORECASE)
|
|
TIMESTAMP_REGEX = re.compile(r'\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]?')
|
|
|
|
def get_safe_log_path(filename):
|
|
"""Ensure requested file is strictly located inside settings.LOGS_DIR and in whitelist."""
|
|
if not filename:
|
|
filename = 'app.log'
|
|
# Security: basename only, no directory traversal
|
|
clean_filename = os.path.basename(filename)
|
|
if clean_filename not in ALLOWED_LOG_FILES and not clean_filename.endswith('.log'):
|
|
return None
|
|
full_path = Path(settings.LOGS_DIR).resolve() / clean_filename
|
|
# Prevent traversal
|
|
if not str(full_path).startswith(str(Path(settings.LOGS_DIR).resolve())):
|
|
return None
|
|
return full_path
|
|
|
|
def parse_log_line(line, line_num=0, source_file=''):
|
|
"""Extract structured details from a log line."""
|
|
clean_line = line.rstrip('\r\n')
|
|
level = 'INFO'
|
|
timestamp = ''
|
|
logger_name = ''
|
|
message = clean_line
|
|
|
|
ts_match = TIMESTAMP_REGEX.search(clean_line)
|
|
if ts_match:
|
|
timestamp = ts_match.group(1)
|
|
|
|
lvl_match = LOG_LEVEL_REGEX.search(clean_line)
|
|
if lvl_match:
|
|
raw_lvl = lvl_match.group(1).upper()
|
|
level = 'WARNING' if raw_lvl == 'WARN' else raw_lvl
|
|
|
|
# Try to parse pipe-separated format: [ts] LEVEL name ... | message
|
|
if ' | ' in clean_line:
|
|
parts = clean_line.split(' | ', 1)
|
|
header = parts[0]
|
|
message = parts[1]
|
|
header_parts = header.split()
|
|
for p in header_parts:
|
|
if p in ('api', 'django.request', 'django.security', 'root') or '.' in p:
|
|
logger_name = p
|
|
break
|
|
else:
|
|
# Fallback message
|
|
message = clean_line
|
|
|
|
return {
|
|
'id': line_num,
|
|
'source': source_file,
|
|
'timestamp': timestamp,
|
|
'level': level,
|
|
'logger': logger_name,
|
|
'message': message,
|
|
'raw': clean_line,
|
|
}
|
|
|
|
class LogsView(APIView):
|
|
"""
|
|
### 📊 Looking for the Interactive Log Dashboard?
|
|
Visit **[/dashboard/](/dashboard/)** for the full UI featuring real-time log streaming, level filters, keyword search, file selector, and API test runners.
|
|
"""
|
|
permission_classes = [AllowAny]
|
|
authentication_classes = []
|
|
|
|
def get(self, request):
|
|
filename = request.query_params.get('file', 'app.log')
|
|
level_filter = request.query_params.get('level', 'ALL').upper()
|
|
search_query = request.query_params.get('search', '').strip()
|
|
tail = request.query_params.get('tail', 'true').lower() in ('true', '1')
|
|
raw_format = request.query_params.get('raw', 'false').lower() in ('true', '1')
|
|
|
|
try:
|
|
limit = int(request.query_params.get('limit', 150))
|
|
limit = max(1, min(limit, 2000))
|
|
except ValueError:
|
|
limit = 150
|
|
|
|
# Handle 'all' to read across multiple files
|
|
target_files = []
|
|
if filename == 'all':
|
|
target_files = ['app.log', 'error.log', 'requests.log']
|
|
else:
|
|
safe_path = get_safe_log_path(filename)
|
|
if not safe_path:
|
|
return Response({'error': f'Invalid or disallowed log file name: {filename}'}, status=status.HTTP_400_BAD_REQUEST)
|
|
target_files = [safe_path.name]
|
|
|
|
all_entries = []
|
|
line_counter = 0
|
|
file_stats = {}
|
|
level_counts = {'DEBUG': 0, 'INFO': 0, 'WARNING': 0, 'ERROR': 0, 'CRITICAL': 0}
|
|
|
|
for fname in target_files:
|
|
file_path = Path(settings.LOGS_DIR) / fname
|
|
if not file_path.exists():
|
|
file_stats[fname] = {'exists': False, 'size_bytes': 0, 'lines': 0}
|
|
continue
|
|
|
|
size_bytes = file_path.stat().st_size
|
|
file_stats[fname] = {
|
|
'exists': True,
|
|
'size_bytes': size_bytes,
|
|
'size_kb': round(size_bytes / 1024, 2),
|
|
}
|
|
|
|
try:
|
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
lines = f.readlines()
|
|
file_stats[fname]['lines'] = len(lines)
|
|
|
|
for raw_line in lines:
|
|
if not raw_line.strip():
|
|
continue
|
|
line_counter += 1
|
|
parsed = parse_log_line(raw_line, line_num=line_counter, source_file=fname)
|
|
lvl = parsed['level']
|
|
if lvl in level_counts:
|
|
level_counts[lvl] += 1
|
|
all_entries.append(parsed)
|
|
except Exception as e:
|
|
return Response({'error': f'Failed reading {fname}: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
# Apply filtering
|
|
filtered = all_entries
|
|
|
|
# Level filter
|
|
if level_filter and level_filter != 'ALL':
|
|
filtered = [entry for entry in filtered if entry['level'] == level_filter]
|
|
|
|
# Search filter
|
|
if search_query:
|
|
q = search_query.lower()
|
|
filtered = [
|
|
entry for entry in filtered
|
|
if q in entry['raw'].lower() or q in entry['message'].lower() or q in entry['logger'].lower()
|
|
]
|
|
|
|
# Order & Limit
|
|
total_matched = len(filtered)
|
|
if tail:
|
|
# Take the most recent entries
|
|
display_entries = filtered[-limit:]
|
|
else:
|
|
display_entries = filtered[:limit]
|
|
|
|
if raw_format:
|
|
raw_text = '\n'.join([e['raw'] for e in display_entries])
|
|
return HttpResponse(raw_text, content_type='text/plain; charset=utf-8')
|
|
|
|
return Response({
|
|
'success': True,
|
|
'file': filename,
|
|
'available_files': list(ALLOWED_LOG_FILES.keys()),
|
|
'file_stats': file_stats,
|
|
'total_lines_read': line_counter,
|
|
'total_matched': total_matched,
|
|
'returned_count': len(display_entries),
|
|
'level_counts': level_counts,
|
|
'filters': {
|
|
'file': filename,
|
|
'level': level_filter,
|
|
'search': search_query,
|
|
'limit': limit,
|
|
'tail': tail,
|
|
},
|
|
'logs': display_entries,
|
|
})
|
|
|
|
class LogStatsView(APIView):
|
|
permission_classes = [AllowAny]
|
|
authentication_classes = []
|
|
|
|
def get(self, request):
|
|
stats = {}
|
|
total_size = 0
|
|
total_errors = 0
|
|
total_warnings = 0
|
|
total_lines = 0
|
|
|
|
for filename, desc in ALLOWED_LOG_FILES.items():
|
|
file_path = Path(settings.LOGS_DIR) / filename
|
|
if not file_path.exists():
|
|
stats[filename] = {
|
|
'description': desc,
|
|
'exists': False,
|
|
'size_bytes': 0,
|
|
'size_formatted': '0 KB',
|
|
'line_count': 0,
|
|
'error_count': 0,
|
|
'last_modified': None,
|
|
}
|
|
continue
|
|
|
|
st = file_path.stat()
|
|
size = st.st_size
|
|
total_size += size
|
|
mtime = st.st_mtime
|
|
|
|
# Count lines and errors
|
|
lines_cnt = 0
|
|
err_cnt = 0
|
|
warn_cnt = 0
|
|
try:
|
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
for line in f:
|
|
lines_cnt += 1
|
|
up = line.upper()
|
|
if 'ERROR' in up or 'CRITICAL' in up:
|
|
err_cnt += 1
|
|
elif 'WARN' in up:
|
|
warn_cnt += 1
|
|
except Exception:
|
|
pass
|
|
|
|
total_lines += lines_cnt
|
|
total_errors += err_cnt
|
|
total_warnings += warn_cnt
|
|
|
|
stats[filename] = {
|
|
'description': desc,
|
|
'exists': True,
|
|
'size_bytes': size,
|
|
'size_formatted': f"{round(size / 1024, 2)} KB" if size < 1024 * 1024 else f"{round(size / (1024 * 1024), 2)} MB",
|
|
'line_count': lines_cnt,
|
|
'error_count': err_cnt,
|
|
'warning_count': warn_cnt,
|
|
'last_modified': mtime,
|
|
}
|
|
|
|
return Response({
|
|
'files': stats,
|
|
'summary': {
|
|
'total_files': len(ALLOWED_LOG_FILES),
|
|
'total_lines': total_lines,
|
|
'total_errors': total_errors,
|
|
'total_warnings': total_warnings,
|
|
'total_size_bytes': total_size,
|
|
'total_size_formatted': f"{round(total_size / 1024, 2)} KB" if total_size < 1024 * 1024 else f"{round(total_size / (1024 * 1024), 2)} MB",
|
|
}
|
|
})
|
|
|
|
class LogClearView(APIView):
|
|
permission_classes = [AllowAny]
|
|
authentication_classes = []
|
|
|
|
def post(self, request):
|
|
filename = request.data.get('file', 'app.log')
|
|
if filename == 'all':
|
|
cleared = []
|
|
for fname in ALLOWED_LOG_FILES.keys():
|
|
file_path = Path(settings.LOGS_DIR) / fname
|
|
if file_path.exists():
|
|
with open(file_path, 'w', encoding='utf-8') as f:
|
|
f.write('')
|
|
cleared.append(fname)
|
|
return Response({'success': True, 'cleared': cleared, 'message': f'Cleared {len(cleared)} log files'})
|
|
|
|
safe_path = get_safe_log_path(filename)
|
|
if not safe_path:
|
|
return Response({'error': f'Invalid log file: {filename}'}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if not safe_path.exists():
|
|
return Response({'error': f'Log file does not exist: {filename}'}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
with open(safe_path, 'w', encoding='utf-8') as f:
|
|
f.write('')
|
|
|
|
return Response({'success': True, 'file': filename, 'message': f'Log file {filename} truncated successfully'})
|
|
|
|
def dashboard_view(request):
|
|
"""Serve the single-page testing and log viewer dashboard."""
|
|
return render(request, 'dashboard.html', {
|
|
'debug': settings.DEBUG,
|
|
'environment': os.environ.get('ENVIRONMENT', 'local'),
|
|
})
|