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.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

View File

@@ -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,
}

View File

@@ -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 */

View File

@@ -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');

View File

@@ -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',

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - Aniworld</title>
<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>
<body>
<div class="container mt-5">

View File

@@ -5,11 +5,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<!-- 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>
<body>
@@ -649,32 +649,32 @@
</div>
<!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js"></script>
<script src="/static/js/shared/auth.js"></script>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/js/shared/theme.js"></script>
<script src="/static/js/shared/ui-utils.js"></script>
<script src="/static/js/shared/websocket-client.js"></script>
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
<!-- External modules -->
<script src="/static/js/localization.js"></script>
<script src="/static/js/user_preferences.js"></script>
<script src="/static/js/localization.js?v={{ static_v }}"></script>
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
<!-- Index Page Modules -->
<script src="/static/js/index/series-manager.js"></script>
<script src="/static/js/index/selection-manager.js"></script>
<script src="/static/js/index/search.js"></script>
<script src="/static/js/index/scan-manager.js"></script>
<script src="/static/js/index/nfo-manager.js"></script>
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
<script src="/static/js/index/scan-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/nfo-manager.js?v={{ static_v }}"></script>
<!-- Config Sub-Modules (must load before config-manager.js) -->
<script src="/static/js/index/scheduler-config.js"></script>
<script src="/static/js/index/logging-config.js"></script>
<script src="/static/js/index/advanced-config.js"></script>
<script src="/static/js/index/main-config.js"></script>
<script src="/static/js/index/nfo-config.js"></script>
<script src="/static/js/index/config-manager.js"></script>
<script src="/static/js/index/socket-handler.js"></script>
<script src="/static/js/index/app-init.js"></script>
<script src="/static/js/index/scheduler-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/logging-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/advanced-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/main-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/nfo-config.js?v={{ static_v }}"></script>
<script src="/static/js/index/config-manager.js?v={{ static_v }}"></script>
<script src="/static/js/index/socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/index/app-init.js?v={{ static_v }}"></script>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
.loading-container {

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
.login-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
@@ -234,19 +234,19 @@
</div>
<!-- Shared Modules (load in dependency order) -->
<script src="/static/js/shared/constants.js"></script>
<script src="/static/js/shared/auth.js"></script>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/js/shared/theme.js"></script>
<script src="/static/js/shared/ui-utils.js"></script>
<script src="/static/js/shared/websocket-client.js"></script>
<script src="/static/js/shared/constants.js?v={{ static_v }}"></script>
<script src="/static/js/shared/auth.js?v={{ static_v }}"></script>
<script src="/static/js/shared/api-client.js?v={{ static_v }}"></script>
<script src="/static/js/shared/theme.js?v={{ static_v }}"></script>
<script src="/static/js/shared/ui-utils.js?v={{ static_v }}"></script>
<script src="/static/js/shared/websocket-client.js?v={{ static_v }}"></script>
<!-- Queue Page Modules -->
<script src="/static/js/queue/queue-api.js"></script>
<script src="/static/js/queue/queue-renderer.js"></script>
<script src="/static/js/queue/progress-handler.js"></script>
<script src="/static/js/queue/queue-socket-handler.js"></script>
<script src="/static/js/queue/queue-init.js"></script>
<script src="/static/js/queue/queue-api.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-renderer.js?v={{ static_v }}"></script>
<script src="/static/js/queue/progress-handler.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-socket-handler.js?v={{ static_v }}"></script>
<script src="/static/js/queue/queue-init.js?v={{ static_v }}"></script>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
.setup-container {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
.setup-container {