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

@@ -7,7 +7,7 @@
- Real-time ingestion of multiple Caddy JSON access log files. - Real-time ingestion of multiple Caddy JSON access log files.
- One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias. - One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias.
- Persistent SQLite state for events, IP states, investigations, decisions, backend actions, and source offsets. - Persistent SQLite state for events, IP states, investigations, decisions, backend actions, and source offsets.
- Lightweight web UI with overview cards, a sortable “Recent IPs” table, IP detail pages, decision history, and full request history per address. - Lightweight web UI with overview cards, top activity leaderboards, a sortable “Recent IPs” table, IP detail pages, decision history, and full request history per address.
- Background investigation workers that fill in missing cached intelligence without slowing down page loads. - Background investigation workers that fill in missing cached intelligence without slowing down page loads.
- Manual `Block`, `Unblock`, `Clear override`, and `Refresh investigation` actions from the UI or the HTTP API. - Manual `Block`, `Unblock`, `Clear override`, and `Refresh investigation` actions from the UI or the HTTP API.
- Optional OPNsense integration; the daemon also works in review-only mode. - Optional OPNsense integration; the daemon also works in review-only mode.
@@ -116,7 +116,7 @@ Detailed NixOS installation examples are in [`docs/install.md`](docs/install.md)
The UI is backed by a small JSON API. The main endpoints are: The UI is backed by a small JSON API. The main endpoints are:
- `GET /healthz` - `GET /healthz`
- `GET /api/overview` - `GET /api/overview?hours=24`
- `GET /api/events` - `GET /api/events`
- `GET /api/ips` - `GET /api/ips`
- `GET /api/recent-ips?hours=24` - `GET /api/recent-ips?hours=24`

View File

@@ -33,6 +33,10 @@ Query parameters:
- optional - optional
- default: `50` - default: `50`
- maximum: `1000` - maximum: `1000`
- `hours`
- optional
- default: `24`
- used for the top activity leaderboards returned in the same payload
Main response fields: Main response fields:
@@ -42,6 +46,11 @@ Main response fields:
- `review_ips` - `review_ips`
- `allowed_ips` - `allowed_ips`
- `observed_ips` - `observed_ips`
- `activity_since`
- `top_ips_by_events`
- `top_ips_by_traffic`
- `top_sources`
- `top_urls`
- `recent_ips` - `recent_ips`
- `recent_events` - `recent_events`

View File

@@ -178,6 +178,29 @@ type RecentIPRow struct {
Actions ActionAvailability `json:"actions"` Actions ActionAvailability `json:"actions"`
} }
type TopIPRow struct {
IP string `json:"ip"`
Events int64 `json:"events"`
TrafficBytes int64 `json:"traffic_bytes"`
LastSeenAt time.Time `json:"last_seen_at"`
Bot *BotMatch `json:"bot,omitempty"`
}
type TopSourceRow struct {
SourceName string `json:"source_name"`
Events int64 `json:"events"`
TrafficBytes int64 `json:"traffic_bytes"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type TopURLRow struct {
Host string `json:"host"`
URI string `json:"uri"`
Events int64 `json:"events"`
TrafficBytes int64 `json:"traffic_bytes"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type SourceOffset struct { type SourceOffset struct {
SourceName string SourceName string
Path string Path string
@@ -203,6 +226,11 @@ type Overview struct {
ReviewIPs int64 `json:"review_ips"` ReviewIPs int64 `json:"review_ips"`
AllowedIPs int64 `json:"allowed_ips"` AllowedIPs int64 `json:"allowed_ips"`
ObservedIPs int64 `json:"observed_ips"` ObservedIPs int64 `json:"observed_ips"`
ActivitySince time.Time `json:"activity_since,omitempty"`
TopIPsByEvents []TopIPRow `json:"top_ips_by_events"`
TopIPsByTraffic []TopIPRow `json:"top_ips_by_traffic"`
TopSources []TopSourceRow `json:"top_sources"`
TopURLs []TopURLRow `json:"top_urls"`
RecentIPs []IPState `json:"recent_ips"` RecentIPs []IPState `json:"recent_ips"`
RecentEvents []Event `json:"recent_events"` RecentEvents []Event `json:"recent_events"`
} }

View File

@@ -91,8 +91,15 @@ func (s *Service) Run(ctx context.Context) error {
return nil return nil
} }
func (s *Service) GetOverview(ctx context.Context, limit int) (model.Overview, error) { func (s *Service) GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error) {
return s.store.GetOverview(ctx, limit) overview, err := s.store.GetOverview(ctx, since, limit)
if err != nil {
return model.Overview{}, err
}
if err := s.decorateOverviewTopIPs(ctx, &overview); err != nil {
return model.Overview{}, err
}
return overview, nil
} }
func (s *Service) ListEvents(ctx context.Context, limit int) ([]model.Event, error) { func (s *Service) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
@@ -665,3 +672,37 @@ func recentRowIPs(items []model.RecentIPRow) []string {
} }
return result return result
} }
func (s *Service) decorateOverviewTopIPs(ctx context.Context, overview *model.Overview) error {
if overview == nil {
return nil
}
ips := append(topIPRowIPs(overview.TopIPsByEvents), topIPRowIPs(overview.TopIPsByTraffic)...)
investigations, err := s.store.GetInvestigationsForIPs(ctx, ips)
if err != nil {
return err
}
for index := range overview.TopIPsByEvents {
if investigation, ok := investigations[overview.TopIPsByEvents[index].IP]; ok {
overview.TopIPsByEvents[index].Bot = investigation.Bot
} else {
s.enqueueInvestigation(overview.TopIPsByEvents[index].IP)
}
}
for index := range overview.TopIPsByTraffic {
if investigation, ok := investigations[overview.TopIPsByTraffic[index].IP]; ok {
overview.TopIPsByTraffic[index].Bot = investigation.Bot
} else {
s.enqueueInvestigation(overview.TopIPsByTraffic[index].IP)
}
}
return nil
}
func topIPRowIPs(items []model.TopIPRow) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.IP)
}
return result
}

View File

@@ -98,7 +98,7 @@ sources:
appendLine(t, giteaLogPath, caddyJSONLine("203.0.113.12", "198.51.100.12", "git.example.test", "GET", "/install.php", 404, "curl/8.0", time.Now().UTC())) appendLine(t, giteaLogPath, caddyJSONLine("203.0.113.12", "198.51.100.12", "git.example.test", "GET", "/install.php", 404, "curl/8.0", time.Now().UTC()))
waitFor(t, 3*time.Second, func() bool { waitFor(t, 3*time.Second, func() bool {
overview, err := database.GetOverview(context.Background(), 10) overview, err := database.GetOverview(context.Background(), time.Now().UTC().Add(-time.Hour), 10)
return err == nil && overview.TotalEvents == 3 return err == nil && overview.TotalEvents == 3
}) })

View File

@@ -223,6 +223,8 @@ func (s *Store) RecordEvent(ctx context.Context, event *model.Event) error {
return nil return nil
} }
const responseBytesExpression = `CASE WHEN json_valid(e.raw_json) THEN CAST(COALESCE(json_extract(e.raw_json, '$.size'), 0) AS INTEGER) ELSE 0 END`
func (s *Store) AddDecision(ctx context.Context, decision *model.DecisionRecord) error { func (s *Store) AddDecision(ctx context.Context, decision *model.DecisionRecord) error {
if decision == nil { if decision == nil {
return errors.New("nil decision record") return errors.New("nil decision record")
@@ -370,11 +372,14 @@ func (s *Store) ClearManualOverride(ctx context.Context, ip string, reason strin
return current, nil return current, nil
} }
func (s *Store) GetOverview(ctx context.Context, limit int) (model.Overview, error) { func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
var overview model.Overview var overview model.Overview
if !since.IsZero() {
overview.ActivitySince = since.UTC()
}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM events`).Scan(&overview.TotalEvents); err != nil { if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM events`).Scan(&overview.TotalEvents); err != nil {
return model.Overview{}, fmt.Errorf("count events: %w", err) return model.Overview{}, fmt.Errorf("count events: %w", err)
} }
@@ -402,11 +407,171 @@ func (s *Store) GetOverview(ctx context.Context, limit int) (model.Overview, err
if err != nil { if err != nil {
return model.Overview{}, err return model.Overview{}, err
} }
topIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events")
if err != nil {
return model.Overview{}, err
}
topIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic")
if err != nil {
return model.Overview{}, err
}
topSources, err := s.listTopSourceRows(ctx, since, limit)
if err != nil {
return model.Overview{}, err
}
topURLs, err := s.listTopURLRows(ctx, since, limit)
if err != nil {
return model.Overview{}, err
}
overview.RecentIPs = recentIPs overview.RecentIPs = recentIPs
overview.RecentEvents = recentEvents overview.RecentEvents = recentEvents
overview.TopIPsByEvents = topIPsByEvents
overview.TopIPsByTraffic = topIPsByTraffic
overview.TopSources = topSources
overview.TopURLs = topURLs
return overview, nil return overview, nil
} }
func (s *Store) listTopIPRows(ctx context.Context, since time.Time, limit int, orderBy string) ([]model.TopIPRow, error) {
if limit <= 0 {
limit = 10
}
query := fmt.Sprintf(`
SELECT e.client_ip,
COUNT(*) AS event_count,
COALESCE(SUM(%s), 0) AS traffic_bytes,
MAX(e.occurred_at) AS last_seen_at
FROM events e`, responseBytesExpression)
args := make([]any, 0, 2)
if !since.IsZero() {
query += ` WHERE e.occurred_at >= ?`
args = append(args, formatTime(since))
}
query += ` GROUP BY e.client_ip`
switch orderBy {
case "traffic":
query += ` ORDER BY traffic_bytes DESC, event_count DESC, last_seen_at DESC, e.client_ip ASC`
default:
query += ` ORDER BY event_count DESC, traffic_bytes DESC, last_seen_at DESC, e.client_ip ASC`
}
query += ` LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list top ip rows by %s: %w", orderBy, err)
}
defer rows.Close()
items := make([]model.TopIPRow, 0, limit)
for rows.Next() {
var item model.TopIPRow
var lastSeenAt string
if err := rows.Scan(&item.IP, &item.Events, &item.TrafficBytes, &lastSeenAt); err != nil {
return nil, fmt.Errorf("scan top ip row: %w", err)
}
parsed, err := parseTime(lastSeenAt)
if err != nil {
return nil, fmt.Errorf("parse top ip row last_seen_at: %w", err)
}
item.LastSeenAt = parsed
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate top ip rows by %s: %w", orderBy, err)
}
return items, nil
}
func (s *Store) listTopSourceRows(ctx context.Context, since time.Time, limit int) ([]model.TopSourceRow, error) {
if limit <= 0 {
limit = 10
}
query := fmt.Sprintf(`
SELECT e.source_name,
COUNT(*) AS event_count,
COALESCE(SUM(%s), 0) AS traffic_bytes,
MAX(e.occurred_at) AS last_seen_at
FROM events e`, responseBytesExpression)
args := make([]any, 0, 2)
if !since.IsZero() {
query += ` WHERE e.occurred_at >= ?`
args = append(args, formatTime(since))
}
query += ` GROUP BY e.source_name ORDER BY event_count DESC, traffic_bytes DESC, last_seen_at DESC, e.source_name ASC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list top source rows: %w", err)
}
defer rows.Close()
items := make([]model.TopSourceRow, 0, limit)
for rows.Next() {
var item model.TopSourceRow
var lastSeenAt string
if err := rows.Scan(&item.SourceName, &item.Events, &item.TrafficBytes, &lastSeenAt); err != nil {
return nil, fmt.Errorf("scan top source row: %w", err)
}
parsed, err := parseTime(lastSeenAt)
if err != nil {
return nil, fmt.Errorf("parse top source row last_seen_at: %w", err)
}
item.LastSeenAt = parsed
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate top source rows: %w", err)
}
return items, nil
}
func (s *Store) listTopURLRows(ctx context.Context, since time.Time, limit int) ([]model.TopURLRow, error) {
if limit <= 0 {
limit = 10
}
query := fmt.Sprintf(`
SELECT e.host,
e.uri,
COUNT(*) AS event_count,
COALESCE(SUM(%s), 0) AS traffic_bytes,
MAX(e.occurred_at) AS last_seen_at
FROM events e`, responseBytesExpression)
args := make([]any, 0, 2)
if !since.IsZero() {
query += ` WHERE e.occurred_at >= ?`
args = append(args, formatTime(since))
}
query += ` GROUP BY e.host, e.uri ORDER BY event_count DESC, traffic_bytes DESC, last_seen_at DESC, e.host ASC, e.uri ASC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list top url rows: %w", err)
}
defer rows.Close()
items := make([]model.TopURLRow, 0, limit)
for rows.Next() {
var item model.TopURLRow
var lastSeenAt string
if err := rows.Scan(&item.Host, &item.URI, &item.Events, &item.TrafficBytes, &lastSeenAt); err != nil {
return nil, fmt.Errorf("scan top url row: %w", err)
}
parsed, err := parseTime(lastSeenAt)
if err != nil {
return nil, fmt.Errorf("parse top url row last_seen_at: %w", err)
}
item.LastSeenAt = parsed
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate top url rows: %w", err)
}
return items, nil
}
func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) { func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50

View File

@@ -99,13 +99,22 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
t.Fatalf("unexpected source offset: found=%v offset=%+v", found, offset) t.Fatalf("unexpected source offset: found=%v offset=%+v", found, offset)
} }
overview, err := db.GetOverview(ctx, 10) overview, err := db.GetOverview(ctx, occurredAt.Add(-time.Hour), 10)
if err != nil { if err != nil {
t.Fatalf("get overview: %v", err) t.Fatalf("get overview: %v", err)
} }
if overview.TotalEvents != 1 || overview.TotalIPs != 1 { if overview.TotalEvents != 1 || overview.TotalIPs != 1 {
t.Fatalf("unexpected overview counters: %+v", overview) t.Fatalf("unexpected overview counters: %+v", overview)
} }
if len(overview.TopIPsByEvents) != 1 || overview.TopIPsByEvents[0].IP != event.ClientIP {
t.Fatalf("unexpected top ips by events: %+v", overview.TopIPsByEvents)
}
if len(overview.TopSources) != 1 || overview.TopSources[0].SourceName != event.SourceName {
t.Fatalf("unexpected top sources: %+v", overview.TopSources)
}
if len(overview.TopURLs) != 1 || overview.TopURLs[0].URI != event.URI {
t.Fatalf("unexpected top urls: %+v", overview.TopURLs)
}
recentIPs, err := db.ListRecentIPRows(ctx, occurredAt.Add(-time.Hour), 10) recentIPs, err := db.ListRecentIPRows(ctx, occurredAt.Add(-time.Hour), 10)
if err != nil { if err != nil {
t.Fatalf("list recent ip rows: %v", err) t.Fatalf("list recent ip rows: %v", err)
@@ -161,3 +170,98 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
t.Fatalf("expected no IPs without investigation, got %#v", missingInvestigationIPs) t.Fatalf("expected no IPs without investigation, got %#v", missingInvestigationIPs)
} }
} }
func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "blocker.db")
db, err := Open(dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer db.Close()
ctx := context.Background()
baseTime := time.Date(2025, 3, 12, 15, 0, 0, 0, time.UTC)
events := []*model.Event{
{
SourceName: "public-web",
ProfileName: "public-web",
OccurredAt: baseTime,
RemoteIP: "198.51.100.10",
ClientIP: "203.0.113.10",
Host: "example.test",
Method: "GET",
URI: "/wp-login.php",
Path: "/wp-login.php",
Status: 404,
UserAgent: "curl/8.0",
Decision: model.DecisionActionBlock,
DecisionReason: "php_path",
DecisionReasons: []string{"php_path"},
RawJSON: `{"status":404,"size":2048}`,
},
{
SourceName: "public-web",
ProfileName: "public-web",
OccurredAt: baseTime.Add(10 * time.Second),
RemoteIP: "198.51.100.11",
ClientIP: "203.0.113.10",
Host: "example.test",
Method: "GET",
URI: "/wp-login.php",
Path: "/wp-login.php",
Status: 404,
UserAgent: "curl/8.0",
Decision: model.DecisionActionBlock,
DecisionReason: "php_path",
DecisionReasons: []string{"php_path"},
RawJSON: `{"status":404,"size":1024}`,
},
{
SourceName: "gitea",
ProfileName: "gitea",
OccurredAt: baseTime.Add(20 * time.Second),
RemoteIP: "198.51.100.12",
ClientIP: "203.0.113.20",
Host: "git.example.test",
Method: "GET",
URI: "/install.php",
Path: "/install.php",
Status: 404,
UserAgent: "curl/8.0",
Decision: model.DecisionActionReview,
DecisionReason: "suspicious_path_prefix:/install.php",
DecisionReasons: []string{"suspicious_path_prefix:/install.php"},
RawJSON: `{"status":404,"size":4096}`,
},
}
for _, event := range events {
if err := db.RecordEvent(ctx, event); err != nil {
t.Fatalf("record event %+v: %v", event, err)
}
}
overview, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10)
if err != nil {
t.Fatalf("get overview: %v", err)
}
if len(overview.TopIPsByEvents) < 2 {
t.Fatalf("expected at least 2 top IP rows by events, got %+v", overview.TopIPsByEvents)
}
if overview.TopIPsByEvents[0].IP != "203.0.113.10" || overview.TopIPsByEvents[0].Events != 2 || overview.TopIPsByEvents[0].TrafficBytes != 3072 {
t.Fatalf("unexpected top IP by events row: %+v", overview.TopIPsByEvents[0])
}
if len(overview.TopIPsByTraffic) < 2 {
t.Fatalf("expected at least 2 top IP rows by traffic, got %+v", overview.TopIPsByTraffic)
}
if overview.TopIPsByTraffic[0].IP != "203.0.113.20" || overview.TopIPsByTraffic[0].TrafficBytes != 4096 {
t.Fatalf("unexpected top IP by traffic row: %+v", overview.TopIPsByTraffic[0])
}
if len(overview.TopSources) < 2 || overview.TopSources[0].SourceName != "public-web" || overview.TopSources[0].Events != 2 {
t.Fatalf("unexpected top source rows: %+v", overview.TopSources)
}
if len(overview.TopURLs) == 0 || overview.TopURLs[0].URI != "/wp-login.php" || overview.TopURLs[0].Events != 2 {
t.Fatalf("unexpected top url rows: %+v", overview.TopURLs)
}
}

View File

@@ -17,7 +17,7 @@ import (
) )
type App interface { 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) ListEvents(ctx context.Context, limit int) ([]model.Event, error)
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, 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 return
} }
limit := queryLimit(r, 50) 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 { if err != nil {
writeError(w, http.StatusInternalServerError, err) writeError(w, http.StatusInternalServerError, err)
return return
@@ -355,6 +360,15 @@ const overviewHTML = `<!doctype html>
.muted { color: #94a3b8; } .muted { color: #94a3b8; }
.mono { font-family: ui-monospace, monospace; } .mono { font-family: ui-monospace, monospace; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; } .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 { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
.toolbar .meta { font-size: .95rem; color: #94a3b8; } .toolbar .meta { font-size: .95rem; color: #94a3b8; }
.toolbar-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; justify-content: flex-end; } .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> </header>
<main> <main>
<section class="stats" id="stats"></section> <section class="stats" id="stats"></section>
<section class="leaders" id="leaderboards"></section>
<section class="panel"> <section class="panel">
<div class="toolbar"> <div class="toolbar">
<h2>Recent IPs</h2> <h2>Recent IPs</h2>
@@ -460,6 +475,22 @@ const overviewHTML = `<!doctype html>
].join('')).join(''); ].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) { function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])); 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>'; 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() { function updateSortButtons() {
const botsToggle = document.getElementById('show-bots-toggle'); const botsToggle = document.getElementById('show-bots-toggle');
if (botsToggle) { if (botsToggle) {
@@ -717,6 +837,8 @@ const overviewHTML = `<!doctype html>
showKnownBots = !toggle || toggle.checked; showKnownBots = !toggle || toggle.checked;
saveShowKnownBotsPreference(showKnownBots); saveShowKnownBotsPreference(showKnownBots);
render(); render();
const overviewStats = window.__overviewPayload || {};
renderLeaderboards(overviewStats);
} }
function toggleAllowed() { function toggleAllowed() {
@@ -753,13 +875,15 @@ const overviewHTML = `<!doctype html>
async function refresh() { async function refresh() {
const [overviewResponse, recentResponse] = await Promise.all([ 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') fetch('/api/recent-ips?hours=' + recentHours + '&limit=250')
]); ]);
const overviewPayload = await overviewResponse.json().catch(() => ({})); const overviewPayload = await overviewResponse.json().catch(() => ({}));
const recentPayload = await recentResponse.json().catch(() => []); const recentPayload = await recentResponse.json().catch(() => []);
if (overviewResponse.ok) { if (overviewResponse.ok) {
window.__overviewPayload = overviewPayload || {};
renderStats(overviewPayload || {}); renderStats(overviewPayload || {});
renderLeaderboards(overviewPayload || {});
} }
if (!recentResponse.ok) { if (!recentResponse.ok) {
const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText); const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText);

View File

@@ -33,7 +33,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
} }
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/api/overview?limit=10", nil) request = httptest.NewRequest(http.MethodGet, "/api/overview?hours=24&limit=10", nil)
handler.ServeHTTP(recorder, request) handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Fatalf("unexpected overview status: %d", recorder.Code) t.Fatalf("unexpected overview status: %d", recorder.Code)
@@ -42,7 +42,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil { if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil {
t.Fatalf("decode overview payload: %v", err) t.Fatalf("decode overview payload: %v", err)
} }
if overview.TotalEvents != 1 || len(overview.RecentIPs) != 1 { if overview.TotalEvents != 1 || len(overview.RecentIPs) != 1 || len(overview.TopIPsByEvents) != 1 {
t.Fatalf("unexpected overview payload: %+v", overview) t.Fatalf("unexpected overview payload: %+v", overview)
} }
@@ -80,6 +80,18 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Show known bots") { if !strings.Contains(recorder.Body.String(), "Show known bots") {
t.Fatalf("overview page should expose the known bots toggle") t.Fatalf("overview page should expose the known bots toggle")
} }
if !strings.Contains(recorder.Body.String(), "Top IPs by events") {
t.Fatalf("overview page should expose the top IPs by events block")
}
if !strings.Contains(recorder.Body.String(), "Top IPs by traffic") {
t.Fatalf("overview page should expose the top IPs by traffic block")
}
if !strings.Contains(recorder.Body.String(), "Top sources by events") {
t.Fatalf("overview page should expose the top sources block")
}
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(), "Show allowed") { if !strings.Contains(recorder.Body.String(), "Show allowed") {
t.Fatalf("overview page should expose the allowed toggle") t.Fatalf("overview page should expose the allowed toggle")
} }
@@ -121,12 +133,37 @@ type stubApp struct {
lastAction string lastAction string
} }
func (s *stubApp) GetOverview(context.Context, int) (model.Overview, error) { func (s *stubApp) GetOverview(context.Context, time.Time, int) (model.Overview, error) {
now := time.Now().UTC() now := time.Now().UTC()
return model.Overview{ return model.Overview{
TotalEvents: 1, TotalEvents: 1,
TotalIPs: 1, TotalIPs: 1,
BlockedIPs: 1, BlockedIPs: 1,
TopIPsByEvents: []model.TopIPRow{{
IP: "203.0.113.10",
Events: 3,
TrafficBytes: 4096,
LastSeenAt: now,
}},
TopIPsByTraffic: []model.TopIPRow{{
IP: "203.0.113.10",
Events: 3,
TrafficBytes: 4096,
LastSeenAt: now,
}},
TopSources: []model.TopSourceRow{{
SourceName: "main",
Events: 3,
TrafficBytes: 4096,
LastSeenAt: now,
}},
TopURLs: []model.TopURLRow{{
Host: "example.test",
URI: "/wp-login.php",
Events: 3,
TrafficBytes: 4096,
LastSeenAt: now,
}},
RecentIPs: []model.IPState{{ RecentIPs: []model.IPState{{
IP: "203.0.113.10", IP: "203.0.113.10",
State: model.IPStateBlocked, State: model.IPStateBlocked,
@@ -146,12 +183,12 @@ func (s *stubApp) GetOverview(context.Context, int) (model.Overview, error) {
} }
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) { func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
overview, _ := s.GetOverview(ctx, limit) overview, _ := s.GetOverview(ctx, time.Time{}, limit)
return overview.RecentEvents, nil return overview.RecentEvents, nil
} }
func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) { func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
overview, _ := s.GetOverview(ctx, limit) overview, _ := s.GetOverview(ctx, time.Time{}, limit)
return overview.RecentIPs, nil return overview.RecentIPs, nil
} }