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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
272
tests/api/test_logging_endpoints.py
Normal file
272
tests/api/test_logging_endpoints.py
Normal 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
|
||||||
Reference in New Issue
Block a user