Add filters in statistics view
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────── -->
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user