2

Build a Pi-hole-style dashboard and query log

This commit is contained in:
2026-03-12 17:12:19 +01:00
parent b7943e69db
commit 0a14dd1df9
5 changed files with 1012 additions and 450 deletions

View File

@@ -56,6 +56,16 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
t.Fatalf("overview filter options were not forwarded correctly: %+v", app.lastOverviewOptions)
}
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/api/events?hours=24&limit=250&show_known_bots=false&show_allowed=false&review_only=true", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected filtered events status: %d", recorder.Code)
}
if app.lastEventOptions.ShowKnownBots || app.lastEventOptions.ShowAllowed || !app.lastEventOptions.ReviewOnly {
t.Fatalf("event filter options were not forwarded correctly: %+v", app.lastEventOptions)
}
recorder = httptest.NewRecorder()
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")
@@ -84,12 +94,21 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Local-only review and enforcement console") {
t.Fatalf("overview page did not render expected content")
}
if strings.Contains(recorder.Body.String(), "Recent events") {
t.Fatalf("overview page should no longer render recent events block")
}
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(), "Query Log") {
t.Fatalf("overview page should link to the query log")
}
if !strings.Contains(recorder.Body.String(), "Activity") {
t.Fatalf("overview page should expose the activity chart")
}
if !strings.Contains(recorder.Body.String(), "Methods") {
t.Fatalf("overview page should expose the methods chart")
}
if !strings.Contains(recorder.Body.String(), "Bots") {
t.Fatalf("overview page should expose the bots chart")
}
if !strings.Contains(recorder.Body.String(), "Top IPs by events") {
t.Fatalf("overview page should expose the top IPs by events block")
}
@@ -105,7 +124,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Loading…") {
t.Fatalf("overview page should render stable loading placeholders")
}
if !strings.Contains(recorder.Body.String(), "These two filters affect both the leaderboards and the Recent IPs list") {
if !strings.Contains(recorder.Body.String(), "These filters affect all dashboard charts and top lists") {
t.Fatalf("overview page should explain the scope of the shared filters")
}
if strings.Contains(recorder.Body.String(), "position: sticky") {
@@ -114,13 +133,30 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Show allowed") {
t.Fatalf("overview page should expose the allowed toggle")
}
if !strings.Contains(recorder.Body.String(), "Review only") {
t.Fatalf("overview page should expose the review-only toggle")
if strings.Contains(recorder.Body.String(), "Review only") {
t.Fatalf("overview page should not expose the review-only toggle anymore")
}
if !strings.Contains(recorder.Body.String(), "localStorage") {
t.Fatalf("overview page should persist preferences in localStorage")
}
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/queries", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected query log page status: %d", recorder.Code)
}
queryLogBody := recorder.Body.String()
if !strings.Contains(queryLogBody, "Review only") {
t.Fatalf("query log page should expose the review-only toggle")
}
if !strings.Contains(queryLogBody, "These filters affect the full Query Log") {
t.Fatalf("query log page should explain its filters")
}
if !strings.Contains(queryLogBody, "Request") {
t.Fatalf("query log page should render the request table")
}
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/ips/203.0.113.10", nil)
handler.ServeHTTP(recorder, request)
@@ -154,6 +190,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
type stubApp struct {
lastAction string
lastOverviewOptions model.OverviewOptions
lastEventOptions model.EventListOptions
}
func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options model.OverviewOptions) (model.Overview, error) {
@@ -163,17 +200,29 @@ func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options mod
TotalEvents: 1,
TotalIPs: 1,
BlockedIPs: 1,
ActivityBuckets: []model.ActivityBucket{{
BucketStart: now.Add(-10 * time.Minute),
TotalEvents: 1,
Sources: []model.ActivitySourceCount{{
SourceName: "main",
Events: 1,
}},
}},
Methods: []model.MethodBreakdownRow{{Method: "GET", Events: 1}},
Bots: []model.BotBreakdownRow{{Key: "non_bot", Label: "Non-bot", Events: 1}},
TopIPsByEvents: []model.TopIPRow{{
IP: "203.0.113.10",
Events: 3,
TrafficBytes: 4096,
LastSeenAt: now,
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
}},
TopIPsByTraffic: []model.TopIPRow{{
IP: "203.0.113.10",
Events: 3,
TrafficBytes: 4096,
LastSeenAt: now,
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
}},
TopSources: []model.TopSourceRow{{
SourceName: "main",
@@ -196,17 +245,25 @@ func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options mod
LastSeenAt: now,
}},
RecentEvents: []model.Event{{
ID: 1,
SourceName: "main",
ClientIP: "203.0.113.10",
OccurredAt: now,
Decision: model.DecisionActionBlock,
CurrentState: model.IPStateBlocked,
ID: 1,
SourceName: "main",
ClientIP: "203.0.113.10",
OccurredAt: now,
Method: http.MethodGet,
URI: "/wp-login.php",
Host: "example.test",
Status: http.StatusNotFound,
Decision: model.DecisionActionBlock,
CurrentState: model.IPStateBlocked,
DecisionReason: "php_path",
Actions: model.ActionAvailability{CanUnblock: true},
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
}},
}, nil
}
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
func (s *stubApp) ListEvents(ctx context.Context, _ time.Time, limit int, options model.EventListOptions) ([]model.Event, error) {
s.lastEventOptions = options
overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
return overview.RecentEvents, nil
}