Add log tab in admin panel
This commit is contained in:
@@ -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
81
src/routers/logs.py
Normal 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))
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user