From 4389a20e90b8fdac573c45530606a46f84149cef Mon Sep 17 00:00:00 2001 From: Ettore <=> Date: Fri, 15 May 2026 14:04:28 +0200 Subject: [PATCH] Add log tab in admin panel --- src/main.py | 6 +++ src/routers/logs.py | 81 ++++++++++++++++++++++++++++++++++++ src/static/admin.html | 49 ++++++++++++++++++++++ src/static/admin.js | 96 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 src/routers/logs.py diff --git a/src/main.py b/src/main.py index 5c0cb61..bf1be9d 100644 --- a/src/main.py +++ b/src/main.py @@ -37,6 +37,10 @@ if LOG_FILE: ) _file_handler.setFormatter(_log_fmt) logging.getLogger().addHandler(_file_handler) + # Uvicorn sets propagate=False on its loggers by default; override so + # records bubble up to the root logger and reach our file handler. + for _uvicorn_logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"): + logging.getLogger(_uvicorn_logger_name).propagate = True except OSError as _e: logging.getLogger(__name__).warning( "Cannot open log file %r — file logging disabled: %s", LOG_FILE, _e @@ -56,6 +60,7 @@ from routers.credentials import router as credentials_router from routers.admins import router as admins_router from routers.stats import router as stats_router from routers.telegram import router as telegram_router +from routers.logs import router as logs_router # ── Cache-bust hash (SHA-1 of all static file contents) ────────────────────── def _static_hash(static_dir: str) -> str: @@ -112,6 +117,7 @@ app.include_router(credentials_router) app.include_router(admins_router) app.include_router(stats_router) app.include_router(telegram_router) +app.include_router(logs_router) @app.get("/api/site-config", include_in_schema=False) diff --git a/src/routers/logs.py b/src/routers/logs.py new file mode 100644 index 0000000..2860d20 --- /dev/null +++ b/src/routers/logs.py @@ -0,0 +1,81 @@ +import os +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel + +from core.config import LOG_FILE +from core.dependencies import require_admin + +router = APIRouter(prefix="/api/admin/logs", tags=["admin-logs"]) + +_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4} + + +class LogsResponse(BaseModel): + lines: list[str] + log_file: Optional[str] + total_returned: int + + +def _read_tail(path: str, n: int) -> list[str]: + """Return the last *n* lines from *path* using a memory-efficient seek.""" + size = os.path.getsize(path) + if size == 0: + return [] + # Read in chunks from the end + chunk = 1 << 14 # 16 KB + lines: list[bytes] = [] + with open(path, "rb") as fh: + pos = size + while len(lines) <= n and pos > 0: + pos = max(pos - chunk, 0) + fh.seek(pos) + data = fh.read(min(chunk, size - pos)) + lines = data.split(b"\n") + lines + # If we have more lines than needed, trim from the front + if len(lines) > n + 1: + lines = lines[-(n + 1):] + # Drop empty last element caused by trailing newline + result = [ln.decode("utf-8", errors="replace") for ln in lines if ln] + return result[-n:] if len(result) > n else result + + +@router.get("", response_model=LogsResponse) +async def get_logs( + _: dict = Depends(require_admin), + lines: int = Query(200, ge=1, le=2000, description="Number of lines to return (tail)"), + level: Optional[str] = Query(None, description="Minimum log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"), +): + if not LOG_FILE: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Log file not configured (LOG_FILE env variable is not set).", + ) + if not os.path.isfile(LOG_FILE): + return LogsResponse(lines=[], log_file=LOG_FILE, total_returned=0) + + raw = _read_tail(LOG_FILE, lines) + + if level: + level_upper = level.upper() + if level_upper not in _LEVEL_ORDER: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid level '{level}'. Must be one of: {', '.join(_LEVEL_ORDER)}.", + ) + min_order = _LEVEL_ORDER[level_upper] + filtered: list[str] = [] + for line in raw: + for lvl, order in _LEVEL_ORDER.items(): + if lvl in line and order >= min_order: + filtered.append(line) + break + else: + # Lines that don't match any known level keyword are included + # only when filtering at DEBUG (include everything) + if min_order == 0: + filtered.append(line) + raw = filtered + + return LogsResponse(lines=raw, log_file=LOG_FILE, total_returned=len(raw)) diff --git a/src/static/admin.html b/src/static/admin.html index 08e5f5f..0766445 100644 --- a/src/static/admin.html +++ b/src/static/admin.html @@ -137,6 +137,7 @@ + @@ -367,6 +368,54 @@ + +
+
+

Application Logs

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

+        
+
+
+ diff --git a/src/static/admin.js b/src/static/admin.js index 822ab25..b94233d 100644 --- a/src/static/admin.js +++ b/src/static/admin.js @@ -953,6 +953,102 @@ document.getElementById("btn-tg-test").addEventListener("click", async () => { } }); +// ── Logs ────────────────────────────────────────────────────────────────────── +let _logsAutoRefreshTimer = null; + +const _LEVEL_COLOR = { + DEBUG: "color:var(--text-muted)", + INFO: "color:var(--text)", + WARNING: "color:#d97706", + ERROR: "color:#ef4444", + CRITICAL: "color:#ef4444;font-weight:700", +}; + +function _colorLogLine(line) { + const escaped = esc(line); + for (const [lvl, style] of Object.entries(_LEVEL_COLOR)) { + if (line.includes(lvl)) { + return `${escaped}`; + } + } + return escaped; +} + +async function loadLogs() { + const level = document.getElementById("logs-level").value; + const lines = parseInt(document.getElementById("logs-lines").value, 10) || 200; + const search = document.getElementById("logs-search").value.trim().toLowerCase(); + const pre = document.getElementById("logs-output"); + const noFile = document.getElementById("logs-no-file"); + const meta = document.getElementById("logs-meta"); + + const params = new URLSearchParams({ lines }); + if (level) params.set("level", level); + + try { + const data = await api("GET", `/api/admin/logs?${params}`); + noFile.classList.add("hidden"); + pre.parentElement.style.display = ""; + + let rows = data.lines; + if (search) { + rows = rows.filter(l => l.toLowerCase().includes(search)); + } + + if (!rows.length) { + pre.innerHTML = 'No log entries match the current filters.'; + } else { + pre.innerHTML = rows.map(_colorLogLine).join("\n"); + // Scroll to bottom so the latest entries are visible + pre.scrollTop = pre.scrollHeight; + } + + const ts = new Date().toLocaleTimeString(); + meta.textContent = `${rows.length} line${rows.length !== 1 ? "s" : ""} shown${data.log_file ? ` · ${data.log_file}` : ""} · last refreshed ${ts}`; + } catch (err) { + if (err.message && err.message.includes("not configured")) { + noFile.classList.remove("hidden"); + pre.parentElement.style.display = "none"; + meta.textContent = ""; + } else { + showToast(err.message, true); + } + } +} + +function _startLogsAutoRefresh() { + _stopLogsAutoRefresh(); + _logsAutoRefreshTimer = setInterval(loadLogs, 5000); +} + +function _stopLogsAutoRefresh() { + if (_logsAutoRefreshTimer !== null) { + clearInterval(_logsAutoRefreshTimer); + _logsAutoRefreshTimer = null; + } +} + +document.getElementById("btn-refresh-logs").addEventListener("click", loadLogs); + +document.getElementById("logs-autorefresh").addEventListener("change", e => { + if (e.target.checked) _startLogsAutoRefresh(); + else _stopLogsAutoRefresh(); +}); + +// Stop auto-refresh when leaving the logs tab; restart when entering it +document.querySelectorAll(".tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + if (btn.dataset.tab === "logs") { + if (document.getElementById("logs-autorefresh").checked) { + _startLogsAutoRefresh(); + } + loadLogs(); + } else { + _stopLogsAutoRefresh(); + } + }); +}); + // ── Load all data ───────────────────────────────────────────────────────────── function loadAllData() { const isAdmin = _tokenPayload().scope === "admin";