You've already forked caddy-opnsense-blocker
Improve requests log filters and sorting
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = `<!doctype html>
|
||||
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 = `<!doctype html>
|
||||
.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 = `<!doctype html>
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -989,16 +1013,95 @@ const queryLogHTML = `<!doctype html>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<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>
|
||||
<label class="toggle"><input id="show-review-toggle" type="checkbox" onchange="toggleReviewOnly()">Review only</label>
|
||||
<label class="toggle"><input id="auto-refresh-toggle" type="checkbox" onchange="toggleAutoRefresh()">Auto refresh</label>
|
||||
<details class="panel controls-panel" id="options-panel">
|
||||
<summary>Filters, sorting, and pagination</summary>
|
||||
<div class="controls-body">
|
||||
<div class="controls-help muted">Use exact values, or 4xx / 5xx for HTTP status classes. Click a column header to sort directly from the table.</div>
|
||||
<form class="filters-grid" id="controls-form" onsubmit="applyFilters(event)">
|
||||
<div class="field">
|
||||
<label for="source-filter">Source</label>
|
||||
<input id="source-filter" type="text" placeholder="gitea">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="method-filter">Method</label>
|
||||
<input id="method-filter" type="text" list="method-options" placeholder="GET">
|
||||
<datalist id="method-options">
|
||||
<option value="GET"></option>
|
||||
<option value="POST"></option>
|
||||
<option value="HEAD"></option>
|
||||
<option value="PUT"></option>
|
||||
<option value="DELETE"></option>
|
||||
<option value="PATCH"></option>
|
||||
<option value="OPTIONS"></option>
|
||||
<option value="OTHER"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status-filter">HTTP status</label>
|
||||
<input id="status-filter" type="text" placeholder="404 or 4xx">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="state-filter">State</label>
|
||||
<select id="state-filter">
|
||||
<option value="">All</option>
|
||||
<option value="observed">Observed</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
<option value="allowed">Allowed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="bot-filter">Bots</label>
|
||||
<select id="bot-filter">
|
||||
<option value="all">All traffic</option>
|
||||
<option value="known">Known bots</option>
|
||||
<option value="possible">Possible bots</option>
|
||||
<option value="any">Any bot</option>
|
||||
<option value="non-bot">Non-bots</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sort-by">Sort by</label>
|
||||
<select id="sort-by">
|
||||
<option value="time">Time</option>
|
||||
<option value="ip">IP</option>
|
||||
<option value="method">Method</option>
|
||||
<option value="source">Source</option>
|
||||
<option value="request">Request</option>
|
||||
<option value="status">Status</option>
|
||||
<option value="state">State</option>
|
||||
<option value="reason">Reason</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sort-dir">Direction</label>
|
||||
<select id="sort-dir">
|
||||
<option value="desc">Descending</option>
|
||||
<option value="asc">Ascending</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="page-size">Rows per page</label>
|
||||
<select id="page-size">
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field inline-toggle">
|
||||
<input id="auto-refresh-toggle" type="checkbox" onchange="toggleAutoRefresh()">
|
||||
<label for="auto-refresh-toggle">Auto refresh</label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="panel-actions">
|
||||
<button type="button" onclick="applyFilters()">Apply</button>
|
||||
<button type="button" class="secondary" onclick="resetControls()">Reset</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="muted" id="controls-summary">No active filters.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted">These filters affect the full Requests Log.</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
</details>
|
||||
<section class="panel table-panel">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>Recent requests</h2>
|
||||
@@ -1015,14 +1118,14 @@ const queryLogHTML = `<!doctype html>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-time">Time</th>
|
||||
<th class="col-ip">IP</th>
|
||||
<th class="col-method">Method</th>
|
||||
<th class="col-source">Source</th>
|
||||
<th class="col-request">Request</th>
|
||||
<th class="col-status">Status</th>
|
||||
<th class="col-state">State</th>
|
||||
<th class="col-reason">Reason</th>
|
||||
<th class="col-time sortable" data-sort="time" onclick="applySort('time')">Time<span class="sort-indicator" id="sort-time"></span></th>
|
||||
<th class="col-ip sortable" data-sort="ip" onclick="applySort('ip')">IP<span class="sort-indicator" id="sort-ip"></span></th>
|
||||
<th class="col-method sortable" data-sort="method" onclick="applySort('method')">Method<span class="sort-indicator" id="sort-method"></span></th>
|
||||
<th class="col-source sortable" data-sort="source" onclick="applySort('source')">Source<span class="sort-indicator" id="sort-source"></span></th>
|
||||
<th class="col-request sortable" data-sort="request" onclick="applySort('request')">Request<span class="sort-indicator" id="sort-request"></span></th>
|
||||
<th class="col-status sortable" data-sort="status" onclick="applySort('status')">Status<span class="sort-indicator" id="sort-status"></span></th>
|
||||
<th class="col-state sortable" data-sort="state" onclick="applySort('state')">State<span class="sort-indicator" id="sort-state"></span></th>
|
||||
<th class="col-reason sortable" data-sort="reason" onclick="applySort('reason')">Reason<span class="sort-indicator" id="sort-reason"></span></th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1035,11 +1138,16 @@ const queryLogHTML = `<!doctype html>
|
||||
</main>
|
||||
<script>
|
||||
const recentHours = 24;
|
||||
const pageSize = 100;
|
||||
let showKnownBots = loadBooleanPreference('cob.dashboard.showKnownBots', true);
|
||||
let showAllowed = loadBooleanPreference('cob.dashboard.showAllowed', true);
|
||||
let showReviewOnly = loadBooleanPreference('cob.queryLog.showReviewOnly', false);
|
||||
let sourceFilter = loadStringPreference('cob.requests.source', '');
|
||||
let methodFilter = loadStringPreference('cob.requests.method', '');
|
||||
let statusFilter = loadStringPreference('cob.requests.status', '');
|
||||
let stateFilter = loadStringPreference('cob.requests.state', '');
|
||||
let botFilter = loadStringPreference('cob.requests.botFilter', 'all');
|
||||
let sortBy = loadStringPreference('cob.requests.sortBy', 'time');
|
||||
let sortDir = loadStringPreference('cob.requests.sortDir', 'desc');
|
||||
let pageSize = loadStringPreference('cob.requests.pageSize', '100');
|
||||
let autoRefresh = loadBooleanPreference('cob.requests.autoRefresh', false);
|
||||
let panelOpen = loadBooleanPreference('cob.requests.panelOpen', false);
|
||||
let currentPage = 1;
|
||||
let refreshTimer = null;
|
||||
|
||||
@@ -1052,10 +1160,23 @@ const queryLogHTML = `<!doctype html>
|
||||
}
|
||||
}
|
||||
|
||||
function loadStringPreference(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : raw;
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function saveBooleanPreference(key, value) {
|
||||
localStorage.setItem(key, value ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function saveStringPreference(key, value) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
@@ -1141,16 +1262,123 @@ const queryLogHTML = `<!doctype html>
|
||||
return '';
|
||||
}
|
||||
|
||||
function applyToggles() {
|
||||
document.getElementById('show-bots-toggle').checked = showKnownBots;
|
||||
document.getElementById('show-allowed-toggle').checked = showAllowed;
|
||||
document.getElementById('show-review-toggle').checked = showReviewOnly;
|
||||
function applyControls() {
|
||||
document.getElementById('source-filter').value = sourceFilter;
|
||||
document.getElementById('method-filter').value = methodFilter;
|
||||
document.getElementById('status-filter').value = statusFilter;
|
||||
document.getElementById('state-filter').value = stateFilter;
|
||||
document.getElementById('bot-filter').value = botFilter;
|
||||
document.getElementById('sort-by').value = sortBy;
|
||||
document.getElementById('sort-dir').value = sortDir;
|
||||
document.getElementById('page-size').value = pageSize;
|
||||
document.getElementById('auto-refresh-toggle').checked = autoRefresh;
|
||||
document.getElementById('options-panel').open = panelOpen;
|
||||
updateSortIndicators();
|
||||
updateControlsSummary();
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
const fields = ['time', 'ip', 'method', 'source', 'request', 'status', 'state', 'reason'];
|
||||
for (const field of fields) {
|
||||
const indicator = document.getElementById('sort-' + field);
|
||||
const header = document.querySelector('[data-sort="' + field + '"]');
|
||||
if (!indicator || !header) {
|
||||
continue;
|
||||
}
|
||||
indicator.textContent = field === sortBy ? (sortDir === 'asc' ? '↑' : '↓') : '';
|
||||
header.classList.toggle('active', field === sortBy);
|
||||
}
|
||||
document.getElementById('sort-by').value = sortBy;
|
||||
document.getElementById('sort-dir').value = sortDir;
|
||||
}
|
||||
|
||||
function updateControlsSummary() {
|
||||
const parts = [];
|
||||
if (sourceFilter) { parts.push('source=' + sourceFilter); }
|
||||
if (methodFilter) { parts.push('method=' + methodFilter.toUpperCase()); }
|
||||
if (statusFilter) { parts.push('status=' + statusFilter); }
|
||||
if (stateFilter) { parts.push('state=' + stateFilter); }
|
||||
if (botFilter && botFilter !== 'all') { parts.push('bots=' + botFilter); }
|
||||
parts.push('sort=' + sortBy + ' ' + sortDir);
|
||||
parts.push('page size=' + pageSize);
|
||||
if (autoRefresh) { parts.push('auto refresh'); }
|
||||
document.getElementById('controls-summary').textContent = parts.length ? parts.join(' · ') : 'No active filters.';
|
||||
}
|
||||
|
||||
function saveControls() {
|
||||
saveStringPreference('cob.requests.source', sourceFilter);
|
||||
saveStringPreference('cob.requests.method', methodFilter);
|
||||
saveStringPreference('cob.requests.status', statusFilter);
|
||||
saveStringPreference('cob.requests.state', stateFilter);
|
||||
saveStringPreference('cob.requests.botFilter', botFilter);
|
||||
saveStringPreference('cob.requests.sortBy', sortBy);
|
||||
saveStringPreference('cob.requests.sortDir', sortDir);
|
||||
saveStringPreference('cob.requests.pageSize', pageSize);
|
||||
saveBooleanPreference('cob.requests.autoRefresh', autoRefresh);
|
||||
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
|
||||
}
|
||||
|
||||
function readControls() {
|
||||
sourceFilter = document.getElementById('source-filter').value.trim();
|
||||
methodFilter = document.getElementById('method-filter').value.trim();
|
||||
statusFilter = document.getElementById('status-filter').value.trim();
|
||||
stateFilter = document.getElementById('state-filter').value;
|
||||
botFilter = document.getElementById('bot-filter').value;
|
||||
sortBy = document.getElementById('sort-by').value;
|
||||
sortDir = document.getElementById('sort-dir').value;
|
||||
pageSize = document.getElementById('page-size').value;
|
||||
autoRefresh = document.getElementById('auto-refresh-toggle').checked;
|
||||
}
|
||||
|
||||
function applyFilters(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
readControls();
|
||||
currentPage = 1;
|
||||
saveControls();
|
||||
updateSortIndicators();
|
||||
updateControlsSummary();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function resetControls() {
|
||||
sourceFilter = '';
|
||||
methodFilter = '';
|
||||
statusFilter = '';
|
||||
stateFilter = '';
|
||||
botFilter = 'all';
|
||||
sortBy = 'time';
|
||||
sortDir = 'desc';
|
||||
pageSize = '100';
|
||||
autoRefresh = false;
|
||||
saveControls();
|
||||
applyControls();
|
||||
currentPage = 1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function defaultSortDirection(field) {
|
||||
return field === 'time' || field === 'status' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function applySort(field) {
|
||||
if (sortBy === field) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = field;
|
||||
sortDir = defaultSortDirection(field);
|
||||
}
|
||||
saveControls();
|
||||
updateSortIndicators();
|
||||
updateControlsSummary();
|
||||
currentPage = 1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function updatePager(payload) {
|
||||
const page = Number(payload.page || currentPage || 1);
|
||||
document.getElementById('page-status').textContent = 'Page ' + page;
|
||||
document.getElementById('page-status').textContent = 'Page ' + page + ' · ' + pageSize + ' rows';
|
||||
document.getElementById('prev-page').disabled = !payload.has_prev;
|
||||
document.getElementById('next-page').disabled = !payload.has_next;
|
||||
}
|
||||
@@ -1165,30 +1393,10 @@ const queryLogHTML = `<!doctype html>
|
||||
}
|
||||
}
|
||||
|
||||
function toggleKnownBots() {
|
||||
showKnownBots = document.getElementById('show-bots-toggle').checked;
|
||||
saveBooleanPreference('cob.dashboard.showKnownBots', showKnownBots);
|
||||
currentPage = 1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function toggleAllowed() {
|
||||
showAllowed = document.getElementById('show-allowed-toggle').checked;
|
||||
saveBooleanPreference('cob.dashboard.showAllowed', showAllowed);
|
||||
currentPage = 1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function toggleReviewOnly() {
|
||||
showReviewOnly = document.getElementById('show-review-toggle').checked;
|
||||
saveBooleanPreference('cob.queryLog.showReviewOnly', showReviewOnly);
|
||||
currentPage = 1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh = document.getElementById('auto-refresh-toggle').checked;
|
||||
saveBooleanPreference('cob.requests.autoRefresh', autoRefresh);
|
||||
saveControls();
|
||||
updateControlsSummary();
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
@@ -1250,8 +1458,18 @@ const queryLogHTML = `<!doctype html>
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
applyToggles();
|
||||
const response = await fetch('/api/events?hours=' + recentHours + '&limit=' + pageSize + '&page=' + currentPage + '&show_known_bots=' + (showKnownBots ? 'true' : 'false') + '&show_allowed=' + (showAllowed ? 'true' : 'false') + '&review_only=' + (showReviewOnly ? 'true' : 'false'));
|
||||
const params = new URLSearchParams();
|
||||
params.set('hours', String(recentHours));
|
||||
params.set('limit', String(pageSize));
|
||||
params.set('page', String(currentPage));
|
||||
params.set('sort_by', sortBy);
|
||||
params.set('sort_dir', sortDir);
|
||||
if (sourceFilter) { params.set('source', sourceFilter); }
|
||||
if (methodFilter) { params.set('method', methodFilter); }
|
||||
if (statusFilter) { params.set('status', statusFilter); }
|
||||
if (stateFilter) { params.set('state', stateFilter); }
|
||||
if (botFilter && botFilter !== 'all') { params.set('bot_filter', botFilter); }
|
||||
const response = await fetch('/api/events?' + params.toString());
|
||||
const payload = await response.json().catch(() => ({ items: [] }));
|
||||
if (!response.ok) {
|
||||
document.getElementById('events-body').innerHTML = '<tr><td colspan="9" class="muted">' + escapeHtml(payload.error || response.statusText) + '</td></tr>';
|
||||
@@ -1264,7 +1482,12 @@ const queryLogHTML = `<!doctype html>
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
applyToggles();
|
||||
document.getElementById('options-panel').addEventListener('toggle', () => {
|
||||
panelOpen = document.getElementById('options-panel').open;
|
||||
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
|
||||
});
|
||||
|
||||
applyControls();
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user