diff --git a/README.md b/README.md index 8b3c531..7a624ad 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 a Pi-hole-style dashboard, source-colored activity charts, split bot/non-bot leaderboards, a paginated requests log, IP detail pages, decision history, and full request history per address. +- Lightweight web UI with a Pi-hole-style dashboard, source-colored activity charts, split bot/non-bot leaderboards, a paginated requests log with collapsible filters and clickable column sorting, 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. diff --git a/docs/api.md b/docs/api.md index 948e7bd..8495f57 100644 --- a/docs/api.md +++ b/docs/api.md @@ -72,18 +72,44 @@ Query parameters: - optional - default: `1` - one-based page number +- `source` + - optional + - exact source name match, case-insensitive +- `method` + - optional + - exact HTTP method match, case-insensitive + - use `OTHER` to match methods outside the common set (`GET`, `POST`, `HEAD`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`) +- `status` + - optional + - accepts a full HTTP status code such as `404` + - also accepts a class selector such as `4xx` or `5xx` +- `state` + - optional + - one of `observed`, `review`, `blocked`, `allowed` +- `bot_filter` + - optional + - default: `all` + - accepted values: `all`, `known`, `possible`, `any`, `non-bot` +- `sort_by` + - optional + - default: `time` + - accepted values: `time`, `ip`, `method`, `source`, `request`, `status`, `state`, `reason` +- `sort_dir` + - optional + - default: `desc` + - accepted values: `asc`, `desc` - `show_known_bots` - optional - default: `true` - - when `false`, verified bots are hidden from the log + - compatibility filter that hides verified bots when `false` - `show_allowed` - optional - default: `true` - - when `false`, rows whose current IP state is `allowed` are hidden + - compatibility filter that hides rows whose current IP state is `allowed` when `false` - `review_only` - optional - default: `false` - - when `true`, only requests whose current IP state is `review` are returned + - compatibility filter that maps to `state=review` when `state` is not already set Main response fields: diff --git a/internal/model/types.go b/internal/model/types.go index f9376ff..2e0bde2 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -235,6 +235,13 @@ type EventListOptions struct { ShowAllowed bool ReviewOnly bool Offset int + Source string + Method string + StatusFilter string + State string + BotFilter string + SortBy string + SortDesc bool } type SourceOffset struct { diff --git a/internal/store/store.go b/internal/store/store.go index cba789e..874e2e6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" @@ -257,18 +258,123 @@ func overviewFilterQueryParts(options model.OverviewOptions) (joins []string, cl return joins, clauses } -func eventFilterQueryParts(options model.EventListOptions) (joins []string, clauses []string) { +func normalizedEventStateExpression() string { + return `CASE WHEN COALESCE(s.state, '') = '' THEN '` + string(model.IPStateObserved) + `' ELSE s.state END` +} + +func parseStatusFilter(filter string) (clauses []string, args []any, err error) { + normalized := strings.ToLower(strings.TrimSpace(filter)) + if normalized == "" { + return nil, nil, nil + } + if len(normalized) == 3 && strings.HasSuffix(normalized, "xx") && normalized[0] >= '1' && normalized[0] <= '5' { + hundred := int(normalized[0]-'0') * 100 + return []string{`e.status >= ?`, `e.status < ?`}, []any{hundred, hundred + 100}, nil + } + status, convErr := strconv.Atoi(normalized) + if convErr != nil || status < 100 || status > 599 { + return nil, nil, fmt.Errorf("invalid status filter %q", filter) + } + return []string{`e.status = ?`}, []any{status}, nil +} + +func eventOrderClause(options model.EventListOptions) string { + stateExpr := normalizedEventStateExpression() + direction := "ASC" + tieDirection := "ASC" + field := strings.ToLower(strings.TrimSpace(options.SortBy)) + if field == "" { + return `e.occurred_at DESC, e.id DESC` + } + if options.SortDesc { + direction = "DESC" + tieDirection = "DESC" + } + switch field { + case "time", "occurred_at": + return fmt.Sprintf(`e.occurred_at %s, e.id %s`, direction, tieDirection) + case "source": + return fmt.Sprintf(`LOWER(e.source_name) %s, e.occurred_at %s, e.id %s`, direction, tieDirection, tieDirection) + case "ip": + return fmt.Sprintf(`e.client_ip %s, e.occurred_at %s, e.id %s`, direction, tieDirection, tieDirection) + case "method": + return fmt.Sprintf(`UPPER(e.method) %s, e.occurred_at %s, e.id %s`, direction, tieDirection, tieDirection) + case "request", "uri": + return fmt.Sprintf(`e.uri %s, e.host %s, e.occurred_at %s, e.id %s`, direction, direction, tieDirection, tieDirection) + case "status": + return fmt.Sprintf(`e.status %s, e.occurred_at %s, e.id %s`, direction, tieDirection, tieDirection) + case "state": + return fmt.Sprintf(`%s %s, e.occurred_at %s, e.id %s`, stateExpr, direction, tieDirection, tieDirection) + case "reason": + return fmt.Sprintf(`LOWER(e.decision_reason) %s, e.occurred_at %s, e.id %s`, direction, tieDirection, tieDirection) + default: + return `e.occurred_at DESC, e.id DESC` + } +} + +func eventFilterQueryParts(options model.EventListOptions) (joins []string, clauses []string, args []any, err error) { joins = append(joins, `LEFT JOIN ip_state s ON s.ip = e.client_ip`) - if !options.ShowAllowed { - clauses = append(clauses, `COALESCE(s.state, '') <> '`+string(model.IPStateAllowed)+`'`) + stateExpr := normalizedEventStateExpression() + stateFilter := strings.ToLower(strings.TrimSpace(options.State)) + if stateFilter == "all" { + stateFilter = "" } - if options.ReviewOnly { - clauses = append(clauses, `COALESCE(s.state, '') = '`+string(model.IPStateReview)+`'`) + if stateFilter != "" { + switch stateFilter { + case string(model.IPStateObserved), string(model.IPStateReview), string(model.IPStateBlocked), string(model.IPStateAllowed): + clauses = append(clauses, stateExpr+` = ?`) + args = append(args, stateFilter) + default: + return nil, nil, nil, fmt.Errorf("invalid state filter %q", options.State) + } + } else { + if !options.ShowAllowed { + clauses = append(clauses, stateExpr+` <> ?`) + args = append(args, string(model.IPStateAllowed)) + } + if options.ReviewOnly { + clauses = append(clauses, stateExpr+` = ?`) + args = append(args, string(model.IPStateReview)) + } } - if !options.ShowKnownBots { + if source := strings.TrimSpace(options.Source); source != "" { + clauses = append(clauses, `LOWER(e.source_name) = LOWER(?)`) + args = append(args, source) + } + if method := strings.TrimSpace(options.Method); method != "" { + if strings.EqualFold(method, "other") { + clauses = append(clauses, `UPPER(COALESCE(e.method, '')) NOT IN ('GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'OPTIONS')`) + } else { + clauses = append(clauses, `UPPER(COALESCE(e.method, '')) = ?`) + args = append(args, strings.ToUpper(method)) + } + } + statusClauses, statusArgs, statusErr := parseStatusFilter(options.StatusFilter) + if statusErr != nil { + return nil, nil, nil, statusErr + } + clauses = append(clauses, statusClauses...) + args = append(args, statusArgs...) + botFilter := strings.ToLower(strings.TrimSpace(options.BotFilter)) + if botFilter == "" && !options.ShowKnownBots { + botFilter = "no-known" + } + switch botFilter { + case "", "all": + case "known": + clauses = append(clauses, knownBotExistsClause(`e.client_ip`)) + case "possible": + clauses = append(clauses, anyBotExistsClause(`e.client_ip`), `NOT `+knownBotExistsClause(`e.client_ip`)) + case "any": + clauses = append(clauses, anyBotExistsClause(`e.client_ip`)) + case "non-bot": + clauses = append(clauses, `NOT `+anyBotExistsClause(`e.client_ip`)) + case "no-known": clauses = append(clauses, `NOT `+knownBotExistsClause(`e.client_ip`)) + default: + return nil, nil, nil, fmt.Errorf("invalid bot filter %q", options.BotFilter) } - return joins, clauses + return joins, clauses, args, nil } func (s *Store) AddDecision(ctx context.Context, decision *model.DecisionRecord) error { @@ -836,7 +942,10 @@ func (s *Store) ListEvents(ctx context.Context, since time.Time, limit int, opti if options.Offset < 0 { options.Offset = 0 } - joins, clauses := eventFilterQueryParts(options) + joins, clauses, filterArgs, err := eventFilterQueryParts(options) + if err != nil { + return nil, err + } query := ` SELECT e.id, e.source_name, e.profile_name, e.occurred_at, e.remote_ip, e.client_ip, e.host, e.method, e.uri, e.path, e.status, e.user_agent, e.decision, e.decision_reason, @@ -846,15 +955,16 @@ func (s *Store) ListEvents(ctx context.Context, since time.Time, limit int, opti if len(joins) > 0 { query += ` ` + strings.Join(joins, ` `) } - args := make([]any, 0, 2) + args := make([]any, 0, len(filterArgs)+3) if !since.IsZero() { clauses = append([]string{`e.occurred_at >= ?`}, clauses...) args = append(args, formatTime(since)) } + args = append(args, filterArgs...) if len(clauses) > 0 { query += ` WHERE ` + strings.Join(clauses, ` AND `) } - query += ` ORDER BY e.occurred_at DESC, e.id DESC LIMIT ? OFFSET ?` + query += ` ORDER BY ` + eventOrderClause(options) + ` LIMIT ? OFFSET ?` args = append(args, limit, options.Offset) rows, err := s.db.QueryContext(ctx, query, args...) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 2a79aad..0067183 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -330,3 +330,123 @@ func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) { t.Fatalf("expected filtered top urls to be empty, got %+v", filtered.TopURLs) } } + +func TestStoreListEventsSupportsFiltersAndSorting(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, 18, 0, 0, 0, time.UTC) + events := []*model.Event{ + { + SourceName: "main", + ProfileName: "main", + 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.DecisionActionReview, + DecisionReason: "php_path", + DecisionReasons: []string{"php_path"}, + RawJSON: `{"status":404}`, + }, + { + SourceName: "main", + ProfileName: "main", + OccurredAt: baseTime.Add(10 * time.Second), + RemoteIP: "198.51.100.11", + ClientIP: "203.0.113.11", + Host: "example.test", + Method: "GET", + URI: "/xmlrpc.php", + Path: "/xmlrpc.php", + Status: 401, + UserAgent: "curl/8.0", + Decision: model.DecisionActionReview, + DecisionReason: "php_path", + DecisionReasons: []string{"php_path"}, + RawJSON: `{"status":401}`, + }, + { + SourceName: "main", + ProfileName: "main", + OccurredAt: baseTime.Add(20 * time.Second), + RemoteIP: "198.51.100.12", + ClientIP: "203.0.113.20", + Host: "example.test", + Method: "POST", + URI: "/xmlrpc.php", + Path: "/xmlrpc.php", + Status: 403, + UserAgent: "curl/8.0", + Decision: model.DecisionActionReview, + DecisionReason: "unexpected_post", + DecisionReasons: []string{"unexpected_post"}, + RawJSON: `{"status":403}`, + }, + { + SourceName: "gitea", + ProfileName: "gitea", + OccurredAt: baseTime.Add(30 * time.Second), + RemoteIP: "198.51.100.13", + ClientIP: "203.0.113.30", + 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}`, + }, + } + for _, event := range events { + if err := db.RecordEvent(ctx, event); err != nil { + t.Fatalf("record event %+v: %v", event, err) + } + } + for _, ip := range []string{"203.0.113.10", "203.0.113.11"} { + if err := db.SaveInvestigation(ctx, model.IPInvestigation{ + IP: ip, + UpdatedAt: baseTime, + Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true}, + }); err != nil { + t.Fatalf("save investigation for %s: %v", ip, err) + } + } + + items, err := db.ListEvents(ctx, baseTime.Add(-time.Minute), 10, model.EventListOptions{ + Source: "main", + Method: "GET", + StatusFilter: "4xx", + State: string(model.IPStateReview), + BotFilter: "known", + SortBy: "status", + SortDesc: false, + }) + if err != nil { + t.Fatalf("list events with filters: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 filtered items, got %+v", items) + } + if items[0].ClientIP != "203.0.113.11" || items[0].Status != 401 { + t.Fatalf("unexpected first filtered row: %+v", items[0]) + } + if items[1].ClientIP != "203.0.113.10" || items[1].Status != 404 { + t.Fatalf("unexpected second filtered row: %+v", items[1]) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go index fc2776c..98a86af 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -150,12 +150,23 @@ func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) { if page <= 0 { page = 1 } + stateFilter := strings.TrimSpace(r.URL.Query().Get("state")) + if stateFilter == "" && queryBool(r, "review_only", false) { + stateFilter = string(model.IPStateReview) + } since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour) options := model.EventListOptions{ ShowKnownBots: queryBool(r, "show_known_bots", true), ShowAllowed: queryBool(r, "show_allowed", true), ReviewOnly: queryBool(r, "review_only", false), Offset: (page - 1) * limit, + Source: strings.TrimSpace(r.URL.Query().Get("source")), + Method: strings.TrimSpace(r.URL.Query().Get("method")), + StatusFilter: strings.TrimSpace(r.URL.Query().Get("status")), + State: stateFilter, + BotFilter: strings.TrimSpace(r.URL.Query().Get("bot_filter")), + SortBy: strings.TrimSpace(r.URL.Query().Get("sort_by")), + SortDesc: !strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("sort_dir")), "asc"), } events, err := h.app.ListEvents(r.Context(), since, limit+1, options) if err != nil { @@ -901,28 +912,42 @@ const queryLogHTML = ` a:hover { text-decoration: underline; } .muted { color: #94a3b8; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } - .panel { background: #111827; border: 1px solid #334155; border-radius: .85rem; padding: 1rem; overflow: hidden; } - .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; } - .toggle { display: inline-flex; align-items: center; gap: .45rem; font-size: .95rem; color: #cbd5e1; } - .toggle input { margin: 0; } + .panel { background: #111827; border: 1px solid #334155; border-radius: .85rem; overflow: hidden; } .toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; } .toolbar-actions { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; } .page-status { color: #cbd5e1; font-size: .92rem; } + .controls-panel summary { cursor: pointer; padding: 1rem; font-weight: 700; color: #e2e8f0; list-style: none; user-select: none; } + .controls-panel summary::-webkit-details-marker { display: none; } + .controls-panel[open] summary { border-bottom: 1px solid #334155; } + .controls-body { padding: 1rem; display: grid; gap: 1rem; } + .controls-help { font-size: .92rem; } + .filters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .85rem; align-items: end; } + .field { display: grid; gap: .35rem; } + .field label { font-size: .85rem; color: #cbd5e1; } + .field input, .field select { width: 100%; box-sizing: border-box; padding: .55rem .65rem; border-radius: .55rem; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; } + .field input::placeholder { color: #64748b; } + .field.inline-toggle { display: flex; align-items: center; gap: .55rem; padding-top: 1.7rem; } + .field.inline-toggle input { width: auto; } + .panel-actions { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; } + .panel-actions .spacer { flex: 1; } + .table-panel { padding: 1rem; } .table-shell { overflow: hidden; } table { width: 100%; border-collapse: collapse; table-layout: fixed; } th, td { padding: .6rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; min-width: 0; } thead th { color: #93c5fd; } tbody tr:nth-child(even) { background: rgba(15, 23, 42, .55); } - .col-time, .col-source, .col-ip, .col-method, .col-status, .col-state, .col-reason, .col-actions { white-space: nowrap; } + .sortable { cursor: pointer; user-select: none; } + .sortable.active { color: #e2e8f0; } + .sort-indicator { color: #64748b; font-size: .82rem; margin-left: .2rem; } + .col-time, .col-ip, .col-method, .col-source, .col-status, .col-state, .col-reason, .col-actions { white-space: nowrap; } .col-time { width: 11rem; } - .col-source { width: 7rem; } .col-ip { width: 12rem; } .col-method { width: 5.5rem; } - .col-status { width: 4.5rem; } + .col-source { width: 7rem; } + .col-status { width: 4.75rem; } .col-state { width: 6.5rem; } - .col-reason { width: 12rem; overflow: hidden; } - .col-actions { width: 11rem; } + .col-reason { width: 11rem; overflow: hidden; } + .col-actions { width: 7.5rem; } .col-request { width: auto; overflow: hidden; } .request-text, .reason-text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; } @@ -938,13 +963,13 @@ const queryLogHTML = ` .method.post { background: #78350f; color: #fef3c7; } .method.head { background: #0c4a6e; color: #e0f2fe; } .method.other { background: #334155; color: #e2e8f0; } - .actions { display: block; } - .actions .muted { display: block; text-align: center; } - button { display: block; width: 100%; min-width: 7rem; align-items: center; justify-content: center; gap: .35rem; border-radius: .45rem; padding: .3rem .6rem; font-size: .9rem; white-space: nowrap; } - button { background: #2563eb; color: white; border: 0; cursor: pointer; } + button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; border-radius: .45rem; padding: .3rem .75rem; font-size: .9rem; white-space: nowrap; background: #2563eb; color: white; border: 0; cursor: pointer; } button.secondary { background: #475569; } button.danger { background: #dc2626; } button[disabled] { opacity: .5; cursor: default; } + .actions { display: block; } + .actions button { display: block; width: 100%; min-width: 0; } + .actions .muted { display: block; text-align: center; } .ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; } .bot-chip { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border-radius: 999px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: .72rem; font-weight: 700; cursor: help; flex: 0 0 auto; } .bot-chip.verified { border-color: #2563eb; } @@ -962,16 +987,15 @@ const queryLogHTML = ` .bot-chip.baidu { background: #7c3aed; color: white; } .bot-chip.bytespider { background: #111827; color: white; } @media (max-width: 960px) { - .toolbar, .controls { align-items: flex-start; } - .toolbar-actions, .controls-group { width: 100%; justify-content: flex-start; } + .toolbar, .panel-actions { align-items: flex-start; } + .toolbar-actions, .panel-actions, .filters-grid { width: 100%; } th, td { font-size: .88rem; } } @media (max-width: 720px) { header { padding: .9rem 1rem; } main { padding: 1rem; } - .panel { padding: .85rem; } .table-shell { overflow-x: auto; } - table { min-width: 900px; } + table { min-width: 980px; } } @@ -989,16 +1013,95 @@ const queryLogHTML = `
-
-
- - - - +
+ Filters, sorting, and pagination +
+
Use exact values, or 4xx / 5xx for HTTP status classes. Click a column header to sort directly from the table.
+
+
+ + +
+
+ + + + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
No active filters.
+
-
These filters affect the full Requests Log.
-
-
+ +

Recent requests

@@ -1015,14 +1118,14 @@ const queryLogHTML = ` - - - - - - - - + + + + + + + + @@ -1035,11 +1138,16 @@ const queryLogHTML = ` diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index eea8396..0b60b48 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -57,7 +57,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) { } recorder = httptest.NewRecorder() - request = httptest.NewRequest(http.MethodGet, "/api/events?hours=24&limit=250&page=2&show_known_bots=false&show_allowed=false&review_only=true", nil) + request = httptest.NewRequest(http.MethodGet, "/api/events?hours=24&limit=250&page=2&source=main&method=GET&status=4xx&state=review&bot_filter=known&sort_by=status&sort_dir=asc", nil) handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unexpected filtered events status: %d", recorder.Code) @@ -69,7 +69,15 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) { if eventPage.Page != 2 || !eventPage.HasPrev { t.Fatalf("unexpected event page payload: %+v", eventPage) } - if app.lastEventOptions.ShowKnownBots || app.lastEventOptions.ShowAllowed || !app.lastEventOptions.ReviewOnly || app.lastEventOptions.Offset != 250 { + if app.lastEventOptions.Offset != 250 || + app.lastEventOptions.Source != "main" || + app.lastEventOptions.Method != "GET" || + app.lastEventOptions.StatusFilter != "4xx" || + app.lastEventOptions.State != string(model.IPStateReview) || + app.lastEventOptions.BotFilter != "known" || + app.lastEventOptions.SortBy != "status" || + app.lastEventOptions.SortDesc || + app.lastEventOptions.ReviewOnly { t.Fatalf("event filter options were not forwarded correctly: %+v", app.lastEventOptions) } @@ -158,11 +166,11 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) { t.Fatalf("unexpected requests log page status: %d", recorder.Code) } queryLogBody := recorder.Body.String() - if !strings.Contains(queryLogBody, "Review only") { - t.Fatalf("requests log page should expose the review-only toggle") + if !strings.Contains(queryLogBody, "Filters, sorting, and pagination") { + t.Fatalf("requests log page should expose the collapsible controls panel") } - if !strings.Contains(queryLogBody, "These filters affect the full Requests Log") { - t.Fatalf("requests log page should explain its filters") + if !strings.Contains(queryLogBody, "Rows per page") { + t.Fatalf("requests log page should expose pagination settings") } if !strings.Contains(queryLogBody, "Request") { t.Fatalf("requests log page should render the request table") @@ -170,6 +178,12 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) { if !strings.Contains(queryLogBody, "Auto refresh") { t.Fatalf("requests log page should expose the auto refresh toggle") } + if !strings.Contains(queryLogBody, "onclick=\"applySort('status')\"") { + t.Fatalf("requests log page should expose clickable sortable columns") + } + if !strings.Contains(queryLogBody, "Source") || !strings.Contains(queryLogBody, "Bots") || !strings.Contains(queryLogBody, "HTTP status") { + t.Fatalf("requests log page should expose source, bot, and status filters") + } if !strings.Contains(queryLogBody, "Previous") || !strings.Contains(queryLogBody, "Next") { t.Fatalf("requests log page should expose pagination controls") }
TimeIPMethodSourceRequestStatusStateReasonTimeIPMethodSourceRequestStatusStateReason Actions