fix: config modal scrollbar, scheduler-config.js, logging API endpoint, static cache-busting

This commit is contained in:
2026-02-22 10:01:52 +01:00
parent 0265ae2a70
commit eed75ff08b
14 changed files with 614 additions and 62 deletions

230
src/server/api/logging.py Normal file
View 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}

View File

@@ -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.config import router as config_router
from src.server.api.download import router as download_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.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.nfo import router as nfo_router
from src.server.api.scheduler import router as scheduler_router from src.server.api.scheduler import router as scheduler_router
from src.server.api.websocket import router as websocket_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(anime_router)
app.include_router(download_router) app.include_router(download_router)
app.include_router(nfo_router) app.include_router(nfo_router)
app.include_router(logging_router)
app.include_router(websocket_router) app.include_router(websocket_router)
# Register exception handlers # Register exception handlers

View File

@@ -14,6 +14,7 @@ All template helpers that handle series data use `key` for identification and
provide `folder` as display metadata only. provide `folder` as display metadata only.
""" """
import logging import logging
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -26,6 +27,9 @@ logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates" TEMPLATES_DIR = Path(__file__).parent.parent / "web" / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) 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( def get_base_context(
request: Request, title: str = "Aniworld" request: Request, title: str = "Aniworld"
@@ -44,7 +48,8 @@ def get_base_context(
"request": request, "request": request,
"title": title, "title": title,
"app_name": "Aniworld Download Manager", "app_name": "Aniworld Download Manager",
"version": "1.0.0" "version": "1.0.0",
"static_v": STATIC_VERSION,
} }

View File

@@ -35,13 +35,17 @@
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
max-height: 80vh; 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 { .modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-shrink: 0;
padding: var(--spacing-lg); padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
@@ -55,6 +59,8 @@
.modal-body { .modal-body {
padding: var(--spacing-lg); padding: var(--spacing-lg);
overflow-y: auto; overflow-y: auto;
flex: 1;
min-height: 0;
} }
/* Config Section within modals */ /* Config Section within modals */

View File

@@ -24,21 +24,37 @@ AniWorld.SchedulerConfig = (function() {
if (data.success) { if (data.success) {
const config = data.config; const config = data.config;
const runtimeStatus = data.status || {};
// Update UI elements // Update UI elements
document.getElementById('scheduled-rescan-enabled').checked = config.enabled; document.getElementById('scheduled-rescan-enabled').checked = config.enabled;
document.getElementById('scheduled-rescan-time').value = config.time || '03:00'; document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
document.getElementById('auto-download-after-rescan').checked = config.auto_download_after_rescan;
// Update status display const autoDownload = document.getElementById('auto-download-after-rescan');
document.getElementById('next-rescan-time').textContent = if (autoDownload) {
config.next_run ? new Date(config.next_run).toLocaleString() : 'Not scheduled'; autoDownload.checked = config.auto_download_after_rescan || false;
document.getElementById('last-rescan-time').textContent = }
config.last_run ? new Date(config.last_run).toLocaleString() : 'Never';
// 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'); const statusBadge = document.getElementById('scheduler-running-status');
statusBadge.textContent = config.is_running ? 'Running' : 'Stopped'; if (statusBadge) {
statusBadge.className = 'info-value status-badge ' + (config.is_running ? 'running' : 'stopped'); 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 // Enable/disable time input based on checkbox
toggleTimeInput(); toggleTimeInput();
@@ -55,25 +71,45 @@ AniWorld.SchedulerConfig = (function() {
async function save() { async function save() {
try { try {
const enabled = document.getElementById('scheduled-rescan-enabled').checked; 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 // Collect checked day checkboxes
const configResponse = await AniWorld.ApiClient.get(AniWorld.Constants.API.CONFIG); const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun'].filter(function(day) {
if (!configResponse) return; const cb = document.getElementById('scheduler-day-' + day);
const config = await configResponse.json(); return cb && cb.checked;
});
// Update scheduler settings const autoDownloadEl = document.getElementById('auto-download-after-rescan');
config.scheduler = { 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, enabled: enabled,
schedule_time: scheduleTime,
schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload,
interval_minutes: interval interval_minutes: interval
}; };
// Save updated config const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);
const response = await AniWorld.ApiClient.put(AniWorld.Constants.API.CONFIG, config);
if (!response) return; if (!response) return;
AniWorld.UI.showToast('Scheduler configuration saved successfully', 'success'); const result = await response.json();
await load(); 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) { } catch (error) {
console.error('Error saving scheduler config:', error); console.error('Error saving scheduler config:', error);
AniWorld.UI.showToast('Failed to save scheduler configuration', 'error'); AniWorld.UI.showToast('Failed to save scheduler configuration', 'error');

View File

@@ -40,6 +40,7 @@ AniWorld.Constants = (function() {
QUEUE_PENDING: '/api/queue/pending', QUEUE_PENDING: '/api/queue/pending',
// Config endpoints // Config endpoints
CONFIG: '/api/config',
CONFIG_DIRECTORY: '/api/config/directory', CONFIG_DIRECTORY: '/api/config/directory',
CONFIG_SECTION: '/api/config/section', // + /{section} CONFIG_SECTION: '/api/config/section', // + /{section}
CONFIG_BACKUP: '/api/config/backup', CONFIG_BACKUP: '/api/config/backup',

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - Aniworld</title> <title>Error - Aniworld</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/styles.css" rel="stylesheet"> <link href="/static/css/styles.css?v={{ static_v }}" rel="stylesheet">
</head> </head>
<body> <body>
<div class="container mt-5"> <div class="container mt-5">

View File

@@ -5,11 +5,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager</title> <title>AniWorld Manager</title>
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- UX Enhancement and Mobile & Accessibility CSS --> <!-- UX Enhancement and Mobile & Accessibility CSS -->
<link rel="stylesheet" href="/static/css/ux_features.css"> <link rel="stylesheet" href="/static/css/ux_features.css?v={{ static_v }}">
</head> </head>
<body> <body>
@@ -649,32 +649,32 @@
</div> </div>
<!-- Shared Modules (load in dependency order) --> <!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js"></script> <script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
<script src="/static/js/shared/auth.js"></script> <script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
<script src="/static/js/shared/api-client.js"></script> <script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
<script src="/static/js/shared/theme.js"></script> <script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
<script src="/static/js/shared/ui-utils.js"></script> <script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
<script src="/static/js/shared/websocket-client.js"></script> <script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
<!-- External modules --> <!-- External modules -->
<script src="/static/js/localization.js"></script> <script src="/static/js/localization.js?v={{ static_v }}"></script>
<script src="/static/js/user_preferences.js"></script> <script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
<!-- Index Page Modules --> <!-- Index Page Modules -->
<script src="/static/js/index/series-manager.js"></script> <script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/selection-manager.js"></script> <script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/search.js"></script> <script src="/static/js/index/search.js?v={{ static_v }}"></script>
<script src="/static/js/index/scan-manager.js"></script> <script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/nfo-manager.js"></script> <script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script>
<!-- Config Sub-Modules (must load before config-manager.js) --> <!-- Config Sub-Modules (must load before config-manager.js) -->
<script src="/static/js/index/scheduler-config.js"></script> <script src="/static/js/index/scheduler-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/logging-config.js"></script> <script src="/static/js/index/logging-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/advanced-config.js"></script> <script src="/static/js/index/advanced-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/main-config.js"></script> <script src="/static/js/index/main-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/nfo-config.js"></script> <script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/config-manager.js"></script> <script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/socket-handler.js"></script> <script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/index/app-init.js"></script> <script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
</body> </body>
</html> </html>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Initializing</title> <title>AniWorld Manager - Initializing</title>
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.loading-container { .loading-container {

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Login</title> <title>AniWorld Manager - Login</title>
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.login-container { .login-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Download Queue - AniWorld Manager</title> <title>Download Queue - AniWorld Manager</title>
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head> </head>
@@ -234,19 +234,19 @@
</div> </div>
<!-- Shared Modules (load in dependency order) --> <!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js"></script> <script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
<script src="/static/js/shared/auth.js"></script> <script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
<script src="/static/js/shared/api-client.js"></script> <script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
<script src="/static/js/shared/theme.js"></script> <script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
<script src="/static/js/shared/ui-utils.js"></script> <script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
<script src="/static/js/shared/websocket-client.js"></script> <script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
<!-- Queue Page Modules --> <!-- Queue Page Modules -->
<script src="/static/js/queue/queue-api.js"></script> <script src="/static/js/queue/queue-api.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-renderer.js"></script> <script src="/static/js/queue/queue-renderer.js?v={{ static_v }}"></script>
<script src="/static/js/queue/progress-handler.js"></script> <script src="/static/js/queue/progress-handler.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-socket-handler.js"></script> <script src="/static/js/queue/queue-socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-init.js"></script> <script src="/static/js/queue/queue-init.js?v={{ static_v }}"></script>
</body> </body>
</html> </html>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Setup</title> <title>AniWorld Manager - Setup</title>
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.setup-container { .setup-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AniWorld Manager - Setup</title> <title>AniWorld Manager - Setup</title>
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css?v={{ static_v }}">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style> <style>
.setup-container { .setup-container {

View File

@@ -0,0 +1,272 @@
"""Tests for /api/logging endpoints.
Covers:
- GET /api/logging/config — returns correct shape with log_level and flags
- GET /api/logging/files — lists log files
- POST /api/logging/test — test log trigger
- POST /api/logging/cleanup — file cleanup
- GET /api/logging/files/{name}/tail — tail endpoint
These tests guard against regressions of the 404 that previously caused the
config modal to fail on load.
"""
from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from src.server.fastapi_app import app
from src.server.models.config import AppConfig, LoggingConfig
from src.server.services.auth_service import auth_service
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_auth_state():
"""Reset auth service state before each test."""
auth_service._hash = None
auth_service._failed.clear()
if not auth_service.is_configured():
auth_service.setup_master_password("TestPass123!")
yield
auth_service._hash = None
auth_service._failed.clear()
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def auth_client(client):
"""Authenticated async client."""
response = await client.post("/api/auth/login", json={"password": "TestPass123!"})
assert response.status_code == 200, response.text
token = response.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_config_service():
"""Return a mock config service with minimal logging config."""
service = Mock()
app_cfg = Mock(spec=AppConfig)
app_cfg.logging = LoggingConfig(
level="INFO",
file=None,
max_bytes=None,
backup_count=3,
)
service.load_config = Mock(return_value=app_cfg)
return service
# ---------------------------------------------------------------------------
# GET /api/logging/config
# ---------------------------------------------------------------------------
class TestGetLoggingConfig:
"""GET /api/logging/config returns the expected shape."""
@pytest.mark.asyncio
async def test_returns_success_shape(self, auth_client, mock_config_service):
with patch(
"src.server.api.logging.get_config_service",
return_value=mock_config_service,
):
response = await auth_client.get("/api/logging/config")
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert "config" in body
@pytest.mark.asyncio
async def test_config_contains_log_level(self, auth_client, mock_config_service):
with patch(
"src.server.api.logging.get_config_service",
return_value=mock_config_service,
):
response = await auth_client.get("/api/logging/config")
config = response.json()["config"]
assert config["log_level"] == "INFO"
@pytest.mark.asyncio
async def test_config_contains_boolean_flags(self, auth_client, mock_config_service):
with patch(
"src.server.api.logging.get_config_service",
return_value=mock_config_service,
):
response = await auth_client.get("/api/logging/config")
config = response.json()["config"]
assert "enable_console_logging" in config
assert "enable_fail2ban_logging" in config
assert isinstance(config["enable_console_logging"], bool)
@pytest.mark.asyncio
async def test_requires_authentication(self, client):
response = await client.get("/api/logging/config")
assert response.status_code in (401, 403)
# ---------------------------------------------------------------------------
# GET /api/logging/files
# ---------------------------------------------------------------------------
class TestListLogFiles:
"""GET /api/logging/files lists available log files."""
@pytest.mark.asyncio
async def test_returns_success_and_files_list(self, auth_client, tmp_path, monkeypatch):
# Create a fake log file
(tmp_path / "server.log").write_text("line1\nline2\n")
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.get("/api/logging/files")
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert isinstance(body["files"], list)
@pytest.mark.asyncio
async def test_file_metadata_shape(self, auth_client, tmp_path, monkeypatch):
(tmp_path / "app.log").write_text("test content")
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.get("/api/logging/files")
files = response.json()["files"]
assert len(files) >= 1
f = files[0]
assert "name" in f
assert "size_mb" in f
assert "modified" in f
@pytest.mark.asyncio
async def test_empty_log_dir(self, auth_client, tmp_path, monkeypatch):
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.get("/api/logging/files")
assert response.status_code == 200
assert response.json()["files"] == []
# ---------------------------------------------------------------------------
# GET /api/logging/files/{filename}/tail
# ---------------------------------------------------------------------------
class TestTailLogFile:
"""GET /api/logging/files/{filename}/tail returns log lines."""
@pytest.mark.asyncio
async def test_returns_last_n_lines(self, auth_client, tmp_path, monkeypatch):
log_content = "\n".join(f"line {i}" for i in range(200))
(tmp_path / "server.log").write_text(log_content)
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.get("/api/logging/files/server.log/tail?lines=50")
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert len(body["lines"]) == 50
assert body["showing_lines"] == 50
assert body["total_lines"] == 200
@pytest.mark.asyncio
async def test_returns_404_for_missing_file(self, auth_client, tmp_path, monkeypatch):
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.get("/api/logging/files/nonexistent.log/tail")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_prevents_path_traversal(self, auth_client, tmp_path, monkeypatch):
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.get(
"/api/logging/files/..%2F..%2Fetc%2Fpasswd/tail"
)
# Either 404 (file not found) or 400 is acceptable — must NOT return 200
assert response.status_code != 200
# ---------------------------------------------------------------------------
# POST /api/logging/test
# ---------------------------------------------------------------------------
class TestTestLogging:
"""POST /api/logging/test writes test log messages."""
@pytest.mark.asyncio
async def test_returns_success(self, auth_client):
response = await auth_client.post("/api/logging/test")
assert response.status_code == 200
assert response.json()["success"] is True
@pytest.mark.asyncio
async def test_requires_authentication(self, client):
response = await client.post("/api/logging/test")
assert response.status_code in (401, 403)
# ---------------------------------------------------------------------------
# POST /api/logging/cleanup
# ---------------------------------------------------------------------------
class TestCleanupLogs:
"""POST /api/logging/cleanup removes old log files."""
@pytest.mark.asyncio
async def test_removes_old_files(self, auth_client, tmp_path, monkeypatch):
old_file = tmp_path / "old.log"
old_file.write_text("old content")
# Make it look very old
old_ts = time.time() - (40 * 86400)
import os
os.utime(old_file, (old_ts, old_ts))
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.post("/api/logging/cleanup", json={"days": 30})
assert response.status_code == 200
body = response.json()
assert body["success"] is True
assert "old.log" in body["removed"]
assert not old_file.exists()
@pytest.mark.asyncio
async def test_keeps_recent_files(self, auth_client, tmp_path, monkeypatch):
new_file = tmp_path / "new.log"
new_file.write_text("recent content")
monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path)
response = await auth_client.post("/api/logging/cleanup", json={"days": 30})
assert response.status_code == 200
assert "new.log" not in response.json()["removed"]
assert new_file.exists()
@pytest.mark.asyncio
async def test_invalid_days_returns_400(self, auth_client):
response = await auth_client.post("/api/logging/cleanup", json={"days": 0})
assert response.status_code == 400