Add log tab in admin panel
This commit is contained in:
@@ -37,6 +37,10 @@ if LOG_FILE:
|
|||||||
)
|
)
|
||||||
_file_handler.setFormatter(_log_fmt)
|
_file_handler.setFormatter(_log_fmt)
|
||||||
logging.getLogger().addHandler(_file_handler)
|
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:
|
except OSError as _e:
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
"Cannot open log file %r — file logging disabled: %s", LOG_FILE, _e
|
"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.admins import router as admins_router
|
||||||
from routers.stats import router as stats_router
|
from routers.stats import router as stats_router
|
||||||
from routers.telegram import router as telegram_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) ──────────────────────
|
# ── Cache-bust hash (SHA-1 of all static file contents) ──────────────────────
|
||||||
def _static_hash(static_dir: str) -> str:
|
def _static_hash(static_dir: str) -> str:
|
||||||
@@ -112,6 +117,7 @@ app.include_router(credentials_router)
|
|||||||
app.include_router(admins_router)
|
app.include_router(admins_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
app.include_router(telegram_router)
|
app.include_router(telegram_router)
|
||||||
|
app.include_router(logs_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/site-config", include_in_schema=False)
|
@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 active" data-tab="keypasses">Keypasses</button>
|
||||||
<button class="tab-btn" data-tab="gates">Gates</button>
|
<button class="tab-btn" data-tab="gates">Gates</button>
|
||||||
<button class="tab-btn" data-tab="stats">Statistics</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="admins">Admins</button>
|
||||||
<button class="tab-btn admin-only" data-tab="credentials">API Credentials</button>
|
<button class="tab-btn admin-only" data-tab="credentials">API Credentials</button>
|
||||||
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
|
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
|
||||||
@@ -367,6 +368,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</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><!-- /.tab-content -->
|
||||||
</div><!-- /#admin-view -->
|
</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 ─────────────────────────────────────────────────────────────
|
// ── Load all data ─────────────────────────────────────────────────────────────
|
||||||
function loadAllData() {
|
function loadAllData() {
|
||||||
const isAdmin = _tokenPayload().scope === "admin";
|
const isAdmin = _tokenPayload().scope === "admin";
|
||||||
|
|||||||
Reference in New Issue
Block a user