82 lines
2.9 KiB
Python
82 lines
2.9 KiB
Python
import os
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel
|
|
|
|
from core.config import LOG_FILE
|
|
from core.dependencies import require_admin
|
|
|
|
router = APIRouter(prefix="/api/admin/logs", tags=["admin-logs"])
|
|
|
|
_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4}
|
|
|
|
|
|
class LogsResponse(BaseModel):
|
|
lines: list[str]
|
|
log_file: Optional[str]
|
|
total_returned: int
|
|
|
|
|
|
def _read_tail(path: str, n: int) -> list[str]:
|
|
"""Return the last *n* lines from *path* using a memory-efficient seek."""
|
|
size = os.path.getsize(path)
|
|
if size == 0:
|
|
return []
|
|
# Read in chunks from the end
|
|
chunk = 1 << 14 # 16 KB
|
|
lines: list[bytes] = []
|
|
with open(path, "rb") as fh:
|
|
pos = size
|
|
while len(lines) <= n and pos > 0:
|
|
pos = max(pos - chunk, 0)
|
|
fh.seek(pos)
|
|
data = fh.read(min(chunk, size - pos))
|
|
lines = data.split(b"\n") + lines
|
|
# If we have more lines than needed, trim from the front
|
|
if len(lines) > n + 1:
|
|
lines = lines[-(n + 1):]
|
|
# Drop empty last element caused by trailing newline
|
|
result = [ln.decode("utf-8", errors="replace") for ln in lines if ln]
|
|
return result[-n:] if len(result) > n else result
|
|
|
|
|
|
@router.get("", response_model=LogsResponse)
|
|
async def get_logs(
|
|
_: dict = Depends(require_admin),
|
|
lines: int = Query(200, ge=1, le=2000, description="Number of lines to return (tail)"),
|
|
level: Optional[str] = Query(None, description="Minimum log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"),
|
|
):
|
|
if not LOG_FILE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Log file not configured (LOG_FILE env variable is not set).",
|
|
)
|
|
if not os.path.isfile(LOG_FILE):
|
|
return LogsResponse(lines=[], log_file=LOG_FILE, total_returned=0)
|
|
|
|
raw = _read_tail(LOG_FILE, lines)
|
|
|
|
if level:
|
|
level_upper = level.upper()
|
|
if level_upper not in _LEVEL_ORDER:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid level '{level}'. Must be one of: {', '.join(_LEVEL_ORDER)}.",
|
|
)
|
|
min_order = _LEVEL_ORDER[level_upper]
|
|
filtered: list[str] = []
|
|
for line in raw:
|
|
for lvl, order in _LEVEL_ORDER.items():
|
|
if lvl in line and order >= min_order:
|
|
filtered.append(line)
|
|
break
|
|
else:
|
|
# Lines that don't match any known level keyword are included
|
|
# only when filtering at DEBUG (include everything)
|
|
if min_order == 0:
|
|
filtered.append(line)
|
|
raw = filtered
|
|
|
|
return LogsResponse(lines=raw, log_file=LOG_FILE, total_returned=len(raw))
|