You've already forked caddy-opnsense-blocker
Refine dashboard leaderboard filters and layout
This commit is contained in:
@@ -37,6 +37,14 @@ Query parameters:
|
|||||||
- optional
|
- optional
|
||||||
- default: `24`
|
- default: `24`
|
||||||
- used for the top activity leaderboards returned in the same payload
|
- used for the top activity leaderboards returned in the same payload
|
||||||
|
- `show_known_bots`
|
||||||
|
- optional
|
||||||
|
- default: `true`
|
||||||
|
- when `false`, the leaderboards exclude IPs currently identified as known bots
|
||||||
|
- `show_allowed`
|
||||||
|
- optional
|
||||||
|
- default: `true`
|
||||||
|
- when `false`, the leaderboards exclude IPs whose current state is `allowed`
|
||||||
|
|
||||||
Main response fields:
|
Main response fields:
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ type TopURLRow struct {
|
|||||||
LastSeenAt time.Time `json:"last_seen_at"`
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OverviewOptions struct {
|
||||||
|
ShowKnownBots bool
|
||||||
|
ShowAllowed bool
|
||||||
|
}
|
||||||
|
|
||||||
type SourceOffset struct {
|
type SourceOffset struct {
|
||||||
SourceName string
|
SourceName string
|
||||||
Path string
|
Path string
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error) {
|
func (s *Service) GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error) {
|
||||||
overview, err := s.store.GetOverview(ctx, since, limit)
|
overview, err := s.store.GetOverview(ctx, since, limit, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Overview{}, err
|
return model.Overview{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(), time.Now().UTC().Add(-time.Hour), 10)
|
overview, err := database.GetOverview(context.Background(), time.Now().UTC().Add(-time.Hour), 10, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
||||||
return err == nil && overview.TotalEvents == 3
|
return err == nil && overview.TotalEvents == 3
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,23 @@ func (s *Store) RecordEvent(ctx context.Context, event *model.Event) error {
|
|||||||
|
|
||||||
const responseBytesExpression = `CASE WHEN json_valid(e.raw_json) THEN CAST(COALESCE(json_extract(e.raw_json, '$.size'), 0) AS INTEGER) ELSE 0 END`
|
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 overviewFilterQueryParts(options model.OverviewOptions) (joins []string, clauses []string) {
|
||||||
|
if !options.ShowAllowed {
|
||||||
|
joins = append(joins, `LEFT JOIN ip_state s ON s.ip = e.client_ip`)
|
||||||
|
clauses = append(clauses, `COALESCE(s.state, '') <> '`+string(model.IPStateAllowed)+`'`)
|
||||||
|
}
|
||||||
|
if !options.ShowKnownBots {
|
||||||
|
clauses = append(clauses, `NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ip_investigations i
|
||||||
|
WHERE i.ip = e.client_ip
|
||||||
|
AND json_valid(i.payload_json)
|
||||||
|
AND json_type(i.payload_json, '$.bot') IS NOT NULL
|
||||||
|
)`)
|
||||||
|
}
|
||||||
|
return joins, clauses
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
@@ -372,7 +389,7 @@ func (s *Store) ClearManualOverride(ctx context.Context, ip string, reason strin
|
|||||||
return current, nil
|
return current, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error) {
|
func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
@@ -407,19 +424,19 @@ func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int) (mo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Overview{}, err
|
return model.Overview{}, err
|
||||||
}
|
}
|
||||||
topIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events")
|
topIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events", options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Overview{}, err
|
return model.Overview{}, err
|
||||||
}
|
}
|
||||||
topIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic")
|
topIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic", options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Overview{}, err
|
return model.Overview{}, err
|
||||||
}
|
}
|
||||||
topSources, err := s.listTopSourceRows(ctx, since, limit)
|
topSources, err := s.listTopSourceRows(ctx, since, limit, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Overview{}, err
|
return model.Overview{}, err
|
||||||
}
|
}
|
||||||
topURLs, err := s.listTopURLRows(ctx, since, limit)
|
topURLs, err := s.listTopURLRows(ctx, since, limit, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.Overview{}, err
|
return model.Overview{}, err
|
||||||
}
|
}
|
||||||
@@ -432,21 +449,28 @@ func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int) (mo
|
|||||||
return overview, nil
|
return overview, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) listTopIPRows(ctx context.Context, since time.Time, limit int, orderBy string) ([]model.TopIPRow, error) {
|
func (s *Store) listTopIPRows(ctx context.Context, since time.Time, limit int, orderBy string, options model.OverviewOptions) ([]model.TopIPRow, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
|
joins, clauses := overviewFilterQueryParts(options)
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT e.client_ip,
|
SELECT e.client_ip,
|
||||||
COUNT(*) AS event_count,
|
COUNT(*) AS event_count,
|
||||||
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
||||||
MAX(e.occurred_at) AS last_seen_at
|
MAX(e.occurred_at) AS last_seen_at
|
||||||
FROM events e`, responseBytesExpression)
|
FROM events e`, responseBytesExpression)
|
||||||
|
if len(joins) > 0 {
|
||||||
|
query += ` ` + strings.Join(joins, ` `)
|
||||||
|
}
|
||||||
args := make([]any, 0, 2)
|
args := make([]any, 0, 2)
|
||||||
if !since.IsZero() {
|
if !since.IsZero() {
|
||||||
query += ` WHERE e.occurred_at >= ?`
|
clauses = append([]string{`e.occurred_at >= ?`}, clauses...)
|
||||||
args = append(args, formatTime(since))
|
args = append(args, formatTime(since))
|
||||||
}
|
}
|
||||||
|
if len(clauses) > 0 {
|
||||||
|
query += ` WHERE ` + strings.Join(clauses, ` AND `)
|
||||||
|
}
|
||||||
query += ` GROUP BY e.client_ip`
|
query += ` GROUP BY e.client_ip`
|
||||||
switch orderBy {
|
switch orderBy {
|
||||||
case "traffic":
|
case "traffic":
|
||||||
@@ -483,21 +507,28 @@ func (s *Store) listTopIPRows(ctx context.Context, since time.Time, limit int, o
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) listTopSourceRows(ctx context.Context, since time.Time, limit int) ([]model.TopSourceRow, error) {
|
func (s *Store) listTopSourceRows(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) ([]model.TopSourceRow, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
|
joins, clauses := overviewFilterQueryParts(options)
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT e.source_name,
|
SELECT e.source_name,
|
||||||
COUNT(*) AS event_count,
|
COUNT(*) AS event_count,
|
||||||
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
||||||
MAX(e.occurred_at) AS last_seen_at
|
MAX(e.occurred_at) AS last_seen_at
|
||||||
FROM events e`, responseBytesExpression)
|
FROM events e`, responseBytesExpression)
|
||||||
|
if len(joins) > 0 {
|
||||||
|
query += ` ` + strings.Join(joins, ` `)
|
||||||
|
}
|
||||||
args := make([]any, 0, 2)
|
args := make([]any, 0, 2)
|
||||||
if !since.IsZero() {
|
if !since.IsZero() {
|
||||||
query += ` WHERE e.occurred_at >= ?`
|
clauses = append([]string{`e.occurred_at >= ?`}, clauses...)
|
||||||
args = append(args, formatTime(since))
|
args = append(args, formatTime(since))
|
||||||
}
|
}
|
||||||
|
if len(clauses) > 0 {
|
||||||
|
query += ` WHERE ` + strings.Join(clauses, ` AND `)
|
||||||
|
}
|
||||||
query += ` GROUP BY e.source_name ORDER BY event_count DESC, traffic_bytes DESC, last_seen_at DESC, e.source_name ASC LIMIT ?`
|
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)
|
args = append(args, limit)
|
||||||
|
|
||||||
@@ -527,10 +558,11 @@ func (s *Store) listTopSourceRows(ctx context.Context, since time.Time, limit in
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) listTopURLRows(ctx context.Context, since time.Time, limit int) ([]model.TopURLRow, error) {
|
func (s *Store) listTopURLRows(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) ([]model.TopURLRow, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
|
joins, clauses := overviewFilterQueryParts(options)
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT e.host,
|
SELECT e.host,
|
||||||
e.uri,
|
e.uri,
|
||||||
@@ -538,11 +570,17 @@ func (s *Store) listTopURLRows(ctx context.Context, since time.Time, limit int)
|
|||||||
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
||||||
MAX(e.occurred_at) AS last_seen_at
|
MAX(e.occurred_at) AS last_seen_at
|
||||||
FROM events e`, responseBytesExpression)
|
FROM events e`, responseBytesExpression)
|
||||||
|
if len(joins) > 0 {
|
||||||
|
query += ` ` + strings.Join(joins, ` `)
|
||||||
|
}
|
||||||
args := make([]any, 0, 2)
|
args := make([]any, 0, 2)
|
||||||
if !since.IsZero() {
|
if !since.IsZero() {
|
||||||
query += ` WHERE e.occurred_at >= ?`
|
clauses = append([]string{`e.occurred_at >= ?`}, clauses...)
|
||||||
args = append(args, formatTime(since))
|
args = append(args, formatTime(since))
|
||||||
}
|
}
|
||||||
|
if len(clauses) > 0 {
|
||||||
|
query += ` WHERE ` + strings.Join(clauses, ` AND `)
|
||||||
|
}
|
||||||
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 ?`
|
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)
|
args = append(args, limit)
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ 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, occurredAt.Add(-time.Hour), 10)
|
overview, err := db.GetOverview(ctx, occurredAt.Add(-time.Hour), 10, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get overview: %v", err)
|
t.Fatalf("get overview: %v", err)
|
||||||
}
|
}
|
||||||
@@ -242,7 +242,7 @@ func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
overview, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10)
|
overview, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get overview: %v", err)
|
t.Fatalf("get overview: %v", err)
|
||||||
}
|
}
|
||||||
@@ -264,4 +264,32 @@ func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) {
|
|||||||
if len(overview.TopURLs) == 0 || overview.TopURLs[0].URI != "/wp-login.php" || overview.TopURLs[0].Events != 2 {
|
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)
|
t.Fatalf("unexpected top url rows: %+v", overview.TopURLs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := db.SaveInvestigation(ctx, model.IPInvestigation{
|
||||||
|
IP: "203.0.113.10",
|
||||||
|
UpdatedAt: baseTime,
|
||||||
|
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save top bot investigation: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.SetManualOverride(ctx, "203.0.113.20", model.ManualOverrideForceAllow, model.IPStateAllowed, "manual allow"); err != nil {
|
||||||
|
t.Fatalf("set manual override for filter test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10, model.OverviewOptions{ShowKnownBots: false, ShowAllowed: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get filtered overview: %v", err)
|
||||||
|
}
|
||||||
|
if len(filtered.TopIPsByEvents) != 0 {
|
||||||
|
t.Fatalf("expected filtered top IPs by events to be empty, got %+v", filtered.TopIPsByEvents)
|
||||||
|
}
|
||||||
|
if len(filtered.TopIPsByTraffic) != 0 {
|
||||||
|
t.Fatalf("expected filtered top IPs by traffic to be empty, got %+v", filtered.TopIPsByTraffic)
|
||||||
|
}
|
||||||
|
if len(filtered.TopSources) != 0 {
|
||||||
|
t.Fatalf("expected filtered top sources to be empty, got %+v", filtered.TopSources)
|
||||||
|
}
|
||||||
|
if len(filtered.TopURLs) != 0 {
|
||||||
|
t.Fatalf("expected filtered top urls to be empty, got %+v", filtered.TopURLs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App interface {
|
type App interface {
|
||||||
GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error)
|
GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (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)
|
||||||
@@ -107,7 +107,11 @@ func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
hours = 24
|
hours = 24
|
||||||
}
|
}
|
||||||
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||||
overview, err := h.app.GetOverview(r.Context(), since, limit)
|
options := model.OverviewOptions{
|
||||||
|
ShowKnownBots: queryBool(r, "show_known_bots", true),
|
||||||
|
ShowAllowed: queryBool(r, "show_allowed", true),
|
||||||
|
}
|
||||||
|
overview, err := h.app.GetOverview(r.Context(), since, limit, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err)
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
@@ -277,6 +281,21 @@ func queryInt(r *http.Request, name string, fallback int) int {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryBool(r *http.Request, name string, fallback bool) bool {
|
||||||
|
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(name)))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -360,7 +379,9 @@ 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; }
|
.controls { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.controls-group { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
.leaders { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||||
.leader-card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; }
|
.leader-card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; }
|
||||||
.leader-card h2 { margin-bottom: .35rem; font-size: 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-list { list-style: none; margin: .75rem 0 0 0; padding: 0; display: grid; gap: .65rem; }
|
||||||
@@ -368,7 +389,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||||
.leader-main .mono { overflow: hidden; text-overflow: ellipsis; }
|
.leader-main .mono { overflow: hidden; text-overflow: ellipsis; }
|
||||||
.leader-value { font-weight: 600; white-space: nowrap; }
|
.leader-value { font-weight: 600; white-space: nowrap; }
|
||||||
.leader-sub { font-size: .87rem; color: #94a3b8; }
|
@media (max-width: 1100px) { .leaders { grid-template-columns: 1fr; } }
|
||||||
.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; }
|
||||||
@@ -407,13 +428,18 @@ const overviewHTML = `<!doctype html>
|
|||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section class="stats" id="stats"></section>
|
<section class="stats" id="stats"></section>
|
||||||
|
<section class="panel controls">
|
||||||
|
<div class="controls-group">
|
||||||
|
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
|
||||||
|
<label class="toggle"><input id="show-allowed-toggle" type="checkbox" checked onchange="toggleAllowed()">Show allowed</label>
|
||||||
|
</div>
|
||||||
|
<div class="muted">These two filters affect both the leaderboards and the Recent IPs list.</div>
|
||||||
|
</section>
|
||||||
<section class="leaders" id="leaderboards"></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>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
|
|
||||||
<label class="toggle"><input id="show-allowed-toggle" type="checkbox" checked onchange="toggleAllowed()">Show allowed</label>
|
|
||||||
<label class="toggle"><input id="show-review-toggle" type="checkbox" onchange="toggleReviewOnly()">Review only</label>
|
<label class="toggle"><input id="show-review-toggle" type="checkbox" onchange="toggleReviewOnly()">Review only</label>
|
||||||
<div class="meta">Last 24 hours · click a column to sort</div>
|
<div class="meta">Last 24 hours · click a column to sort</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -638,24 +664,20 @@ const overviewHTML = `<!doctype html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTopIPs(items, primaryMetric) {
|
function renderTopIPs(items, primaryMetric) {
|
||||||
const filteredItems = (Array.isArray(items) ? items : []).filter(item => showKnownBots || !item.bot);
|
const visibleItems = Array.isArray(items) ? items : [];
|
||||||
if (filteredItems.length === 0) {
|
if (visibleItems.length === 0) {
|
||||||
return '<div class="muted">No matching IP activity in the selected window.</div>';
|
return '<div class="muted">No matching IP activity in the selected window.</div>';
|
||||||
}
|
}
|
||||||
return '<ol class="leader-list">' + filteredItems.map(item => {
|
return '<ol class="leader-list">' + visibleItems.map(item => {
|
||||||
const primaryValue = primaryMetric === 'traffic'
|
const primaryValue = primaryMetric === 'traffic'
|
||||||
? formatBytes(item.traffic_bytes)
|
? formatBytes(item.traffic_bytes)
|
||||||
: String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's');
|
: String(item.events || 0);
|
||||||
const secondaryValue = primaryMetric === 'traffic'
|
|
||||||
? String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')
|
|
||||||
: formatBytes(item.traffic_bytes);
|
|
||||||
return [
|
return [
|
||||||
'<li class="leader-item">',
|
'<li class="leader-item">',
|
||||||
' <div class="leader-main">',
|
' <div class="leader-main">',
|
||||||
' <div class="ip-cell mono">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></div>',
|
' <a class="mono" href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a>',
|
||||||
' <span class="leader-value">' + escapeHtml(primaryValue) + '</span>',
|
' <span class="leader-value">' + escapeHtml(primaryValue) + '</span>',
|
||||||
' </div>',
|
' </div>',
|
||||||
' <div class="leader-sub">' + escapeHtml(secondaryValue) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
|
|
||||||
'</li>'
|
'</li>'
|
||||||
].join('');
|
].join('');
|
||||||
}).join('') + '</ol>';
|
}).join('') + '</ol>';
|
||||||
@@ -669,9 +691,8 @@ const overviewHTML = `<!doctype html>
|
|||||||
'<li class="leader-item">',
|
'<li class="leader-item">',
|
||||||
' <div class="leader-main">',
|
' <div class="leader-main">',
|
||||||
' <span class="mono">' + escapeHtml(item.source_name || '—') + '</span>',
|
' <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>',
|
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
|
||||||
' </div>',
|
' </div>',
|
||||||
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
|
|
||||||
'</li>'
|
'</li>'
|
||||||
].join('')).join('') + '</ol>';
|
].join('')).join('') + '</ol>';
|
||||||
}
|
}
|
||||||
@@ -686,9 +707,8 @@ const overviewHTML = `<!doctype html>
|
|||||||
'<li class="leader-item">',
|
'<li class="leader-item">',
|
||||||
' <div class="leader-main">',
|
' <div class="leader-main">',
|
||||||
' <span class="mono">' + escapeHtml(label) + '</span>',
|
' <span class="mono">' + escapeHtml(label) + '</span>',
|
||||||
' <span class="leader-value">' + escapeHtml(String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')) + '</span>',
|
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
|
||||||
' </div>',
|
' </div>',
|
||||||
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
|
|
||||||
'</li>'
|
'</li>'
|
||||||
].join('');
|
].join('');
|
||||||
}).join('') + '</ol>';
|
}).join('') + '</ol>';
|
||||||
@@ -837,8 +857,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
showKnownBots = !toggle || toggle.checked;
|
showKnownBots = !toggle || toggle.checked;
|
||||||
saveShowKnownBotsPreference(showKnownBots);
|
saveShowKnownBotsPreference(showKnownBots);
|
||||||
render();
|
render();
|
||||||
const overviewStats = window.__overviewPayload || {};
|
refreshOverview();
|
||||||
renderLeaderboards(overviewStats);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAllowed() {
|
function toggleAllowed() {
|
||||||
@@ -846,6 +865,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
showAllowed = !toggle || toggle.checked;
|
showAllowed = !toggle || toggle.checked;
|
||||||
saveShowAllowedPreference(showAllowed);
|
saveShowAllowedPreference(showAllowed);
|
||||||
render();
|
render();
|
||||||
|
refreshOverview();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleReviewOnly() {
|
function toggleReviewOnly() {
|
||||||
@@ -873,18 +893,21 @@ const overviewHTML = `<!doctype html>
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refreshOverview() {
|
||||||
const [overviewResponse, recentResponse] = await Promise.all([
|
const response = await fetch('/api/overview?hours=' + recentHours + '&limit=10&show_known_bots=' + (showKnownBots ? 'true' : 'false') + '&show_allowed=' + (showAllowed ? 'true' : 'false'));
|
||||||
fetch('/api/overview?hours=' + recentHours + '&limit=10'),
|
const payload = await response.json().catch(() => ({}));
|
||||||
fetch('/api/recent-ips?hours=' + recentHours + '&limit=250')
|
if (!response.ok) {
|
||||||
]);
|
return;
|
||||||
const overviewPayload = await overviewResponse.json().catch(() => ({}));
|
|
||||||
const recentPayload = await recentResponse.json().catch(() => []);
|
|
||||||
if (overviewResponse.ok) {
|
|
||||||
window.__overviewPayload = overviewPayload || {};
|
|
||||||
renderStats(overviewPayload || {});
|
|
||||||
renderLeaderboards(overviewPayload || {});
|
|
||||||
}
|
}
|
||||||
|
window.__overviewPayload = payload || {};
|
||||||
|
renderStats(payload || {});
|
||||||
|
renderLeaderboards(payload || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const recentResponse = await fetch('/api/recent-ips?hours=' + recentHours + '&limit=250');
|
||||||
|
const recentPayload = await recentResponse.json().catch(() => []);
|
||||||
|
refreshOverview();
|
||||||
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);
|
||||||
document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">' + escapeHtml(message) + '</td></tr>';
|
document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">' + escapeHtml(message) + '</td></tr>';
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
t.Fatalf("unexpected overview payload: %+v", overview)
|
t.Fatalf("unexpected overview payload: %+v", overview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/api/overview?hours=24&limit=10&show_known_bots=false&show_allowed=false", nil)
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected filtered overview status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if app.lastOverviewOptions.ShowKnownBots || app.lastOverviewOptions.ShowAllowed {
|
||||||
|
t.Fatalf("overview filter options were not forwarded correctly: %+v", app.lastOverviewOptions)
|
||||||
|
}
|
||||||
|
|
||||||
recorder = httptest.NewRecorder()
|
recorder = httptest.NewRecorder()
|
||||||
request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/block", strings.NewReader(`{"reason":"test reason","actor":"tester"}`))
|
request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/block", strings.NewReader(`{"reason":"test reason","actor":"tester"}`))
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
@@ -92,6 +102,9 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
|
if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
|
||||||
t.Fatalf("overview page should expose the top URLs block")
|
t.Fatalf("overview page should expose the top URLs block")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "These two filters affect both the leaderboards and the Recent IPs list") {
|
||||||
|
t.Fatalf("overview page should explain the scope of the shared filters")
|
||||||
|
}
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -131,9 +144,11 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
|
|
||||||
type stubApp struct {
|
type stubApp struct {
|
||||||
lastAction string
|
lastAction string
|
||||||
|
lastOverviewOptions model.OverviewOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubApp) GetOverview(context.Context, time.Time, int) (model.Overview, error) {
|
func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options model.OverviewOptions) (model.Overview, error) {
|
||||||
|
s.lastOverviewOptions = options
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
return model.Overview{
|
return model.Overview{
|
||||||
TotalEvents: 1,
|
TotalEvents: 1,
|
||||||
@@ -183,12 +198,12 @@ func (s *stubApp) GetOverview(context.Context, time.Time, int) (model.Overview,
|
|||||||
}
|
}
|
||||||
|
|
||||||
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, time.Time{}, limit)
|
overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
||||||
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, time.Time{}, limit)
|
overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
||||||
return overview.RecentIPs, nil
|
return overview.RecentIPs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user