You've already forked caddy-opnsense-blocker
Add dashboard activity leaderboards
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -203,6 +226,11 @@ type Overview struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user