2

Refine dashboard leaderboard filters and layout

This commit is contained in:
2026-03-12 16:03:02 +01:00
parent 49bda65b3b
commit 87d2d5f440
8 changed files with 169 additions and 52 deletions

View File

@@ -17,7 +17,7 @@ import (
)
type App interface {
GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error)
GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error)
ListEvents(ctx context.Context, limit int) ([]model.Event, error)
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error)
@@ -107,7 +107,11 @@ func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
hours = 24
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
overview, err := h.app.GetOverview(r.Context(), since, limit)
options := model.OverviewOptions{
ShowKnownBots: queryBool(r, "show_known_bots", true),
ShowAllowed: queryBool(r, "show_allowed", true),
}
overview, err := h.app.GetOverview(r.Context(), since, limit, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
@@ -277,6 +281,21 @@ func queryInt(r *http.Request, name string, fallback int) int {
return parsed
}
func queryBool(r *http.Request, name string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(name)))
if value == "" {
return fallback
}
switch value {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -360,7 +379,9 @@ const overviewHTML = `<!doctype html>
.muted { color: #94a3b8; }
.mono { font-family: ui-monospace, monospace; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
.leaders { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; }
.controls { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.controls-group { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.leaders { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.leader-card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; }
.leader-card h2 { margin-bottom: .35rem; font-size: 1rem; }
.leader-list { list-style: none; margin: .75rem 0 0 0; padding: 0; display: grid; gap: .65rem; }
@@ -368,7 +389,7 @@ const overviewHTML = `<!doctype html>
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.leader-main .mono { overflow: hidden; text-overflow: ellipsis; }
.leader-value { font-weight: 600; white-space: nowrap; }
.leader-sub { font-size: .87rem; color: #94a3b8; }
@media (max-width: 1100px) { .leaders { grid-template-columns: 1fr; } }
.toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
.toolbar .meta { font-size: .95rem; color: #94a3b8; }
.toolbar-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; justify-content: flex-end; }
@@ -407,13 +428,18 @@ const overviewHTML = `<!doctype html>
</header>
<main>
<section class="stats" id="stats"></section>
<section class="panel controls">
<div class="controls-group">
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
<label class="toggle"><input id="show-allowed-toggle" type="checkbox" checked onchange="toggleAllowed()">Show allowed</label>
</div>
<div class="muted">These two filters affect both the leaderboards and the Recent IPs list.</div>
</section>
<section class="leaders" id="leaderboards"></section>
<section class="panel">
<div class="toolbar">
<h2>Recent IPs</h2>
<div class="toolbar-right">
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
<label class="toggle"><input id="show-allowed-toggle" type="checkbox" checked onchange="toggleAllowed()">Show allowed</label>
<label class="toggle"><input id="show-review-toggle" type="checkbox" onchange="toggleReviewOnly()">Review only</label>
<div class="meta">Last 24 hours · click a column to sort</div>
</div>
@@ -638,24 +664,20 @@ const overviewHTML = `<!doctype html>
}
function renderTopIPs(items, primaryMetric) {
const filteredItems = (Array.isArray(items) ? items : []).filter(item => showKnownBots || !item.bot);
if (filteredItems.length === 0) {
const visibleItems = Array.isArray(items) ? items : [];
if (visibleItems.length === 0) {
return '<div class="muted">No matching IP activity in the selected window.</div>';
}
return '<ol class="leader-list">' + filteredItems.map(item => {
return '<ol class="leader-list">' + visibleItems.map(item => {
const primaryValue = primaryMetric === 'traffic'
? formatBytes(item.traffic_bytes)
: String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's');
const secondaryValue = primaryMetric === 'traffic'
? String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')
: formatBytes(item.traffic_bytes);
: String(item.events || 0);
return [
'<li class="leader-item">',
' <div class="leader-main">',
' <div class="ip-cell mono">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></div>',
' <a class="mono" href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a>',
' <span class="leader-value">' + escapeHtml(primaryValue) + '</span>',
' </div>',
' <div class="leader-sub">' + escapeHtml(secondaryValue) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
'</li>'
].join('');
}).join('') + '</ol>';
@@ -669,9 +691,8 @@ const overviewHTML = `<!doctype html>
'<li class="leader-item">',
' <div class="leader-main">',
' <span class="mono">' + escapeHtml(item.source_name || '—') + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')) + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
' </div>',
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
'</li>'
].join('')).join('') + '</ol>';
}
@@ -686,9 +707,8 @@ const overviewHTML = `<!doctype html>
'<li class="leader-item">',
' <div class="leader-main">',
' <span class="mono">' + escapeHtml(label) + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')) + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
' </div>',
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
'</li>'
].join('');
}).join('') + '</ol>';
@@ -837,8 +857,7 @@ const overviewHTML = `<!doctype html>
showKnownBots = !toggle || toggle.checked;
saveShowKnownBotsPreference(showKnownBots);
render();
const overviewStats = window.__overviewPayload || {};
renderLeaderboards(overviewStats);
refreshOverview();
}
function toggleAllowed() {
@@ -846,6 +865,7 @@ const overviewHTML = `<!doctype html>
showAllowed = !toggle || toggle.checked;
saveShowAllowedPreference(showAllowed);
render();
refreshOverview();
}
function toggleReviewOnly() {
@@ -873,18 +893,21 @@ const overviewHTML = `<!doctype html>
await refresh();
}
async function refresh() {
const [overviewResponse, recentResponse] = await Promise.all([
fetch('/api/overview?hours=' + recentHours + '&limit=10'),
fetch('/api/recent-ips?hours=' + recentHours + '&limit=250')
]);
const overviewPayload = await overviewResponse.json().catch(() => ({}));
const recentPayload = await recentResponse.json().catch(() => []);
if (overviewResponse.ok) {
window.__overviewPayload = overviewPayload || {};
renderStats(overviewPayload || {});
renderLeaderboards(overviewPayload || {});
async function refreshOverview() {
const response = await fetch('/api/overview?hours=' + recentHours + '&limit=10&show_known_bots=' + (showKnownBots ? 'true' : 'false') + '&show_allowed=' + (showAllowed ? 'true' : 'false'));
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return;
}
window.__overviewPayload = payload || {};
renderStats(payload || {});
renderLeaderboards(payload || {});
}
async function refresh() {
const recentResponse = await fetch('/api/recent-ips?hours=' + recentHours + '&limit=250');
const recentPayload = await recentResponse.json().catch(() => []);
refreshOverview();
if (!recentResponse.ok) {
const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText);
document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">' + escapeHtml(message) + '</td></tr>';

View File

@@ -46,6 +46,16 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
t.Fatalf("unexpected overview payload: %+v", overview)
}
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/api/overview?hours=24&limit=10&show_known_bots=false&show_allowed=false", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected filtered overview status: %d", recorder.Code)
}
if app.lastOverviewOptions.ShowKnownBots || app.lastOverviewOptions.ShowAllowed {
t.Fatalf("overview filter options were not forwarded correctly: %+v", app.lastOverviewOptions)
}
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/block", strings.NewReader(`{"reason":"test reason","actor":"tester"}`))
request.Header.Set("Content-Type", "application/json")
@@ -92,6 +102,9 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
t.Fatalf("overview page should expose the top URLs block")
}
if !strings.Contains(recorder.Body.String(), "These two filters affect both the leaderboards and the Recent IPs list") {
t.Fatalf("overview page should explain the scope of the shared filters")
}
if !strings.Contains(recorder.Body.String(), "Show allowed") {
t.Fatalf("overview page should expose the allowed toggle")
}
@@ -130,10 +143,12 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
}
type stubApp struct {
lastAction string
lastAction string
lastOverviewOptions model.OverviewOptions
}
func (s *stubApp) GetOverview(context.Context, time.Time, int) (model.Overview, error) {
func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options model.OverviewOptions) (model.Overview, error) {
s.lastOverviewOptions = options
now := time.Now().UTC()
return model.Overview{
TotalEvents: 1,
@@ -183,12 +198,12 @@ func (s *stubApp) GetOverview(context.Context, time.Time, int) (model.Overview,
}
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
overview, _ := s.GetOverview(ctx, time.Time{}, limit)
overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
return overview.RecentEvents, nil
}
func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
overview, _ := s.GetOverview(ctx, time.Time{}, limit)
overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
return overview.RecentIPs, nil
}