fix: config modal scrollbar, scheduler-config.js, logging API endpoint, static cache-busting
This commit is contained in:
230
src/server/api/logging.py
Normal file
230
src/server/api/logging.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user