diff --git a/src/server/api/logging.py b/src/server/api/logging.py new file mode 100644 index 0000000..9cc65e2 --- /dev/null +++ b/src/server/api/logging.py @@ -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} diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py index ac17e13..2422051 100644 --- a/src/server/fastapi_app.py +++ b/src/server/fastapi_app.py @@ -23,6 +23,7 @@ from src.server.api.auth import router as auth_router from src.server.api.config import router as config_router from src.server.api.download import router as download_router from src.server.api.health import router as health_router +from src.server.api.logging import router as logging_router from src.server.api.nfo import router as nfo_router from src.server.api.scheduler import router as scheduler_router from src.server.api.websocket import router as websocket_router @@ -476,6 +477,7 @@ app.include_router(scheduler_router) app.include_router(anime_router) app.include_router(download_router) app.include_router(nfo_router) +app.include_router(logging_router) app.include_router(websocket_router) # Register exception handlers diff --git a/src/server/utils/template_helpers.py b/src/server/utils/template_helpers.py index 3ecca40..8d0aa78 100644 --- a/src/server/utils/template_helpers.py +++ b/src/server/utils/template_helpers.py @@ -14,6 +14,7 @@ All template helpers that handle series data use `key` for identification and provide `folder` as display metadata only. """ import logging +import time from pathlib import Path from typing import Any, Dict, List, Optional @@ -26,6 +27,9 @@ logger = logging.getLogger(__name__) TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) +# Version token for static asset cache-busting; changes on every server start. +STATIC_VERSION: str = str(int(time.time())) + def get_base_context( request: Request, title: str = "Aniworld" @@ -44,7 +48,8 @@ def get_base_context( "request": request, "title": title, "app_name": "Aniworld Download Manager", - "version": "1.0.0" + "version": "1.0.0", + "static_v": STATIC_VERSION, } diff --git a/src/server/web/static/css/components/modals.css b/src/server/web/static/css/components/modals.css index 0f678ad..ee681ad 100644 --- a/src/server/web/static/css/components/modals.css +++ b/src/server/web/static/css/components/modals.css @@ -35,13 +35,17 @@ max-width: 500px; width: 90%; max-height: 80vh; - overflow: hidden; + /* overflow:hidden removed — it was clipping the modal-body scrollbar. + Border-radius clips backgrounds correctly without it on modern browsers. */ + display: flex; + flex-direction: column; } .modal-header { display: flex; justify-content: space-between; align-items: center; + flex-shrink: 0; padding: var(--spacing-lg); border-bottom: 1px solid var(--color-border); } @@ -55,6 +59,8 @@ .modal-body { padding: var(--spacing-lg); overflow-y: auto; + flex: 1; + min-height: 0; } /* Config Section within modals */ diff --git a/src/server/web/static/js/index/scheduler-config.js b/src/server/web/static/js/index/scheduler-config.js index 3ec3e1d..acf6119 100644 --- a/src/server/web/static/js/index/scheduler-config.js +++ b/src/server/web/static/js/index/scheduler-config.js @@ -24,21 +24,37 @@ AniWorld.SchedulerConfig = (function() { if (data.success) { const config = data.config; + const runtimeStatus = data.status || {}; // Update UI elements document.getElementById('scheduled-rescan-enabled').checked = config.enabled; - document.getElementById('scheduled-rescan-time').value = config.time || '03:00'; - document.getElementById('auto-download-after-rescan').checked = config.auto_download_after_rescan; + document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00'; - // Update status display - document.getElementById('next-rescan-time').textContent = - config.next_run ? new Date(config.next_run).toLocaleString() : 'Not scheduled'; - document.getElementById('last-rescan-time').textContent = - config.last_run ? new Date(config.last_run).toLocaleString() : 'Never'; + const autoDownload = document.getElementById('auto-download-after-rescan'); + if (autoDownload) { + autoDownload.checked = config.auto_download_after_rescan || false; + } + + // Update schedule day checkboxes + const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun']; + ['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) { + const cb = document.getElementById('scheduler-day-' + day); + if (cb) cb.checked = days.indexOf(day) !== -1; + }); + + // Update status display (runtime fields come from data.status) + const nextRunEl = document.getElementById('scheduler-next-run'); + if (nextRunEl) { + nextRunEl.textContent = runtimeStatus.next_run + ? new Date(runtimeStatus.next_run).toLocaleString() + : 'Not scheduled'; + } const statusBadge = document.getElementById('scheduler-running-status'); - statusBadge.textContent = config.is_running ? 'Running' : 'Stopped'; - statusBadge.className = 'info-value status-badge ' + (config.is_running ? 'running' : 'stopped'); + if (statusBadge) { + statusBadge.textContent = runtimeStatus.is_running ? 'Running' : 'Stopped'; + statusBadge.className = 'info-value status-badge ' + (runtimeStatus.is_running ? 'running' : 'stopped'); + } // Enable/disable time input based on checkbox toggleTimeInput(); @@ -55,25 +71,45 @@ AniWorld.SchedulerConfig = (function() { async function save() { try { const enabled = document.getElementById('scheduled-rescan-enabled').checked; - const interval = parseInt(document.getElementById('scheduled-rescan-interval').value) || 60; + const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00'; - // Get current config - const configResponse = await AniWorld.ApiClient.get(AniWorld.Constants.API.CONFIG); - if (!configResponse) return; - const config = await configResponse.json(); + // Collect checked day checkboxes + const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun'].filter(function(day) { + const cb = document.getElementById('scheduler-day-' + day); + return cb && cb.checked; + }); - // Update scheduler settings - config.scheduler = { + const autoDownloadEl = document.getElementById('auto-download-after-rescan'); + const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false; + + const intervalEl = document.getElementById('scheduled-rescan-interval'); + const interval = intervalEl ? (parseInt(intervalEl.value) || 60) : 60; + + // POST directly to the scheduler config endpoint + const payload = { enabled: enabled, + schedule_time: scheduleTime, + schedule_days: scheduleDays, + auto_download_after_rescan: autoDownload, interval_minutes: interval }; - // Save updated config - const response = await AniWorld.ApiClient.put(AniWorld.Constants.API.CONFIG, config); + const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload); if (!response) return; - AniWorld.UI.showToast('Scheduler configuration saved successfully', 'success'); - await load(); + const result = await response.json(); + if (result.success) { + AniWorld.UI.showToast('Scheduler configuration saved successfully', 'success'); + // Update next run display from response + const nextRunEl = document.getElementById('scheduler-next-run'); + if (nextRunEl && result.status) { + nextRunEl.textContent = result.status.next_run + ? new Date(result.status.next_run).toLocaleString() + : 'Not scheduled'; + } + } else { + AniWorld.UI.showToast('Failed to save scheduler configuration', 'error'); + } } catch (error) { console.error('Error saving scheduler config:', error); AniWorld.UI.showToast('Failed to save scheduler configuration', 'error'); diff --git a/src/server/web/static/js/shared/constants.js b/src/server/web/static/js/shared/constants.js index 5fdfc03..1e36ffd 100644 --- a/src/server/web/static/js/shared/constants.js +++ b/src/server/web/static/js/shared/constants.js @@ -40,6 +40,7 @@ AniWorld.Constants = (function() { QUEUE_PENDING: '/api/queue/pending', // Config endpoints + CONFIG: '/api/config', CONFIG_DIRECTORY: '/api/config/directory', CONFIG_SECTION: '/api/config/section', // + /{section} CONFIG_BACKUP: '/api/config/backup', diff --git a/src/server/web/templates/error.html b/src/server/web/templates/error.html index ff3b507..6c92314 100644 --- a/src/server/web/templates/error.html +++ b/src/server/web/templates/error.html @@ -5,7 +5,7 @@ Error - Aniworld - +
diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html index 994ca16..19eace7 100644 --- a/src/server/web/templates/index.html +++ b/src/server/web/templates/index.html @@ -5,11 +5,11 @@ AniWorld Manager - + - + @@ -649,32 +649,32 @@
- - - - - - + + + + + + - - + + - - - - - + + + + + - - - - - - - - + + + + + + + + \ No newline at end of file diff --git a/src/server/web/templates/loading.html b/src/server/web/templates/loading.html index 1721c47..db155fc 100644 --- a/src/server/web/templates/loading.html +++ b/src/server/web/templates/loading.html @@ -5,7 +5,7 @@ AniWorld Manager - Initializing - +