python-log-viewer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_log_viewer/__init__.py +10 -0
- python_log_viewer/_html.py +775 -0
- python_log_viewer/auth.py +39 -0
- python_log_viewer/contrib/__init__.py +1 -0
- python_log_viewer/contrib/django/__init__.py +4 -0
- python_log_viewer/contrib/django/apps.py +8 -0
- python_log_viewer/contrib/django/urls.py +19 -0
- python_log_viewer/contrib/django/views.py +163 -0
- python_log_viewer/contrib/fastapi.py +124 -0
- python_log_viewer/contrib/flask.py +124 -0
- python_log_viewer/core.py +165 -0
- python_log_viewer-0.1.0.dist-info/METADATA +283 -0
- python_log_viewer-0.1.0.dist-info/RECORD +14 -0
- python_log_viewer-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Basic Authentication helpers (stdlib only).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import secrets
|
|
9
|
+
from typing import Optional, Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decode_basic_auth(header_value: str) -> Optional[Tuple[str, str]]:
|
|
13
|
+
"""Decode an ``Authorization: Basic ...`` header.
|
|
14
|
+
|
|
15
|
+
Returns ``(username, password)`` or ``None`` on failure.
|
|
16
|
+
"""
|
|
17
|
+
if not header_value.startswith("Basic "):
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
decoded = base64.b64decode(header_value[6:]).decode("utf-8")
|
|
21
|
+
username, password = decoded.split(":", 1)
|
|
22
|
+
return username, password
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_credentials(
|
|
28
|
+
header_value: str,
|
|
29
|
+
expected_username: str,
|
|
30
|
+
expected_password: str,
|
|
31
|
+
) -> bool:
|
|
32
|
+
"""Verify Basic-Auth credentials using constant-time comparison."""
|
|
33
|
+
creds = decode_basic_auth(header_value)
|
|
34
|
+
if creds is None:
|
|
35
|
+
return False
|
|
36
|
+
username, password = creds
|
|
37
|
+
return secrets.compare_digest(username, expected_username) and secrets.compare_digest(
|
|
38
|
+
password, expected_password
|
|
39
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Framework integrations for python-log-viewer.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from .views import (
|
|
4
|
+
log_viewer_page,
|
|
5
|
+
get_log_files,
|
|
6
|
+
get_log_content,
|
|
7
|
+
delete_log_file,
|
|
8
|
+
clear_log_file,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
app_name = "log_viewer"
|
|
12
|
+
|
|
13
|
+
urlpatterns = [
|
|
14
|
+
path("", log_viewer_page, name="log_viewer"),
|
|
15
|
+
path("api/files", get_log_files, name="log_viewer_files"),
|
|
16
|
+
path("api/content", get_log_content, name="log_viewer_content"),
|
|
17
|
+
path("api/file", delete_log_file, name="log_viewer_delete"),
|
|
18
|
+
path("api/clear", clear_log_file, name="log_viewer_clear"),
|
|
19
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django views for the log viewer.
|
|
3
|
+
|
|
4
|
+
Settings (all optional, set in ``settings.py``)::
|
|
5
|
+
|
|
6
|
+
LOG_VIEWER_DIR = BASE_DIR / "logs" # path to log directory
|
|
7
|
+
LOG_VIEWER_AUTO_REFRESH = True
|
|
8
|
+
LOG_VIEWER_REFRESH_TIMER = 5000 # ms
|
|
9
|
+
LOG_VIEWER_AUTO_SCROLL = True
|
|
10
|
+
LOG_VIEWER_COLORIZE = True
|
|
11
|
+
LOG_VIEWER_USERNAME = None # set both to enable Basic Auth
|
|
12
|
+
LOG_VIEWER_PASSWORD = None
|
|
13
|
+
LOG_VIEWER_SUPERUSER_ACCESS = True # allow Django superusers without Basic Auth
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from functools import wraps
|
|
20
|
+
|
|
21
|
+
from django.conf import settings
|
|
22
|
+
from django.http import HttpResponse, JsonResponse
|
|
23
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
24
|
+
from django.views.decorators.http import require_GET, require_POST, require_http_methods
|
|
25
|
+
|
|
26
|
+
from python_log_viewer.auth import check_credentials
|
|
27
|
+
from python_log_viewer.core import LogDirectory, LogReader
|
|
28
|
+
from python_log_viewer._html import render_html
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Lazy singletons – created once, reused across requests.
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _get_log_dir() -> LogDirectory:
|
|
36
|
+
path = getattr(settings, "LOG_VIEWER_DIR", None)
|
|
37
|
+
if path is None:
|
|
38
|
+
path = os.path.join(settings.BASE_DIR, "logs")
|
|
39
|
+
return LogDirectory(str(path))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_reader() -> LogReader:
|
|
43
|
+
return LogReader(_get_log_dir())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Authentication decorator
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def _basic_auth_required(view_func):
|
|
51
|
+
"""Decorator: enforce HTTP Basic Auth when credentials are configured.
|
|
52
|
+
|
|
53
|
+
Authenticated Django superusers are allowed through automatically when
|
|
54
|
+
``LOG_VIEWER_SUPERUSER_ACCESS`` is ``True`` (the default).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@wraps(view_func)
|
|
58
|
+
def _wrapped(request, *args, **kwargs):
|
|
59
|
+
# Allow Django superusers to bypass Basic Auth
|
|
60
|
+
superuser_access = getattr(settings, "LOG_VIEWER_SUPERUSER_ACCESS", True)
|
|
61
|
+
if superuser_access:
|
|
62
|
+
user = getattr(request, "user", None)
|
|
63
|
+
if user is not None and getattr(user, "is_authenticated", False) and getattr(user, "is_superuser", False):
|
|
64
|
+
return view_func(request, *args, **kwargs)
|
|
65
|
+
|
|
66
|
+
username = getattr(settings, "LOG_VIEWER_USERNAME", None) or os.getenv("LOG_VIEWER_USERNAME")
|
|
67
|
+
password = getattr(settings, "LOG_VIEWER_PASSWORD", None) or os.getenv("LOG_VIEWER_PASSWORD")
|
|
68
|
+
|
|
69
|
+
if username and password:
|
|
70
|
+
auth = request.META.get("HTTP_AUTHORIZATION", "")
|
|
71
|
+
if not check_credentials(auth, username, password):
|
|
72
|
+
return HttpResponse(
|
|
73
|
+
"Authentication required",
|
|
74
|
+
status=401,
|
|
75
|
+
headers={"WWW-Authenticate": 'Basic realm="Log Viewer"'},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return view_func(request, *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
return _wrapped
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Views
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@_basic_auth_required
|
|
88
|
+
@require_GET
|
|
89
|
+
def log_viewer_page(request):
|
|
90
|
+
"""Serve the log viewer HTML page."""
|
|
91
|
+
# Read the URL prefix from the request path
|
|
92
|
+
# (strip trailing /api/... or trailing / to get the mount point)
|
|
93
|
+
prefix = request.path.rstrip("/")
|
|
94
|
+
|
|
95
|
+
html = render_html(
|
|
96
|
+
base_url=prefix,
|
|
97
|
+
auto_refresh=getattr(settings, "LOG_VIEWER_AUTO_REFRESH", True),
|
|
98
|
+
refresh_timer=getattr(settings, "LOG_VIEWER_REFRESH_TIMER", 5000),
|
|
99
|
+
auto_scroll=getattr(settings, "LOG_VIEWER_AUTO_SCROLL", True),
|
|
100
|
+
colorize=getattr(settings, "LOG_VIEWER_COLORIZE", True),
|
|
101
|
+
)
|
|
102
|
+
return HttpResponse(html, content_type="text/html")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@_basic_auth_required
|
|
106
|
+
@require_GET
|
|
107
|
+
def get_log_files(request):
|
|
108
|
+
"""Return a list of available log files with metadata."""
|
|
109
|
+
try:
|
|
110
|
+
files = _get_log_dir().list_files()
|
|
111
|
+
return JsonResponse(
|
|
112
|
+
{"files": [{"name": f.name, "size": f.size, "modified": f.modified} for f in files]}
|
|
113
|
+
)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
return JsonResponse({"files": [], "error": str(e)})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@_basic_auth_required
|
|
119
|
+
@require_GET
|
|
120
|
+
def get_log_content(request):
|
|
121
|
+
"""Return log lines from the selected file as JSON."""
|
|
122
|
+
try:
|
|
123
|
+
result = _get_reader().read(
|
|
124
|
+
file=request.GET.get("file", "app.log"),
|
|
125
|
+
lines=int(request.GET.get("lines", "500")),
|
|
126
|
+
level=request.GET.get("level", ""),
|
|
127
|
+
search=request.GET.get("search", ""),
|
|
128
|
+
)
|
|
129
|
+
return JsonResponse(result)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
return JsonResponse({"lines": [f"Error reading log file: {e}"], "total": 0})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@csrf_exempt
|
|
135
|
+
@_basic_auth_required
|
|
136
|
+
@require_http_methods(["DELETE"])
|
|
137
|
+
def delete_log_file(request):
|
|
138
|
+
"""Permanently delete a log file."""
|
|
139
|
+
try:
|
|
140
|
+
file_param = request.GET.get("file", "")
|
|
141
|
+
if _get_log_dir().delete_file(file_param):
|
|
142
|
+
return JsonResponse(
|
|
143
|
+
{"success": True, "message": f"{os.path.basename(file_param)} deleted"}
|
|
144
|
+
)
|
|
145
|
+
return JsonResponse({"success": False, "error": "Invalid or missing file"}, status=404)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@csrf_exempt
|
|
151
|
+
@_basic_auth_required
|
|
152
|
+
@require_POST
|
|
153
|
+
def clear_log_file(request):
|
|
154
|
+
"""Clear the contents of a log file (truncate to 0 bytes)."""
|
|
155
|
+
try:
|
|
156
|
+
file_param = request.GET.get("file", "")
|
|
157
|
+
if _get_log_dir().clear_file(file_param):
|
|
158
|
+
return JsonResponse(
|
|
159
|
+
{"success": True, "message": f"{os.path.basename(file_param)} cleared"}
|
|
160
|
+
)
|
|
161
|
+
return JsonResponse({"success": False, "error": "Invalid or missing file"}, status=404)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
return JsonResponse({"success": False, "error": str(e)}, status=500)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI integration for python-log-viewer.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from python_log_viewer.contrib.fastapi import create_log_viewer_router
|
|
8
|
+
|
|
9
|
+
app = FastAPI()
|
|
10
|
+
app.include_router(
|
|
11
|
+
create_log_viewer_router(
|
|
12
|
+
log_dir="./logs",
|
|
13
|
+
prefix="/logs",
|
|
14
|
+
username="admin",
|
|
15
|
+
password="secret",
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from python_log_viewer.core import LogDirectory, LogReader
|
|
26
|
+
from python_log_viewer._html import render_html
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_log_viewer_router(
|
|
30
|
+
log_dir: str = "./logs",
|
|
31
|
+
prefix: str = "/logs",
|
|
32
|
+
username: Optional[str] = None,
|
|
33
|
+
password: Optional[str] = None,
|
|
34
|
+
auto_refresh: bool = True,
|
|
35
|
+
refresh_timer: int = 5000,
|
|
36
|
+
auto_scroll: bool = True,
|
|
37
|
+
colorize: bool = True,
|
|
38
|
+
):
|
|
39
|
+
"""Create and return a FastAPI :class:`~fastapi.APIRouter`.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
log_dir:
|
|
44
|
+
Path to the directory containing log files.
|
|
45
|
+
prefix:
|
|
46
|
+
URL prefix to mount the router at (e.g. ``/logs``).
|
|
47
|
+
username / password:
|
|
48
|
+
Enable HTTP Basic Auth when both are provided.
|
|
49
|
+
auto_refresh / refresh_timer / auto_scroll / colorize:
|
|
50
|
+
UI defaults.
|
|
51
|
+
"""
|
|
52
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
53
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
54
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
55
|
+
|
|
56
|
+
from python_log_viewer.auth import check_credentials as _check
|
|
57
|
+
|
|
58
|
+
import secrets as _secrets
|
|
59
|
+
|
|
60
|
+
directory = LogDirectory(log_dir)
|
|
61
|
+
reader = LogReader(directory)
|
|
62
|
+
router = APIRouter(prefix=prefix, tags=["python-log-viewer"])
|
|
63
|
+
|
|
64
|
+
# ---- auth dependency ------------------------------------------------
|
|
65
|
+
|
|
66
|
+
_security = HTTPBasic(auto_error=False)
|
|
67
|
+
|
|
68
|
+
async def _verify(credentials: Optional[HTTPBasicCredentials] = Depends(_security)):
|
|
69
|
+
if username and password:
|
|
70
|
+
if credentials is None:
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=401,
|
|
73
|
+
detail="Authentication required",
|
|
74
|
+
headers={"WWW-Authenticate": 'Basic realm="Log Viewer"'},
|
|
75
|
+
)
|
|
76
|
+
if not (
|
|
77
|
+
_secrets.compare_digest(credentials.username, username)
|
|
78
|
+
and _secrets.compare_digest(credentials.password, password)
|
|
79
|
+
):
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=401,
|
|
82
|
+
detail="Invalid credentials",
|
|
83
|
+
headers={"WWW-Authenticate": 'Basic realm="Log Viewer"'},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ---- routes ---------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
@router.get("/", response_class=HTMLResponse, dependencies=[Depends(_verify)])
|
|
89
|
+
async def index():
|
|
90
|
+
return render_html(
|
|
91
|
+
base_url=prefix,
|
|
92
|
+
auto_refresh=auto_refresh,
|
|
93
|
+
refresh_timer=refresh_timer,
|
|
94
|
+
auto_scroll=auto_scroll,
|
|
95
|
+
colorize=colorize,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@router.get("/api/files", dependencies=[Depends(_verify)])
|
|
99
|
+
async def api_files():
|
|
100
|
+
files = directory.list_files()
|
|
101
|
+
return {"files": [{"name": f.name, "size": f.size, "modified": f.modified} for f in files]}
|
|
102
|
+
|
|
103
|
+
@router.get("/api/content", dependencies=[Depends(_verify)])
|
|
104
|
+
async def api_content(
|
|
105
|
+
file: str = Query("app.log"),
|
|
106
|
+
lines: int = Query(500),
|
|
107
|
+
level: str = Query(""),
|
|
108
|
+
search: str = Query(""),
|
|
109
|
+
):
|
|
110
|
+
return reader.read(file=file, lines=lines, level=level, search=search)
|
|
111
|
+
|
|
112
|
+
@router.delete("/api/file", dependencies=[Depends(_verify)])
|
|
113
|
+
async def api_delete(file: str = Query("")):
|
|
114
|
+
if directory.delete_file(file):
|
|
115
|
+
return {"success": True, "message": f"{os.path.basename(file)} deleted"}
|
|
116
|
+
return JSONResponse({"success": False, "error": "Invalid or missing file"}, status_code=404)
|
|
117
|
+
|
|
118
|
+
@router.post("/api/clear", dependencies=[Depends(_verify)])
|
|
119
|
+
async def api_clear(file: str = Query("")):
|
|
120
|
+
if directory.clear_file(file):
|
|
121
|
+
return {"success": True, "message": f"{os.path.basename(file)} cleared"}
|
|
122
|
+
return JSONResponse({"success": False, "error": "Invalid or missing file"}, status_code=404)
|
|
123
|
+
|
|
124
|
+
return router
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask integration for python-log-viewer.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from flask import Flask
|
|
7
|
+
from python_log_viewer.contrib.flask import create_log_viewer_blueprint
|
|
8
|
+
|
|
9
|
+
app = Flask(__name__)
|
|
10
|
+
app.register_blueprint(
|
|
11
|
+
create_log_viewer_blueprint(
|
|
12
|
+
log_dir="./logs",
|
|
13
|
+
url_prefix="/logs",
|
|
14
|
+
username="admin",
|
|
15
|
+
password="secret",
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from functools import wraps
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from python_log_viewer.core import LogDirectory, LogReader
|
|
27
|
+
from python_log_viewer._html import render_html
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_log_viewer_blueprint(
|
|
31
|
+
log_dir: str = "./logs",
|
|
32
|
+
url_prefix: str = "/logs",
|
|
33
|
+
username: Optional[str] = None,
|
|
34
|
+
password: Optional[str] = None,
|
|
35
|
+
auto_refresh: bool = True,
|
|
36
|
+
refresh_timer: int = 5000,
|
|
37
|
+
auto_scroll: bool = True,
|
|
38
|
+
colorize: bool = True,
|
|
39
|
+
):
|
|
40
|
+
"""Create and return a Flask :class:`~flask.Blueprint` for the log viewer.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
log_dir:
|
|
45
|
+
Path to the directory containing log files.
|
|
46
|
+
url_prefix:
|
|
47
|
+
URL prefix to mount the blueprint at (e.g. ``/logs``).
|
|
48
|
+
username / password:
|
|
49
|
+
Enable HTTP Basic Auth when both are provided.
|
|
50
|
+
auto_refresh / refresh_timer / auto_scroll / colorize:
|
|
51
|
+
UI defaults.
|
|
52
|
+
"""
|
|
53
|
+
from flask import Blueprint, jsonify, request, Response
|
|
54
|
+
|
|
55
|
+
from python_log_viewer.auth import check_credentials
|
|
56
|
+
|
|
57
|
+
directory = LogDirectory(log_dir)
|
|
58
|
+
reader = LogReader(directory)
|
|
59
|
+
bp = Blueprint("log_viewer", __name__, url_prefix=url_prefix)
|
|
60
|
+
|
|
61
|
+
# ---- auth decorator -------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _auth_required(fn):
|
|
64
|
+
@wraps(fn)
|
|
65
|
+
def wrapper(*args, **kwargs):
|
|
66
|
+
if username and password:
|
|
67
|
+
auth = request.headers.get("Authorization", "")
|
|
68
|
+
if not check_credentials(auth, username, password):
|
|
69
|
+
return Response(
|
|
70
|
+
"Authentication required",
|
|
71
|
+
401,
|
|
72
|
+
{"WWW-Authenticate": 'Basic realm="Log Viewer"'},
|
|
73
|
+
)
|
|
74
|
+
return fn(*args, **kwargs)
|
|
75
|
+
return wrapper
|
|
76
|
+
|
|
77
|
+
# ---- routes ---------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
@bp.route("/", methods=["GET"])
|
|
80
|
+
@_auth_required
|
|
81
|
+
def index():
|
|
82
|
+
html = render_html(
|
|
83
|
+
base_url=url_prefix,
|
|
84
|
+
auto_refresh=auto_refresh,
|
|
85
|
+
refresh_timer=refresh_timer,
|
|
86
|
+
auto_scroll=auto_scroll,
|
|
87
|
+
colorize=colorize,
|
|
88
|
+
)
|
|
89
|
+
return Response(html, content_type="text/html")
|
|
90
|
+
|
|
91
|
+
@bp.route("/api/files", methods=["GET"])
|
|
92
|
+
@_auth_required
|
|
93
|
+
def api_files():
|
|
94
|
+
files = directory.list_files()
|
|
95
|
+
return jsonify({"files": [{"name": f.name, "size": f.size, "modified": f.modified} for f in files]})
|
|
96
|
+
|
|
97
|
+
@bp.route("/api/content", methods=["GET"])
|
|
98
|
+
@_auth_required
|
|
99
|
+
def api_content():
|
|
100
|
+
result = reader.read(
|
|
101
|
+
file=request.args.get("file", "app.log"),
|
|
102
|
+
lines=int(request.args.get("lines", "500")),
|
|
103
|
+
level=request.args.get("level", ""),
|
|
104
|
+
search=request.args.get("search", ""),
|
|
105
|
+
)
|
|
106
|
+
return jsonify(result)
|
|
107
|
+
|
|
108
|
+
@bp.route("/api/file", methods=["DELETE"])
|
|
109
|
+
@_auth_required
|
|
110
|
+
def api_delete():
|
|
111
|
+
file_param = request.args.get("file", "")
|
|
112
|
+
if directory.delete_file(file_param):
|
|
113
|
+
return jsonify({"success": True, "message": f"{os.path.basename(file_param)} deleted"})
|
|
114
|
+
return jsonify({"success": False, "error": "Invalid or missing file"}), 404
|
|
115
|
+
|
|
116
|
+
@bp.route("/api/clear", methods=["POST"])
|
|
117
|
+
@_auth_required
|
|
118
|
+
def api_clear():
|
|
119
|
+
file_param = request.args.get("file", "")
|
|
120
|
+
if directory.clear_file(file_param):
|
|
121
|
+
return jsonify({"success": True, "message": f"{os.path.basename(file_param)} cleared"})
|
|
122
|
+
return jsonify({"success": False, "error": "Invalid or missing file"}), 404
|
|
123
|
+
|
|
124
|
+
return bp
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework-agnostic core logic for reading and managing log files.
|
|
3
|
+
|
|
4
|
+
No external dependencies – only the Python standard library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class LogFileInfo:
|
|
16
|
+
"""Metadata for a single log file."""
|
|
17
|
+
|
|
18
|
+
name: str # relative path from the log directory
|
|
19
|
+
size: int
|
|
20
|
+
modified: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LogDirectory:
|
|
24
|
+
"""Represents a directory tree of log files.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
path:
|
|
29
|
+
Absolute or relative path to the root log directory.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: str) -> None:
|
|
33
|
+
self.path = os.path.abspath(path)
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Listing
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def list_files(self) -> List[LogFileInfo]:
|
|
40
|
+
"""Walk *self.path* and return metadata for every regular file."""
|
|
41
|
+
if not os.path.isdir(self.path):
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
files: list[LogFileInfo] = []
|
|
45
|
+
for root, _dirs, filenames in os.walk(self.path):
|
|
46
|
+
for entry in sorted(filenames):
|
|
47
|
+
filepath = os.path.join(root, entry)
|
|
48
|
+
if os.path.isfile(filepath):
|
|
49
|
+
rel = os.path.relpath(filepath, self.path)
|
|
50
|
+
stat = os.stat(filepath)
|
|
51
|
+
files.append(
|
|
52
|
+
LogFileInfo(name=rel, size=stat.st_size, modified=stat.st_mtime)
|
|
53
|
+
)
|
|
54
|
+
files.sort(key=lambda f: f.name)
|
|
55
|
+
return files
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Safe path resolution
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _safe_resolve(self, relative: str) -> Optional[str]:
|
|
62
|
+
"""Return the absolute path if *relative* stays inside *self.path*.
|
|
63
|
+
|
|
64
|
+
Returns ``None`` when the path escapes the log directory.
|
|
65
|
+
"""
|
|
66
|
+
safe = os.path.normpath(relative)
|
|
67
|
+
if safe.startswith("..") or os.path.isabs(safe):
|
|
68
|
+
return None
|
|
69
|
+
full = os.path.realpath(os.path.join(self.path, safe))
|
|
70
|
+
if not full.startswith(os.path.realpath(self.path)):
|
|
71
|
+
return None
|
|
72
|
+
if not os.path.isfile(full):
|
|
73
|
+
return None
|
|
74
|
+
return full
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# File mutations
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def delete_file(self, relative: str) -> bool:
|
|
81
|
+
"""Permanently remove a log file. Returns *True* on success."""
|
|
82
|
+
resolved = self._safe_resolve(relative)
|
|
83
|
+
if resolved is None:
|
|
84
|
+
return False
|
|
85
|
+
os.remove(resolved)
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def clear_file(self, relative: str) -> bool:
|
|
89
|
+
"""Truncate a log file to zero bytes. Returns *True* on success."""
|
|
90
|
+
resolved = self._safe_resolve(relative)
|
|
91
|
+
if resolved is None:
|
|
92
|
+
return False
|
|
93
|
+
open(resolved, "w", encoding="utf-8").close()
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class LogReader:
|
|
98
|
+
"""Read and filter log entries from a file inside a :class:`LogDirectory`.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
log_dir:
|
|
103
|
+
A :class:`LogDirectory` instance.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
_LEVEL_KEYWORDS = frozenset({"INFO", "WARNING", "ERROR", "DEBUG", "CRITICAL"})
|
|
107
|
+
|
|
108
|
+
def __init__(self, log_dir: LogDirectory) -> None:
|
|
109
|
+
self.log_dir = log_dir
|
|
110
|
+
|
|
111
|
+
def read(
|
|
112
|
+
self,
|
|
113
|
+
file: str,
|
|
114
|
+
*,
|
|
115
|
+
lines: int = 500,
|
|
116
|
+
level: str = "",
|
|
117
|
+
search: str = "",
|
|
118
|
+
) -> dict:
|
|
119
|
+
"""Return filtered log entries as a dict.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
dict
|
|
124
|
+
``{"lines": [...], "total": int}`` on success, or
|
|
125
|
+
``{"lines": [...], "total": 0, "error": "..."}`` on failure.
|
|
126
|
+
"""
|
|
127
|
+
resolved = self.log_dir._safe_resolve(file)
|
|
128
|
+
if resolved is None:
|
|
129
|
+
return {"lines": [], "total": 0, "error": "Invalid or missing file"}
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
with open(resolved, "r", encoding="utf-8") as fh:
|
|
133
|
+
raw_lines = fh.readlines()
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
return {"lines": [f"Error reading log file: {exc}"], "total": 0}
|
|
136
|
+
|
|
137
|
+
# Group multi-line entries
|
|
138
|
+
entries: list[str] = []
|
|
139
|
+
for line in raw_lines:
|
|
140
|
+
stripped = line.rstrip()
|
|
141
|
+
if stripped and (
|
|
142
|
+
stripped[0].isdigit()
|
|
143
|
+
or stripped.split()[0] in self._LEVEL_KEYWORDS
|
|
144
|
+
):
|
|
145
|
+
entries.append(stripped)
|
|
146
|
+
elif entries:
|
|
147
|
+
entries[-1] += "\n" + stripped
|
|
148
|
+
else:
|
|
149
|
+
entries.append(stripped)
|
|
150
|
+
|
|
151
|
+
# Level filter
|
|
152
|
+
if level:
|
|
153
|
+
upper = level.upper()
|
|
154
|
+
entries = [e for e in entries if upper in e]
|
|
155
|
+
|
|
156
|
+
# Text search
|
|
157
|
+
if search:
|
|
158
|
+
lower = search.lower()
|
|
159
|
+
entries = [e for e in entries if lower in e.lower()]
|
|
160
|
+
|
|
161
|
+
total = len(entries)
|
|
162
|
+
if lines > 0:
|
|
163
|
+
entries = entries[-lines:]
|
|
164
|
+
|
|
165
|
+
return {"lines": entries, "total": total}
|