Add log tab in admin panel

This commit is contained in:
Ettore
2026-05-15 14:04:28 +02:00
parent 8f72d692e2
commit 4389a20e90
4 changed files with 232 additions and 0 deletions

View File

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

81
src/routers/logs.py Normal file
View File

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

View File

@@ -137,6 +137,7 @@
<button class="tab-btn active" data-tab="keypasses">Keypasses</button>
<button class="tab-btn" data-tab="gates">Gates</button>
<button class="tab-btn" data-tab="stats">Statistics</button>
<button class="tab-btn admin-only" data-tab="logs">Logs</button>
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
<button class="tab-btn admin-only" data-tab="credentials">API Credentials</button>
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
@@ -367,6 +368,54 @@
</div>
</div>
<!-- ── Logs pane ──────────────────────────────────────────────────── -->
<div id="tab-logs" class="tab-pane">
<div class="section-header">
<h3>Application Logs</h3>
<div style="display:flex;gap:.5rem;align-items:center">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.85rem;color:var(--text-muted);cursor:pointer">
<input type="checkbox" id="logs-autorefresh" style="width:1rem;height:1rem;cursor:pointer" />
Auto-refresh
</label>
<button id="btn-refresh-logs" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button>
</div>
</div>
<!-- Filter bar -->
<div style="display:flex;flex-wrap:wrap;gap:.75rem;margin-bottom:1rem;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:.3rem">
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Min level</label>
<select id="logs-level" style="width:130px">
<option value="">All</option>
<option value="DEBUG">DEBUG</option>
<option value="INFO" selected>INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Lines (tail)</label>
<input id="logs-lines" type="number" value="200" min="10" max="2000"
style="width:90px" />
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Filter text</label>
<input id="logs-search" type="text" placeholder="e.g. ERROR or keypass…"
style="width:200px" autocomplete="off" />
</div>
</div>
<div id="logs-no-file" class="hidden" style="color:var(--text-muted);font-size:.9rem;padding:1rem 0">
Log file not configured — set the <code>LOG_FILE</code> environment variable to enable file logging.
</div>
<div class="card" style="padding:0;overflow:hidden">
<pre id="logs-output"
style="margin:0;padding:1rem;font-size:.78rem;line-height:1.55;overflow-x:auto;overflow-y:auto;max-height:60vh;white-space:pre-wrap;word-break:break-all;background:var(--surface);color:var(--text)"></pre>
</div>
<div style="margin-top:.5rem;font-size:.8rem;color:var(--text-muted)" id="logs-meta"></div>
</div>
</div><!-- /.tab-content -->
</div><!-- /#admin-view -->

View File

@@ -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 `<span style="${style}">${escaped}</span>`;
}
}
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 = '<span style="color:var(--text-muted)">No log entries match the current filters.</span>';
} 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";