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

@@ -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