Add filters in statistics view

This commit is contained in:
Ettore
2026-05-10 00:48:21 +02:00
parent ff097b31d1
commit 0264425383
4 changed files with 146 additions and 14 deletions

View File

@@ -137,3 +137,10 @@ class AccessLogResponse(BaseModel):
user_agent: Optional[str] user_agent: Optional[str]
success: bool success: bool
error: Optional[str] error: Optional[str]
class StatsPage(BaseModel):
total: int
page: int
page_size: int
items: list[AccessLogResponse]

View File

@@ -1,21 +1,45 @@
from fastapi import APIRouter, Depends from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.database import GateAccessLog, get_db from core.database import GateAccessLog, get_db
from core.dependencies import require_manager from core.dependencies import require_manager
from core.schemas import AccessLogResponse from core.schemas import StatsPage
router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"]) router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"])
@router.get("", response_model=list[AccessLogResponse]) @router.get("", response_model=StatsPage)
async def get_stats( async def get_stats(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: dict = Depends(require_manager), _: dict = Depends(require_manager),
gate_id: Optional[int] = Query(None),
keypass_code: Optional[str] = Query(None),
success: Optional[bool] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
): ):
return ( q = db.query(GateAccessLog)
db.query(GateAccessLog) if gate_id is not None:
.order_by(GateAccessLog.timestamp.desc()) q = q.filter(GateAccessLog.gate_id == gate_id)
.limit(500) if keypass_code:
q = q.filter(GateAccessLog.keypass_code.ilike(f"%{keypass_code}%"))
if success is not None:
q = q.filter(GateAccessLog.success == success)
if date_from is not None:
q = q.filter(GateAccessLog.timestamp >= date_from)
if date_to is not None:
q = q.filter(GateAccessLog.timestamp <= date_to)
total = q.count()
items = (
q.order_by(GateAccessLog.timestamp.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all() .all()
) )
return StatsPage(total=total, page=page, page_size=page_size, items=items)

View File

@@ -206,6 +206,40 @@
<h3>Gate Access Log</h3> <h3>Gate Access Log</h3>
<button id="btn-refresh-stats" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">↻ Refresh</button> <button id="btn-refresh-stats" 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)">Keypass code</label>
<input id="filter-keypass" type="text" placeholder="Any"
style="width:140px;font-family:monospace;text-transform:uppercase" autocomplete="off" />
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Gate</label>
<select id="filter-gate" style="width:160px">
<option value="">Any</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">Result</label>
<select id="filter-success" style="width:110px">
<option value="">Any</option>
<option value="true">Success</option>
<option value="false">Failed</option>
</select>
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">From</label>
<input id="filter-from" type="datetime-local" style="width:180px" />
</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<label style="font-size:.8rem;font-weight:600;color:var(--text-muted)">To</label>
<input id="filter-to" type="datetime-local" style="width:180px" />
</div>
<button id="btn-stats-filter" class="btn btn-primary" style="font-size:.85rem;padding:.5rem 1rem">Filter</button>
<button id="btn-stats-reset" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">Reset</button>
</div>
<div class="table-wrap card" style="padding:0"> <div class="table-wrap card" style="padding:0">
<table id="stats-table"> <table id="stats-table">
<thead> <thead>
@@ -221,6 +255,16 @@
<tbody id="stats-body"></tbody> <tbody id="stats-body"></tbody>
</table> </table>
</div> </div>
<!-- Pagination -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:1rem;font-size:.9rem;flex-wrap:wrap;gap:.5rem">
<span id="stats-total-label" style="color:var(--text-muted)"></span>
<div style="display:flex;gap:.5rem;align-items:center">
<button id="btn-stats-prev" class="btn btn-ghost" style="font-size:.85rem;padding:.4rem .9rem">← Prev</button>
<span id="stats-page-label" style="color:var(--text-muted);min-width:90px;text-align:center"></span>
<button id="btn-stats-next" class="btn btn-ghost" style="font-size:.85rem;padding:.4rem .9rem">Next →</button>
</div>
</div>
</div> </div>
<!-- ── Admins pane ──────────────────────────────────────────────────── --> <!-- ── Admins pane ──────────────────────────────────────────────────── -->

View File

@@ -366,6 +366,17 @@ document.getElementById("keypass-form").addEventListener("submit", async e => {
async function loadGates() { async function loadGates() {
const rows = await api("GET", "/api/admin/gates"); const rows = await api("GET", "/api/admin/gates");
_allGates = rows; // cache for keypass modal _allGates = rows; // cache for keypass modal
// Populate the stats gate filter dropdown
const filterGate = document.getElementById("filter-gate");
const prevGateVal = filterGate.value;
filterGate.innerHTML = '<option value="">Any</option>';
for (const g of rows) {
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name;
filterGate.appendChild(opt);
}
filterGate.value = prevGateVal; // restore selection if still valid
const isAdmin = _tokenPayload().scope === "admin"; const isAdmin = _tokenPayload().scope === "admin";
const tbody = document.getElementById("gates-body"); const tbody = document.getElementById("gates-body");
tbody.innerHTML = ""; tbody.innerHTML = "";
@@ -521,16 +532,47 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
}); });
// ── Statistics ─────────────────────────────────────────────────────────────── // ── Statistics ───────────────────────────────────────────────────────────────
const STATS_PAGE_SIZE = 50;
let _statsPage = 1;
let _statsTotal = 0;
function _buildStatsParams() {
const params = new URLSearchParams();
const keypass = document.getElementById("filter-keypass").value.trim();
if (keypass) params.set("keypass_code", keypass.toUpperCase());
const gate = document.getElementById("filter-gate").value;
if (gate) params.set("gate_id", gate);
const success = document.getElementById("filter-success").value;
if (success !== "") params.set("success", success);
const from = document.getElementById("filter-from").value;
if (from) params.set("date_from", new Date(from).toISOString());
const to = document.getElementById("filter-to").value;
if (to) params.set("date_to", new Date(to).toISOString());
params.set("page", _statsPage);
params.set("page_size", STATS_PAGE_SIZE);
return params;
}
async function loadStats() { async function loadStats() {
try { try {
const rows = await api("GET", "/api/admin/stats"); const data = await api("GET", `/api/admin/stats?${_buildStatsParams()}`);
_statsTotal = data.total;
const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE));
document.getElementById("stats-total-label").textContent =
`${_statsTotal} record${_statsTotal !== 1 ? "s" : ""}`;
document.getElementById("stats-page-label").textContent =
`Page ${_statsPage} of ${totalPages}`;
document.getElementById("btn-stats-prev").disabled = _statsPage <= 1;
document.getElementById("btn-stats-next").disabled = _statsPage >= totalPages;
const tbody = document.getElementById("stats-body"); const tbody = document.getElementById("stats-body");
tbody.innerHTML = ""; tbody.innerHTML = "";
if (!rows.length) { if (!data.items.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No access logs yet</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-muted);text-align:center;padding:2rem">No records match the current filters</td></tr>';
return; return;
} }
for (const r of rows) { for (const r of data.items) {
const badge = r.success const badge = r.success
? '<span class="badge badge-green">OK</span>' ? '<span class="badge badge-green">OK</span>'
: `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`; : `<span class="badge badge-red" title="${esc(r.error || '')}">Fail</span>`;
@@ -547,9 +589,24 @@ async function loadStats() {
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
} }
document.getElementById("btn-refresh-stats").addEventListener("click", loadStats); document.getElementById("btn-refresh-stats").addEventListener("click", () => { _statsPage = 1; loadStats(); });
document.getElementById("btn-stats-filter").addEventListener("click", () => { _statsPage = 1; loadStats(); });
document.getElementById("btn-refresh-stats").addEventListener("click", loadStats); document.getElementById("btn-stats-reset").addEventListener("click", () => {
document.getElementById("filter-keypass").value = "";
document.getElementById("filter-gate").value = "";
document.getElementById("filter-success").value = "";
document.getElementById("filter-from").value = "";
document.getElementById("filter-to").value = "";
_statsPage = 1;
loadStats();
});
document.getElementById("btn-stats-prev").addEventListener("click", () => {
if (_statsPage > 1) { _statsPage--; loadStats(); }
});
document.getElementById("btn-stats-next").addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(_statsTotal / STATS_PAGE_SIZE));
if (_statsPage < totalPages) { _statsPage++; loadStats(); }
});
// ── Admin users ─────────────────────────────────────────────────────────────── // ── Admin users ───────────────────────────────────────────────────────────────
async function loadAdmins() { async function loadAdmins() {