2

Add dashboard activity leaderboards

This commit is contained in:
2026-03-12 15:48:33 +01:00
parent f15839cf51
commit 49bda65b3b
9 changed files with 534 additions and 26 deletions

View File

@@ -17,7 +17,7 @@ import (
)
type App interface {
GetOverview(ctx context.Context, limit int) (model.Overview, error)
GetOverview(ctx context.Context, since time.Time, limit int) (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)
@@ -102,7 +102,12 @@ func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
return
}
limit := queryLimit(r, 50)
overview, err := h.app.GetOverview(r.Context(), limit)
hours := queryInt(r, "hours", 24)
if hours <= 0 {
hours = 24
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
overview, err := h.app.GetOverview(r.Context(), since, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
@@ -355,6 +360,15 @@ 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; }
.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; }
.leader-item { display: grid; gap: .2rem; }
.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; }
.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; }
@@ -393,6 +407,7 @@ const overviewHTML = `<!doctype html>
</header>
<main>
<section class="stats" id="stats"></section>
<section class="leaders" id="leaderboards"></section>
<section class="panel">
<div class="toolbar">
<h2>Recent IPs</h2>
@@ -460,6 +475,22 @@ const overviewHTML = `<!doctype html>
].join('')).join('');
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
let current = bytes;
let unitIndex = 0;
while (current >= 1000 && unitIndex < units.length - 1) {
current /= 1000;
unitIndex += 1;
}
const precision = current >= 100 || unitIndex === 0 ? 0 : 1;
return current.toFixed(precision) + ' ' + units[unitIndex];
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
}
@@ -606,6 +637,95 @@ const overviewHTML = `<!doctype html>
return '<span class="bot-chip ' + escapeHtml(visual.className) + ' ' + statusClass + '" title="' + escapeHtml(title) + '">' + escapeHtml(visual.short) + '</span>';
}
function renderTopIPs(items, primaryMetric) {
const filteredItems = (Array.isArray(items) ? items : []).filter(item => showKnownBots || !item.bot);
if (filteredItems.length === 0) {
return '<div class="muted">No matching IP activity in the selected window.</div>';
}
return '<ol class="leader-list">' + filteredItems.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);
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>',
' <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>';
}
function renderTopSources(items) {
if (!Array.isArray(items) || items.length === 0) {
return '<div class="muted">No source activity in the selected window.</div>';
}
return '<ol class="leader-list">' + items.map(item => [
'<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>',
' </div>',
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
'</li>'
].join('')).join('') + '</ol>';
}
function renderTopURLs(items) {
if (!Array.isArray(items) || items.length === 0) {
return '<div class="muted">No URL activity in the selected window.</div>';
}
return '<ol class="leader-list">' + items.map(item => {
const label = ((item.host || '') ? (item.host + item.uri) : (item.uri || '—'));
return [
'<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>',
' </div>',
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
'</li>'
].join('');
}).join('') + '</ol>';
}
function renderLeaderboards(data) {
const cards = [
{
title: 'Top IPs by events',
subtitle: 'Last 24 hours',
body: renderTopIPs(data.top_ips_by_events, 'events'),
},
{
title: 'Top IPs by traffic',
subtitle: 'Last 24 hours',
body: renderTopIPs(data.top_ips_by_traffic, 'traffic'),
},
{
title: 'Top sources by events',
subtitle: 'Last 24 hours',
body: renderTopSources(data.top_sources),
},
{
title: 'Top URLs by events',
subtitle: 'Last 24 hours',
body: renderTopURLs(data.top_urls),
},
];
document.getElementById('leaderboards').innerHTML = cards.map(card => [
'<section class="leader-card">',
' <h2>' + escapeHtml(card.title) + '</h2>',
' <div class="muted">' + escapeHtml(card.subtitle) + '</div>',
card.body,
'</section>'
].join('')).join('');
}
function updateSortButtons() {
const botsToggle = document.getElementById('show-bots-toggle');
if (botsToggle) {
@@ -717,6 +837,8 @@ const overviewHTML = `<!doctype html>
showKnownBots = !toggle || toggle.checked;
saveShowKnownBotsPreference(showKnownBots);
render();
const overviewStats = window.__overviewPayload || {};
renderLeaderboards(overviewStats);
}
function toggleAllowed() {
@@ -753,13 +875,15 @@ const overviewHTML = `<!doctype html>
async function refresh() {
const [overviewResponse, recentResponse] = await Promise.all([
fetch('/api/overview?limit=50'),
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 || {});
}
if (!recentResponse.ok) {
const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText);