diff --git a/README.md b/README.md index a645164..64affe1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - 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. - 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. - 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. @@ -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: - `GET /healthz` -- `GET /api/overview` +- `GET /api/overview?hours=24` - `GET /api/events` - `GET /api/ips` - `GET /api/recent-ips?hours=24` diff --git a/docs/api.md b/docs/api.md index 1cfb0ca..7e4f476 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,6 +33,10 @@ Query parameters: - optional - default: `50` - maximum: `1000` +- `hours` + - optional + - default: `24` + - used for the top activity leaderboards returned in the same payload Main response fields: @@ -42,6 +46,11 @@ Main response fields: - `review_ips` - `allowed_ips` - `observed_ips` +- `activity_since` +- `top_ips_by_events` +- `top_ips_by_traffic` +- `top_sources` +- `top_urls` - `recent_ips` - `recent_events` diff --git a/internal/model/types.go b/internal/model/types.go index 0ea0b3f..726a968 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -178,6 +178,29 @@ type RecentIPRow struct { 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 { SourceName string Path string @@ -197,12 +220,17 @@ type IPDetails struct { } type Overview struct { - TotalEvents int64 `json:"total_events"` - TotalIPs int64 `json:"total_ips"` - BlockedIPs int64 `json:"blocked_ips"` - ReviewIPs int64 `json:"review_ips"` - AllowedIPs int64 `json:"allowed_ips"` - ObservedIPs int64 `json:"observed_ips"` - RecentIPs []IPState `json:"recent_ips"` - RecentEvents []Event `json:"recent_events"` + TotalEvents int64 `json:"total_events"` + TotalIPs int64 `json:"total_ips"` + BlockedIPs int64 `json:"blocked_ips"` + ReviewIPs int64 `json:"review_ips"` + AllowedIPs int64 `json:"allowed_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"` + RecentEvents []Event `json:"recent_events"` } diff --git a/internal/service/service.go b/internal/service/service.go index 75b2a8f..8f953d0 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -91,8 +91,15 @@ func (s *Service) Run(ctx context.Context) error { return nil } -func (s *Service) GetOverview(ctx context.Context, limit int) (model.Overview, error) { - return s.store.GetOverview(ctx, limit) +func (s *Service) GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error) { + 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) { @@ -665,3 +672,37 @@ func recentRowIPs(items []model.RecentIPRow) []string { } 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 +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 312174e..ac31955 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -97,10 +97,10 @@ sources: appendLine(t, giteaLogPath, caddyJSONLine("203.0.113.11", "198.51.100.11", "git.example.test", "POST", "/user/login", 401, "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 { - overview, err := database.GetOverview(context.Background(), 10) - return err == nil && overview.TotalEvents == 3 - }) + waitFor(t, 3*time.Second, func() bool { + overview, err := database.GetOverview(context.Background(), time.Now().UTC().Add(-time.Hour), 10) + return err == nil && overview.TotalEvents == 3 + }) blockedState, found, err := database.GetIPState(context.Background(), "203.0.113.10") if err != nil || !found { diff --git a/internal/store/store.go b/internal/store/store.go index 09e9d56..c61008c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -223,6 +223,8 @@ func (s *Store) RecordEvent(ctx context.Context, event *model.Event) error { 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 { if decision == nil { return errors.New("nil decision record") @@ -370,11 +372,14 @@ func (s *Store) ClearManualOverride(ctx context.Context, ip string, reason strin 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 { limit = 50 } 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 { 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 { 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.RecentEvents = recentEvents + overview.TopIPsByEvents = topIPsByEvents + overview.TopIPsByTraffic = topIPsByTraffic + overview.TopSources = topSources + overview.TopURLs = topURLs 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) { if limit <= 0 { limit = 50 diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 0ba286a..909477c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -99,13 +99,22 @@ func TestStoreRecordsEventsAndState(t *testing.T) { 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 { t.Fatalf("get overview: %v", err) } if overview.TotalEvents != 1 || overview.TotalIPs != 1 { 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) if err != nil { 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) } } + +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) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 79c74bf..b2ce335 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -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 = ` .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 = `
+

Recent IPs

@@ -460,6 +475,22 @@ const overviewHTML = ` ].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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])); } @@ -606,6 +637,95 @@ const overviewHTML = ` return '' + escapeHtml(visual.short) + ''; } + function renderTopIPs(items, primaryMetric) { + const filteredItems = (Array.isArray(items) ? items : []).filter(item => showKnownBots || !item.bot); + if (filteredItems.length === 0) { + return '
No matching IP activity in the selected window.
'; + } + return '
    ' + 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 [ + '
  1. ', + '
    ', + '
    ' + renderBotChip(item.bot) + '' + escapeHtml(item.ip) + '
    ', + ' ' + escapeHtml(primaryValue) + '', + '
    ', + '
    ' + escapeHtml(secondaryValue) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '
    ', + '
  2. ' + ].join(''); + }).join('') + '
'; + } + + function renderTopSources(items) { + if (!Array.isArray(items) || items.length === 0) { + return '
No source activity in the selected window.
'; + } + return '
    ' + items.map(item => [ + '
  1. ', + '
    ', + ' ' + escapeHtml(item.source_name || '—') + '', + ' ' + escapeHtml(String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')) + '', + '
    ', + '
    ' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '
    ', + '
  2. ' + ].join('')).join('') + '
'; + } + + function renderTopURLs(items) { + if (!Array.isArray(items) || items.length === 0) { + return '
No URL activity in the selected window.
'; + } + return '
    ' + items.map(item => { + const label = ((item.host || '') ? (item.host + item.uri) : (item.uri || '—')); + return [ + '
  1. ', + '
    ', + ' ' + escapeHtml(label) + '', + ' ' + escapeHtml(String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')) + '', + '
    ', + '
    ' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '
    ', + '
  2. ' + ].join(''); + }).join('') + '
'; + } + + 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 => [ + '
', + '

' + escapeHtml(card.title) + '

', + '
' + escapeHtml(card.subtitle) + '
', + card.body, + '
' + ].join('')).join(''); + } + function updateSortButtons() { const botsToggle = document.getElementById('show-bots-toggle'); if (botsToggle) { @@ -717,6 +837,8 @@ const overviewHTML = ` showKnownBots = !toggle || toggle.checked; saveShowKnownBotsPreference(showKnownBots); render(); + const overviewStats = window.__overviewPayload || {}; + renderLeaderboards(overviewStats); } function toggleAllowed() { @@ -753,13 +875,15 @@ const overviewHTML = ` 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); diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index a81dd18..4ad75da 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -33,7 +33,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) { } 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) if recorder.Code != http.StatusOK { 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 { 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) } @@ -80,6 +80,18 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) { if !strings.Contains(recorder.Body.String(), "Show known bots") { 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") { t.Fatalf("overview page should expose the allowed toggle") } @@ -121,12 +133,37 @@ type stubApp struct { 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() return model.Overview{ TotalEvents: 1, TotalIPs: 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{{ IP: "203.0.113.10", 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) { - overview, _ := s.GetOverview(ctx, limit) + overview, _ := s.GetOverview(ctx, time.Time{}, limit) return overview.RecentEvents, nil } 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 }