231 lines
7.2 KiB
Python
231 lines
7.2 KiB
Python
"""Logging API endpoints for AniWorld.
|
||
|
||
Provides endpoints for reading log configuration, listing log files,
|
||
tailing/downloading individual log files, testing logging, and cleanup.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from fastapi.responses import FileResponse
|
||
|
||
from src.server.services.config_service import get_config_service
|
||
from src.server.utils.dependencies import require_auth
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/logging", tags=["logging"])
|
||
|
||
_LOG_DIR = Path("logs")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _log_dir() -> Path:
|
||
"""Return the log directory, creating it if necessary."""
|
||
_LOG_DIR.mkdir(exist_ok=True)
|
||
return _LOG_DIR
|
||
|
||
|
||
def _list_log_files() -> List[Dict[str, Any]]:
|
||
"""Return metadata for all .log files in the log directory."""
|
||
result: List[Dict[str, Any]] = []
|
||
log_dir = _log_dir()
|
||
for entry in sorted(log_dir.iterdir()):
|
||
if entry.is_file() and entry.suffix in {".log", ".txt"}:
|
||
stat = entry.stat()
|
||
result.append(
|
||
{
|
||
"name": entry.name,
|
||
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||
"modified": stat.st_mtime,
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get("/config")
|
||
def get_logging_config(
|
||
auth: Optional[dict] = Depends(require_auth),
|
||
) -> Dict[str, Any]:
|
||
"""Return current logging configuration as used by the frontend.
|
||
|
||
Maps the internal ``LoggingConfig`` model fields to the shape expected
|
||
by ``logging-config.js``.
|
||
"""
|
||
try:
|
||
config_service = get_config_service()
|
||
app_config = config_service.load_config()
|
||
lc = app_config.logging
|
||
|
||
return {
|
||
"success": True,
|
||
"config": {
|
||
# Primary fields (match the model)
|
||
"log_level": lc.level,
|
||
"log_file": lc.file,
|
||
"max_bytes": lc.max_bytes,
|
||
"backup_count": lc.backup_count,
|
||
# UI-only flags – defaults; not yet persisted in the model
|
||
"enable_console_logging": True,
|
||
"enable_console_progress": False,
|
||
"enable_fail2ban_logging": False,
|
||
},
|
||
}
|
||
except Exception as exc:
|
||
logger.exception("Failed to read logging config")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"Failed to read logging config: {exc}",
|
||
) from exc
|
||
|
||
|
||
@router.get("/files")
|
||
def list_files(
|
||
auth: Optional[dict] = Depends(require_auth),
|
||
) -> Dict[str, Any]:
|
||
"""List all available log files with metadata."""
|
||
try:
|
||
return {"success": True, "files": _list_log_files()}
|
||
except Exception as exc:
|
||
logger.exception("Failed to list log files")
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"Failed to list log files: {exc}",
|
||
) from exc
|
||
|
||
|
||
@router.get("/files/{filename}/tail")
|
||
def tail_file(
|
||
filename: str,
|
||
lines: int = 100,
|
||
auth: Optional[dict] = Depends(require_auth),
|
||
) -> Dict[str, Any]:
|
||
"""Return the last *lines* lines of a log file.
|
||
|
||
Args:
|
||
filename: Name of the log file (no path traversal).
|
||
lines: Number of lines to return (default 100).
|
||
|
||
Returns:
|
||
Dict with ``success``, ``lines``, ``showing_lines``, ``total_lines``.
|
||
"""
|
||
# Prevent path traversal
|
||
safe_name = Path(filename).name
|
||
file_path = _log_dir() / safe_name
|
||
if not file_path.exists():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"Log file not found: {safe_name}",
|
||
)
|
||
|
||
try:
|
||
all_lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||
tail = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
||
return {
|
||
"success": True,
|
||
"lines": tail,
|
||
"showing_lines": len(tail),
|
||
"total_lines": len(all_lines),
|
||
}
|
||
except Exception as exc:
|
||
logger.exception("Failed to tail log file %s", safe_name)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail=f"Failed to read log file: {exc}",
|
||
) from exc
|
||
|
||
|
||
@router.get("/files/{filename}/download")
|
||
def download_file(
|
||
filename: str,
|
||
auth: Optional[dict] = Depends(require_auth),
|
||
) -> FileResponse:
|
||
"""Download a log file as an attachment."""
|
||
safe_name = Path(filename).name
|
||
file_path = _log_dir() / safe_name
|
||
if not file_path.exists():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"Log file not found: {safe_name}",
|
||
)
|
||
return FileResponse(
|
||
path=str(file_path),
|
||
filename=safe_name,
|
||
media_type="text/plain",
|
||
)
|
||
|
||
|
||
@router.post("/test")
|
||
def test_logging(
|
||
auth: dict = Depends(require_auth),
|
||
) -> Dict[str, Any]:
|
||
"""Write test log messages at all levels."""
|
||
logging.getLogger("aniworld.test").debug("Test DEBUG message")
|
||
logging.getLogger("aniworld.test").info("Test INFO message")
|
||
logging.getLogger("aniworld.test").warning("Test WARNING message")
|
||
logging.getLogger("aniworld.test").error("Test ERROR message")
|
||
return {"success": True, "message": "Test messages written to log"}
|
||
|
||
|
||
@router.post("/cleanup")
|
||
def cleanup_logs(
|
||
payload: Dict[str, Any],
|
||
auth: dict = Depends(require_auth),
|
||
) -> Dict[str, Any]:
|
||
"""Delete log files older than *days* days.
|
||
|
||
Args:
|
||
payload: JSON body with ``days`` (int) field.
|
||
|
||
Returns:
|
||
Dict with ``success`` and ``message`` describing what was deleted.
|
||
"""
|
||
import time
|
||
|
||
days = payload.get("days", 30)
|
||
try:
|
||
days = int(days)
|
||
if days < 1:
|
||
raise ValueError("days must be >= 1")
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Invalid days value: {exc}",
|
||
) from exc
|
||
|
||
cutoff = time.time() - days * 86400
|
||
removed: List[str] = []
|
||
errors: List[str] = []
|
||
|
||
for entry in _log_dir().iterdir():
|
||
if entry.is_file() and entry.suffix in {".log", ".txt"}:
|
||
if entry.stat().st_mtime < cutoff:
|
||
try:
|
||
entry.unlink()
|
||
removed.append(entry.name)
|
||
except OSError as exc:
|
||
errors.append(f"{entry.name}: {exc}")
|
||
|
||
message = f"Removed {len(removed)} file(s) older than {days} days."
|
||
if errors:
|
||
message += f" Errors: {'; '.join(errors)}"
|
||
|
||
logger.info(
|
||
"Log cleanup by %s: removed=%s days=%s",
|
||
auth.get("username", "unknown"),
|
||
removed,
|
||
days,
|
||
)
|
||
return {"success": True, "message": message, "removed": removed}
|