2

Adopt Tabulator for the requests log

This commit is contained in:
2026-03-12 21:01:34 +01:00
parent c213054f82
commit 9273c0ae69
10 changed files with 433 additions and 339 deletions

View File

@@ -8,6 +8,7 @@
- One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias. - 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. - 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 with collapsible filters and clickable column sorting, 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.
- The requests log ships with vendored Tabulator assets served locally by the daemon, so the UI stays self-contained and does not depend on a CDN.
- Background investigation workers that fill in missing cached intelligence without slowing down page loads. - 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. - 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. - Optional OPNsense integration; the daemon also works in review-only mode.

View File

@@ -286,9 +286,12 @@ type Overview struct {
} }
type EventPage struct { type EventPage struct {
Items []Event `json:"items"` Items []Event `json:"items"`
Page int `json:"page"` Data []Event `json:"data"`
Limit int `json:"limit"` Page int `json:"page"`
HasPrev bool `json:"has_prev"` Limit int `json:"limit"`
HasNext bool `json:"has_next"` HasPrev bool `json:"has_prev"`
HasNext bool `json:"has_next"`
LastPage int `json:"last_page"`
TotalItems int64 `json:"total_items"`
} }

View File

@@ -114,6 +114,10 @@ func (s *Service) ListEvents(ctx context.Context, since time.Time, limit int, op
return items, nil return items, nil
} }
func (s *Service) CountEvents(ctx context.Context, since time.Time, options model.EventListOptions) (int64, error) {
return s.store.CountEvents(ctx, since, options)
}
func (s *Service) ListSourceNames() []string { func (s *Service) ListSourceNames() []string {
if s == nil || s.cfg == nil || len(s.cfg.Sources) == 0 { if s == nil || s.cfg == nil || len(s.cfg.Sources) == 0 {
return nil return nil

View File

@@ -987,6 +987,31 @@ func (s *Store) ListEvents(ctx context.Context, since time.Time, limit int, opti
return items, nil return items, nil
} }
func (s *Store) CountEvents(ctx context.Context, since time.Time, options model.EventListOptions) (int64, error) {
joins, clauses, filterArgs, err := eventFilterQueryParts(options)
if err != nil {
return 0, err
}
query := `SELECT COUNT(*) FROM events e`
if len(joins) > 0 {
query += ` ` + strings.Join(joins, ` `)
}
args := make([]any, 0, len(filterArgs)+1)
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 `)
}
var total int64
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("count events: %w", err)
}
return total, nil
}
func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) { func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50

18
internal/web/assets.go Normal file
View File

@@ -0,0 +1,18 @@
package web
import (
"embed"
"io/fs"
"net/http"
)
//go:embed assets
var embeddedAssets embed.FS
func assetHandler() http.Handler {
root, err := fs.Sub(embeddedAssets, "assets")
if err != nil {
panic(err)
}
return http.StripPrefix("/assets/", http.FileServer(http.FS(root)))
}

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-2026 Oli Folkerd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,7 @@ import (
type App interface { type App interface {
GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error) GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error)
ListEvents(ctx context.Context, since time.Time, limit int, options model.EventListOptions) ([]model.Event, error) ListEvents(ctx context.Context, since time.Time, limit int, options model.EventListOptions) ([]model.Event, error)
CountEvents(ctx context.Context, since time.Time, options model.EventListOptions) (int64, error)
ListSourceNames() []string ListSourceNames() []string
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)
@@ -66,6 +67,7 @@ func NewHandler(app App) http.Handler {
mux.HandleFunc("/api/ips", h.handleAPIIPs) mux.HandleFunc("/api/ips", h.handleAPIIPs)
mux.HandleFunc("/api/recent-ips", h.handleAPIRecentIPs) mux.HandleFunc("/api/recent-ips", h.handleAPIRecentIPs)
mux.HandleFunc("/api/ips/", h.handleAPIIP) mux.HandleFunc("/api/ips/", h.handleAPIIP)
mux.Handle("/assets/", assetHandler())
return mux return mux
} }
@@ -175,16 +177,28 @@ func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err) writeError(w, http.StatusInternalServerError, err)
return return
} }
totalItems, err := h.app.CountEvents(r.Context(), since, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
hasNext := len(events) > limit hasNext := len(events) > limit
if hasNext { if hasNext {
events = events[:limit] events = events[:limit]
} }
lastPage := 1
if totalItems > 0 {
lastPage = int((totalItems + int64(limit) - 1) / int64(limit))
}
writeJSON(w, http.StatusOK, model.EventPage{ writeJSON(w, http.StatusOK, model.EventPage{
Items: events, Items: events,
Page: page, Data: events,
Limit: limit, Page: page,
HasPrev: page > 1, Limit: limit,
HasNext: hasNext, HasPrev: page > 1,
HasNext: hasNext,
LastPage: lastPage,
TotalItems: totalItems,
}) })
} }
@@ -900,6 +914,7 @@ const queryLogHTML = `<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title> <title>{{ .Title }}</title>
<link rel="stylesheet" href="/assets/tabulator/tabulator_midnight.min.css">
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: linear-gradient(180deg, #0f172a 0%, #020617 100%); color: #e2e8f0; } body { font-family: system-ui, sans-serif; margin: 0; background: linear-gradient(180deg, #0f172a 0%, #020617 100%); color: #e2e8f0; }
@@ -913,12 +928,9 @@ const queryLogHTML = `<!doctype html>
a { color: #93c5fd; text-decoration: none; } a { color: #93c5fd; text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
.muted { color: #94a3b8; } .muted { color: #94a3b8; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .85rem; overflow: hidden; } .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 { 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; } .toolbar-actions { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; }
.pager { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.page-status { color: #cbd5e1; font-size: .92rem; min-width: 0; }
.controls-panel summary { cursor: pointer; padding: 1rem; font-weight: 700; color: #e2e8f0; list-style: none; user-select: none; } .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 summary::-webkit-details-marker { display: none; }
.controls-panel[open] summary { border-bottom: 1px solid #334155; } .controls-panel[open] summary { border-bottom: 1px solid #334155; }
@@ -928,59 +940,24 @@ const queryLogHTML = `<!doctype html>
.field { display: grid; gap: .35rem; } .field { display: grid; gap: .35rem; }
.field label { font-size: .85rem; color: #cbd5e1; } .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, .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 { display: flex; align-items: center; gap: .55rem; padding-top: 1.7rem; }
.field.inline-toggle input { width: auto; } .field.inline-toggle input { width: auto; }
.panel-actions { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; }
.panel-actions .spacer { flex: 1; }
.columns-field { grid-column: 1 / -1; } .columns-field { grid-column: 1 / -1; }
.columns-grid { display: flex; flex-wrap: wrap; gap: .5rem; } .columns-grid { display: flex; flex-wrap: wrap; gap: .5rem; }
.column-chip { display: inline-flex; align-items: center; gap: .35rem; padding: .4rem .65rem; border-radius: 999px; border: 1px solid #334155; background: #0f172a; color: #cbd5e1; font-size: .85rem; } .column-chip { display: inline-flex; align-items: center; gap: .35rem; padding: .4rem .65rem; border-radius: 999px; border: 1px solid #334155; background: #0f172a; color: #cbd5e1; font-size: .85rem; }
.column-chip input { width: auto; margin: 0; } .column-chip input { width: auto; margin: 0; }
.panel-actions { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; }
.panel-actions .spacer { flex: 1; }
.table-panel { padding: 1rem; } .table-panel { padding: 1rem; }
.table-shell { overflow: hidden; } .tabulator-shell { border: 1px solid #1e293b; border-radius: .75rem; overflow: hidden; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; } #requests-table { width: 100%; }
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); }
.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-ip { width: 13rem; }
.col-method { width: 5.5rem; }
.col-source { width: 7rem; }
.col-status { width: 4.75rem; }
.col-state { width: 6.5rem; }
.col-reason { width: 11rem; overflow: hidden; }
.col-actions { width: 4rem; }
.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; }
.status.blocked { background: #7f1d1d; }
.status.review { background: #78350f; }
.status.allowed { background: #14532d; }
.status.observed { background: #1e293b; }
.status-code { display: inline-flex; align-items: center; justify-content: center; min-width: 3.5rem; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; font-weight: 700; background: #1e293b; color: #e2e8f0; }
.status-code.client-error { background: #713f12; color: #fde68a; }
.status-code.server-error { background: #9a3412; color: #fdba74; }
.method { display: inline-block; padding: .2rem .45rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
.method.get { background: #14532d; color: #dcfce7; }
.method.post { background: #78350f; color: #fef3c7; }
.method.head { background: #0c4a6e; color: #e0f2fe; }
.method.other { background: #334155; color: #e2e8f0; }
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 { 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.secondary { background: #475569; }
button.danger { background: #dc2626; }
button[disabled] { opacity: .5; cursor: default; } button[disabled] { opacity: .5; cursor: default; }
.icon-button { min-width: 2.15rem; width: 2.15rem; height: 2.15rem; padding: 0; font-size: 1rem; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.pager-button .button-label { display: inline; }
.actions { display: block; }
.actions button { display: block; width: 2.15rem; height: 2.15rem; min-width: 0; margin-left: auto; padding: 0; }
.actions .muted { display: block; text-align: center; }
.ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; max-width: 100%; } .ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; max-width: 100%; }
.ip-link { display: block; flex: 1 1 auto; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ip-link { display: block; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.request-text, .reason-text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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 { 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; } .bot-chip.verified { border-color: #2563eb; }
.bot-chip.hint { border-style: dashed; } .bot-chip.hint { border-style: dashed; }
@@ -996,19 +973,48 @@ const queryLogHTML = `<!doctype html>
.bot-chip.yandex { background: #dc2626; color: white; } .bot-chip.yandex { background: #dc2626; color: white; }
.bot-chip.baidu { background: #7c3aed; color: white; } .bot-chip.baidu { background: #7c3aed; color: white; }
.bot-chip.bytespider { background: #111827; color: white; } .bot-chip.bytespider { background: #111827; color: white; }
.method-pill { display: inline-block; padding: .2rem .45rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
.method-pill.get { background: #14532d; color: #dcfce7; }
.method-pill.post { background: #78350f; color: #fef3c7; }
.method-pill.head { background: #0c4a6e; color: #e0f2fe; }
.method-pill.other { background: #334155; color: #e2e8f0; }
.status-pill { display: inline-flex; align-items: center; justify-content: center; min-width: 3.5rem; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; font-weight: 700; background: #1e293b; color: #e2e8f0; }
.status-pill.client-error { background: #713f12; color: #fde68a; }
.status-pill.server-error { background: #9a3412; color: #fdba74; }
.state-pill { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
.state-pill.blocked { background: #7f1d1d; }
.state-pill.review { background: #78350f; }
.state-pill.allowed { background: #14532d; }
.state-pill.observed { background: #1e293b; }
.action-icon { width: 1.9rem; height: 1.9rem; padding: 0; border-radius: .5rem; display: inline-flex; align-items: center; justify-content: center; border: 0; cursor: pointer; font-size: .95rem; }
.action-icon.block { background: #dc2626; color: white; }
.action-icon.unblock { background: #475569; color: white; }
.tabulator { background: transparent; border: 0; font-size: .92rem; }
.tabulator .tabulator-header { background: #0f172a; border-bottom: 1px solid #334155; }
.tabulator .tabulator-header .tabulator-col { background: #0f172a; border-right: 1px solid #1e293b; }
.tabulator .tabulator-header .tabulator-col.tabulator-sortable:hover { background: #111827; }
.tabulator .tabulator-header .tabulator-col .tabulator-col-content { padding: .75rem .65rem; }
.tabulator .tabulator-row { background: #111827; border-bottom: 1px solid #1e293b; }
.tabulator .tabulator-row:nth-child(even) { background: rgba(15, 23, 42, .55); }
.tabulator .tabulator-cell { padding: .55rem .65rem; border-right: 1px solid #1e293b; color: #e2e8f0; }
.tabulator .tabulator-footer { background: #0b1120; border-top: 1px solid #334155; color: #cbd5e1; }
.tabulator .tabulator-footer .tabulator-paginator { color: #cbd5e1; }
.tabulator .tabulator-footer .tabulator-page { background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: .45rem; }
.tabulator .tabulator-footer .tabulator-page.active { background: #2563eb; border-color: transparent; color: white; }
.tabulator .tabulator-footer .tabulator-page:disabled { opacity: .5; }
.tabulator-placeholder { padding: 1rem; color: #94a3b8; }
@media (max-width: 960px) { @media (max-width: 960px) {
.toolbar, .panel-actions { align-items: flex-start; } .toolbar, .panel-actions { align-items: flex-start; }
.toolbar-actions, .panel-actions, .filters-grid { width: 100%; } .toolbar-actions, .panel-actions, .filters-grid { width: 100%; }
th, td { font-size: .88rem; }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
header { padding: .9rem 1rem; } header { padding: .9rem 1rem; }
main { padding: 1rem; } main { padding: 1rem; }
.toolbar-actions { justify-content: space-between; } .filters-grid { grid-template-columns: 1fr; }
.pager { width: 100%; justify-content: space-between; } .toolbar-actions { justify-content: flex-start; }
.pager-button .button-label { display: none; } .tabulator { font-size: .88rem; }
.table-shell { overflow-x: auto; } .tabulator .tabulator-header .tabulator-col .tabulator-col-content,
table { min-width: 980px; } .tabulator .tabulator-cell { padding-left: .5rem; padding-right: .5rem; }
} }
</style> </style>
</head> </head>
@@ -1027,9 +1033,9 @@ const queryLogHTML = `<!doctype html>
</header> </header>
<main> <main>
<details class="panel controls-panel" id="options-panel"> <details class="panel controls-panel" id="options-panel">
<summary>Filters, sorting, and pagination</summary> <summary>Filters, pagination, and columns</summary>
<div class="controls-body"> <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> <div class="controls-help muted">Sort by clicking a column header. Filters remain server-side, while Tabulator handles rendering and pagination.</div>
<form class="filters-grid" id="controls-form" onsubmit="applyFilters(event)"> <form class="filters-grid" id="controls-form" onsubmit="applyFilters(event)">
<div class="field"> <div class="field">
<label for="source-filter">Source</label> <label for="source-filter">Source</label>
@@ -1094,30 +1100,10 @@ const queryLogHTML = `<!doctype html>
<label for="bot-filter">Bots</label> <label for="bot-filter">Bots</label>
<select id="bot-filter"> <select id="bot-filter">
<option value="all">All traffic</option> <option value="all">All traffic</option>
<option value="known">Known bots</option> <option value="known">Known bots only</option>
<option value="possible">Possible bots</option> <option value="possible">Possible bots only</option>
<option value="any">Any bot</option> <option value="any">Any bot</option>
<option value="non-bot">Non-bots</option> <option value="non-bot">Non-bots only</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> </select>
</div> </div>
<div class="field"> <div class="field">
@@ -1136,7 +1122,6 @@ const queryLogHTML = `<!doctype html>
<div class="field columns-field"> <div class="field columns-field">
<label>Columns</label> <label>Columns</label>
<div class="columns-grid"> <div class="columns-grid">
<label class="column-chip"><input id="column-time" type="checkbox" onchange="applyColumnChanges()">Time</label>
<label class="column-chip"><input id="column-method" type="checkbox" onchange="applyColumnChanges()">Method</label> <label class="column-chip"><input id="column-method" type="checkbox" onchange="applyColumnChanges()">Method</label>
<label class="column-chip"><input id="column-source" type="checkbox" onchange="applyColumnChanges()">Source</label> <label class="column-chip"><input id="column-source" type="checkbox" onchange="applyColumnChanges()">Source</label>
<label class="column-chip"><input id="column-status" type="checkbox" onchange="applyColumnChanges()">Status</label> <label class="column-chip"><input id="column-status" type="checkbox" onchange="applyColumnChanges()">Status</label>
@@ -1158,64 +1143,32 @@ const queryLogHTML = `<!doctype html>
<div class="toolbar"> <div class="toolbar">
<div> <div>
<h2>Recent requests</h2> <h2>Recent requests</h2>
<div class="muted">Click an IP to open its detail page</div> <div class="muted">Click an IP to open its detail page. Sort by clicking a column header.</div>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<button class="secondary pager-button" type="button" onclick="refreshNow()" title="Refresh now" aria-label="Refresh now">⟳<span class="button-label">Refresh</span></button> <button class="secondary" type="button" onclick="refreshNow()">Refresh now</button>
<div class="pager">
<button class="secondary icon-button pager-button" type="button" data-prev-page onclick="goToPreviousPage()" title="Previous page" aria-label="Previous page">←<span class="button-label">Previous</span></button>
<div class="page-status" data-page-status>Page 1</div>
<button class="secondary icon-button pager-button" type="button" data-next-page onclick="goToNextPage()" title="Next page" aria-label="Next page">→<span class="button-label">Next</span></button>
</div>
</div> </div>
</div> </div>
<div class="table-shell"> <div class="muted" id="table-status"></div>
<table> <div class="tabulator-shell">
<thead> <div id="requests-table"></div>
<tr>
<th class="col-time sortable" data-sort="time" data-column="time" onclick="applySort('time')">Time<span class="sort-indicator" id="sort-time"></span></th>
<th class="col-ip sortable" data-sort="ip" data-column="ip" onclick="applySort('ip')">IP<span class="sort-indicator" id="sort-ip"></span></th>
<th class="col-method sortable" data-sort="method" data-column="method" onclick="applySort('method')">Method<span class="sort-indicator" id="sort-method"></span></th>
<th class="col-source sortable" data-sort="source" data-column="source" onclick="applySort('source')">Source<span class="sort-indicator" id="sort-source"></span></th>
<th class="col-request sortable" data-sort="request" data-column="request" onclick="applySort('request')">Request<span class="sort-indicator" id="sort-request"></span></th>
<th class="col-status sortable" data-sort="status" data-column="status" onclick="applySort('status')">Status<span class="sort-indicator" id="sort-status"></span></th>
<th class="col-state sortable" data-sort="state" data-column="state" onclick="applySort('state')">State<span class="sort-indicator" id="sort-state"></span></th>
<th class="col-reason sortable" data-sort="reason" data-column="reason" onclick="applySort('reason')">Reason<span class="sort-indicator" id="sort-reason"></span></th>
<th class="col-actions" data-column="actions">Actions</th>
</tr>
</thead>
<tbody id="events-body">
<tr><td colspan="9" class="muted">Loading requests log…</td></tr>
</tbody>
</table>
</div>
<div class="toolbar">
<div class="muted">Use the controls above to tune the view.</div>
<div class="toolbar-actions">
<div class="pager">
<button class="secondary icon-button pager-button" type="button" data-prev-page onclick="goToPreviousPage()" title="Previous page" aria-label="Previous page">←<span class="button-label">Previous</span></button>
<div class="page-status" data-page-status>Page 1</div>
<button class="secondary icon-button pager-button" type="button" data-next-page onclick="goToNextPage()" title="Next page" aria-label="Next page">→<span class="button-label">Next</span></button>
</div>
</div>
</div> </div>
</section> </section>
</main> </main>
<script src="/assets/tabulator/tabulator.min.js"></script>
<script> <script>
const recentHours = 24; const recentHours = 24;
const defaultVisibleColumns = { method: true, source: true, status: true, state: true, reason: true, actions: true };
let sourceFilter = loadStringPreference('cob.requests.source', ''); let sourceFilter = loadStringPreference('cob.requests.source', '');
let methodFilter = loadStringPreference('cob.requests.method', ''); let methodFilter = loadStringPreference('cob.requests.method', '');
let statusFilter = loadStringPreference('cob.requests.status', ''); let statusFilter = loadStringPreference('cob.requests.status', '');
let stateFilter = loadStringPreference('cob.requests.state', ''); let stateFilter = loadStringPreference('cob.requests.state', '');
let botFilter = loadStringPreference('cob.requests.botFilter', 'all'); let botFilter = loadStringPreference('cob.requests.botFilter', 'all');
let sortBy = loadStringPreference('cob.requests.sortBy', 'time'); let pageSize = loadStringPreference('cob.requests.pageSizeTabulator', '25');
let sortDir = loadStringPreference('cob.requests.sortDir', 'desc');
const defaultVisibleColumns = { time: true, method: true, source: true, status: true, state: true, reason: true, actions: true };
let visibleColumns = loadColumnPreferences();
let pageSize = loadStringPreference('cob.requests.pageSizeV2', '25');
let autoRefresh = loadBooleanPreference('cob.requests.autoRefresh', false); let autoRefresh = loadBooleanPreference('cob.requests.autoRefresh', false);
let panelOpen = loadBooleanPreference('cob.requests.panelOpen', false); let panelOpen = loadBooleanPreference('cob.requests.panelOpen', false);
let currentPage = 1; let visibleColumns = loadColumnPreferences();
let table = null;
let refreshTimer = null; let refreshTimer = null;
function loadBooleanPreference(key, fallback) { function loadBooleanPreference(key, fallback) {
@@ -1237,11 +1190,17 @@ const queryLogHTML = `<!doctype html>
} }
function saveBooleanPreference(key, value) { function saveBooleanPreference(key, value) {
localStorage.setItem(key, value ? 'true' : 'false'); try {
localStorage.setItem(key, value ? 'true' : 'false');
} catch (error) {
}
} }
function saveStringPreference(key, value) { function saveStringPreference(key, value) {
localStorage.setItem(key, value); try {
localStorage.setItem(key, value);
} catch (error) {
}
} }
function loadColumnPreferences() { function loadColumnPreferences() {
@@ -1264,7 +1223,10 @@ const queryLogHTML = `<!doctype html>
} }
function saveColumnPreferences() { function saveColumnPreferences() {
localStorage.setItem('cob.requests.visibleColumns', JSON.stringify(visibleColumns)); try {
localStorage.setItem('cob.requests.visibleColumns', JSON.stringify(visibleColumns));
} catch (error) {
}
} }
function escapeHtml(value) { function escapeHtml(value) {
@@ -1333,12 +1295,12 @@ const queryLogHTML = `<!doctype html>
function renderActions(item) { function renderActions(item) {
const actions = item.actions || {}; const actions = item.actions || {};
if (actions.can_unblock) { if (actions.can_unblock) {
return '<div class="actions"><button class="secondary icon-button" data-ip="' + escapeHtml(item.client_ip) + '" title="Unblock this IP" aria-label="Unblock this IP" onclick="sendAction(this.dataset.ip, \'unblock\', \'Reason for manual unblock\')">🔓</button></div>'; return '<button class="action-icon unblock" data-ip="' + escapeHtml(item.client_ip) + '" title="Unblock this IP" aria-label="Unblock this IP" onclick="sendAction(this.dataset.ip, \'unblock\', \'Reason for manual unblock\')">🔓</button>';
} }
if (actions.can_block) { if (actions.can_block) {
return '<div class="actions"><button class="danger icon-button" data-ip="' + escapeHtml(item.client_ip) + '" title="Block this IP" aria-label="Block this IP" onclick="sendAction(this.dataset.ip, \'block\', \'Reason for manual block\')">⛔</button></div>'; return '<button class="action-icon block" data-ip="' + escapeHtml(item.client_ip) + '" title="Block this IP" aria-label="Block this IP" onclick="sendAction(this.dataset.ip, \'block\', \'Reason for manual block\')">⛔</button>';
} }
return '<div class="actions"><span class="muted">—</span></div>'; return '<span class="muted">—</span>';
} }
function statusCodeClass(status) { function statusCodeClass(status) {
@@ -1352,7 +1314,27 @@ const queryLogHTML = `<!doctype html>
return ''; return '';
} }
function applyColumnControls() { function currentSort() {
if (!table) {
return {
field: loadStringPreference('cob.requests.sortField', 'time'),
dir: loadStringPreference('cob.requests.sortDirTabulator', 'desc'),
};
}
const sorters = table.getSorters();
if (Array.isArray(sorters) && sorters.length > 0) {
return {
field: String(sorters[0].field || 'time'),
dir: String(sorters[0].dir || 'desc'),
};
}
return {
field: loadStringPreference('cob.requests.sortField', 'time'),
dir: loadStringPreference('cob.requests.sortDirTabulator', 'desc'),
};
}
function syncColumnControls() {
for (const key of Object.keys(defaultVisibleColumns)) { for (const key of Object.keys(defaultVisibleColumns)) {
const input = document.getElementById('column-' + key); const input = document.getElementById('column-' + key);
if (input) { if (input) {
@@ -1361,86 +1343,19 @@ const queryLogHTML = `<!doctype html>
} }
} }
function applyVisibleColumns() {
for (const key of Object.keys(defaultVisibleColumns)) {
const visible = visibleColumns[key] !== false;
document.querySelectorAll('[data-column="' + key + '"]').forEach(node => {
node.style.display = visible ? '' : 'none';
});
}
}
function visibleColumnCount() {
let total = 2;
for (const key of Object.keys(defaultVisibleColumns)) {
if (visibleColumns[key] !== false) {
total += 1;
}
}
return total;
}
function applyControls() { function applyControls() {
document.getElementById('source-filter').value = sourceFilter; document.getElementById('source-filter').value = sourceFilter;
document.getElementById('method-filter').value = methodFilter; document.getElementById('method-filter').value = methodFilter;
document.getElementById('status-filter').value = statusFilter; document.getElementById('status-filter').value = statusFilter;
document.getElementById('state-filter').value = stateFilter; document.getElementById('state-filter').value = stateFilter;
document.getElementById('bot-filter').value = botFilter; 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('page-size').value = pageSize;
document.getElementById('auto-refresh-toggle').checked = autoRefresh; document.getElementById('auto-refresh-toggle').checked = autoRefresh;
document.getElementById('options-panel').open = panelOpen; document.getElementById('options-panel').open = panelOpen;
applyColumnControls(); syncColumnControls();
applyVisibleColumns();
updateSortIndicators();
updateControlsSummary(); 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); }
const hiddenColumns = Object.keys(defaultVisibleColumns).filter(key => visibleColumns[key] === false);
if (hiddenColumns.length) { parts.push('hidden=' + hiddenColumns.join(',')); }
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.pageSizeV2', pageSize);
saveBooleanPreference('cob.requests.autoRefresh', autoRefresh);
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
saveColumnPreferences();
}
function readColumnControls() { function readColumnControls() {
for (const key of Object.keys(defaultVisibleColumns)) { for (const key of Object.keys(defaultVisibleColumns)) {
const input = document.getElementById('column-' + key); const input = document.getElementById('column-' + key);
@@ -1456,79 +1371,91 @@ const queryLogHTML = `<!doctype html>
statusFilter = document.getElementById('status-filter').value.trim(); statusFilter = document.getElementById('status-filter').value.trim();
stateFilter = document.getElementById('state-filter').value; stateFilter = document.getElementById('state-filter').value;
botFilter = document.getElementById('bot-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; pageSize = document.getElementById('page-size').value;
autoRefresh = document.getElementById('auto-refresh-toggle').checked; autoRefresh = document.getElementById('auto-refresh-toggle').checked;
readColumnControls(); readColumnControls();
} }
function applyColumnChanges() { function saveControls() {
readColumnControls(); saveStringPreference('cob.requests.source', sourceFilter);
saveControls(); saveStringPreference('cob.requests.method', methodFilter);
applyVisibleColumns(); saveStringPreference('cob.requests.status', statusFilter);
updateControlsSummary(); saveStringPreference('cob.requests.state', stateFilter);
syncEmptyStateColspan(); saveStringPreference('cob.requests.botFilter', botFilter);
saveStringPreference('cob.requests.pageSizeTabulator', pageSize);
saveBooleanPreference('cob.requests.autoRefresh', autoRefresh);
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
saveColumnPreferences();
} }
function applyFilters(event) { function updateControlsSummary() {
if (event) { const parts = [];
event.preventDefault(); 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); }
const hidden = Object.keys(defaultVisibleColumns).filter(key => visibleColumns[key] === false);
if (hidden.length > 0) { parts.push('hidden=' + hidden.join(',')); }
const sort = currentSort();
if (sort.field) { parts.push('sort=' + sort.field + ' ' + sort.dir); }
parts.push('page size=' + pageSize);
if (autoRefresh) { parts.push('auto refresh'); }
document.getElementById('controls-summary').textContent = parts.length ? parts.join(' · ') : 'No active filters.';
}
function applyColumnPreferences() {
if (!table) {
return;
}
for (const key of Object.keys(defaultVisibleColumns)) {
const column = table.getColumn(key);
if (!column) {
continue;
}
if (visibleColumns[key] === false) {
column.hide();
} else {
column.show();
}
} }
readControls();
currentPage = 1;
saveControls();
updateSortIndicators();
updateControlsSummary();
refresh();
} }
function resetControls() { function buildAjaxURL(url, config, params) {
sourceFilter = ''; const query = new URLSearchParams();
methodFilter = ''; query.set('hours', String(recentHours));
statusFilter = ''; query.set('page', String(params.page || 1));
stateFilter = ''; query.set('limit', String(pageSize));
botFilter = 'all'; if (sourceFilter) { query.set('source', sourceFilter); }
sortBy = 'time'; if (methodFilter) { query.set('method', methodFilter); }
sortDir = 'desc'; if (statusFilter) { query.set('status', statusFilter); }
pageSize = '25'; if (stateFilter) { query.set('state', stateFilter); }
autoRefresh = false; if (botFilter && botFilter !== 'all') { query.set('bot_filter', botFilter); }
visibleColumns = { ...defaultVisibleColumns }; const sorters = Array.isArray(params.sorters) ? params.sorters : [];
saveControls(); if (sorters.length > 0) {
applyControls(); query.set('sort_by', String(sorters[0].field || 'time'));
currentPage = 1; query.set('sort_dir', String(sorters[0].dir || 'desc'));
refresh();
}
function defaultSortDirection(field) {
return field === 'time' || field === 'status' ? 'desc' : 'asc';
}
function applySort(field) {
if (sortBy === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else { } else {
sortBy = field; const sort = currentSort();
sortDir = defaultSortDirection(field); query.set('sort_by', sort.field || 'time');
query.set('sort_dir', sort.dir || 'desc');
} }
saveControls(); return url + '?' + query.toString();
updateSortIndicators();
updateControlsSummary();
currentPage = 1;
refresh();
} }
function updatePager(payload) { async function ajaxRequest(url, config, params) {
const page = Number(payload.page || currentPage || 1); const response = await fetch(buildAjaxURL(url, config, params), {
document.querySelectorAll('[data-page-status]').forEach(node => { method: 'GET',
node.textContent = 'Page ' + page + ' · ' + pageSize + ' rows'; headers: { 'Accept': 'application/json' },
});
document.querySelectorAll('[data-prev-page]').forEach(node => {
node.disabled = !payload.has_prev;
});
document.querySelectorAll('[data-next-page]').forEach(node => {
node.disabled = !payload.has_next;
}); });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = payload.error || response.statusText || 'Request failed';
document.getElementById('table-status').textContent = message;
throw new Error(message);
}
document.getElementById('table-status').textContent = '';
return payload;
} }
function scheduleRefresh() { function scheduleRefresh() {
@@ -1537,7 +1464,7 @@ const queryLogHTML = `<!doctype html>
refreshTimer = null; refreshTimer = null;
} }
if (autoRefresh) { if (autoRefresh) {
refreshTimer = window.setTimeout(refresh, 5000); refreshTimer = window.setTimeout(refreshNow, 5000);
} }
} }
@@ -1548,21 +1475,58 @@ const queryLogHTML = `<!doctype html>
scheduleRefresh(); scheduleRefresh();
} }
function goToPreviousPage() { function refreshNow() {
if (currentPage <= 1) { if (!table) {
return; return;
} }
currentPage -= 1; const currentPage = Number(table.getPage() || 1);
refresh(); table.setPage(currentPage);
} }
function goToNextPage() { function applyFilters(event) {
currentPage += 1; if (event) {
refresh(); event.preventDefault();
}
readControls();
saveControls();
updateControlsSummary();
if (!table) {
return;
}
table.setPageSize(Number(pageSize));
applyColumnPreferences();
table.setPage(1);
} }
function refreshNow() { function applyColumnChanges() {
refresh(); readColumnControls();
saveControls();
applyColumnPreferences();
updateControlsSummary();
}
function resetControls() {
sourceFilter = '';
methodFilter = '';
statusFilter = '';
stateFilter = '';
botFilter = 'all';
pageSize = '25';
autoRefresh = false;
visibleColumns = { ...defaultVisibleColumns };
saveStringPreference('cob.requests.sortField', 'time');
saveStringPreference('cob.requests.sortDirTabulator', 'desc');
saveControls();
applyControls();
if (!table) {
return;
}
table.clearSort();
table.setSort([{ column: 'time', dir: 'desc' }]);
table.setPageSize(25);
applyColumnPreferences();
table.setPage(1);
scheduleRefresh();
} }
async function sendAction(ip, action, promptLabel) { async function sendAction(ip, action, promptLabel) {
@@ -1575,88 +1539,131 @@ const queryLogHTML = `<!doctype html>
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, actor: 'web-ui' }), body: JSON.stringify({ reason, actor: 'web-ui' }),
}); });
const payload = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText })); window.alert(payload.error || response.statusText || 'Request failed');
window.alert(payload.error || 'Request failed');
return; return;
} }
refresh(); refreshNow();
} }
function syncEmptyStateColspan() { function timeFormatter(cell) {
const cell = document.querySelector('#events-body tr td'); return '<span class="mono">' + escapeHtml(formatDate(cell.getRow().getData().occurred_at)) + '</span>';
if (cell && cell.colSpan) {
cell.colSpan = visibleColumnCount();
}
} }
function renderEmptyState(message) { function ipFormatter(cell) {
document.getElementById('events-body').innerHTML = '<tr><td colspan="' + visibleColumnCount() + '" class="muted">' + escapeHtml(message) + '</td></tr>'; const data = cell.getRow().getData();
applyVisibleColumns(); const ip = data.client_ip || '—';
return '<div class="ip-cell">' + renderBotChip(data.bot) + '<a class="ip-link mono" href="/ips/' + encodeURIComponent(ip) + '" title="' + escapeHtml(ip) + '">' + escapeHtml(ip) + '</a></div>';
} }
function renderEvents(payload) { function methodFormatter(cell) {
const items = Array.isArray(payload.items) ? payload.items : []; const value = cell.getRow().getData().method || 'OTHER';
const rows = items.map(item => { return '<span class="method-pill ' + escapeHtml(methodClass(value)) + '">' + escapeHtml(value) + '</span>';
const requestLabel = item.uri || '—'; }
return [
'<tr>', function requestFormatter(cell) {
' <td class="col-time" data-column="time">' + escapeHtml(formatDate(item.occurred_at)) + '</td>', const value = cell.getRow().getData().uri || '—';
' <td class="col-ip mono" data-column="ip"><div class="ip-cell">' + renderBotChip(item.bot) + '<a class="ip-link" href="/ips/' + encodeURIComponent(item.client_ip) + '" title="' + escapeHtml(item.client_ip || '—') + '">' + escapeHtml(item.client_ip || '—') + '</a></div></td>', return '<span class="request-text mono" title="' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
' <td class="col-method" data-column="method"><span class="method ' + escapeHtml(methodClass(item.method)) + '">' + escapeHtml(item.method || 'OTHER') + '</span></td>', }
' <td class="col-source" data-column="source">' + escapeHtml(item.source_name || '—') + '</td>',
' <td class="col-request mono" data-column="request"><span class="request-text" title="' + escapeHtml(requestLabel) + '">' + escapeHtml(requestLabel) + '</span></td>', function statusFormatter(cell) {
' <td class="col-status" data-column="status"><span class="status-code ' + escapeHtml(statusCodeClass(item.status)) + '">' + escapeHtml(String(item.status || 0)) + '</span></td>', const value = Number(cell.getRow().getData().status || 0);
' <td class="col-state" data-column="state"><span class="status ' + escapeHtml(item.current_state || 'observed') + '">' + escapeHtml(item.current_state || 'observed') + '</span></td>', return '<span class="status-pill ' + escapeHtml(statusCodeClass(value)) + '">' + escapeHtml(String(value || 0)) + '</span>';
' <td class="col-reason" data-column="reason"><span class="reason-text" title="' + escapeHtml(item.decision_reason || '—') + '">' + escapeHtml(item.decision_reason || '—') + '</span></td>', }
' <td class="col-actions" data-column="actions">' + renderActions(item) + '</td>',
'</tr>' function stateFormatter(cell) {
].join(''); const value = String(cell.getRow().getData().current_state || 'observed');
return '<span class="state-pill ' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
}
function reasonFormatter(cell) {
const value = cell.getRow().getData().decision_reason || '—';
return '<span class="reason-text" title="' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
}
function actionFormatter(cell) {
return renderActions(cell.getRow().getData());
}
function createTable() {
table = new Tabulator('#requests-table', {
ajaxURL: '/api/events',
ajaxRequestFunc: ajaxRequest,
ajaxResponse: function(url, params, response) {
scheduleRefresh();
updateControlsSummary();
return response.data || response.items || [];
},
layout: 'fitColumns',
responsiveLayout: 'hide',
responsiveLayoutCollapseUseFormatters: false,
renderVertical: 'virtual',
placeholder: 'No requests match the current filters in the last 24 hours.',
pagination: true,
paginationMode: 'remote',
paginationInitialPage: 1,
paginationSize: Number(pageSize),
paginationSizeSelector: false,
paginationCounter: 'rows',
sortMode: 'remote',
headerSortTristate: false,
initialSort: [{
column: loadStringPreference('cob.requests.sortField', 'time'),
dir: loadStringPreference('cob.requests.sortDirTabulator', 'desc'),
}],
columns: [
{ title: 'Time', field: 'time', headerSort: true, formatter: timeFormatter, width: 184, minWidth: 164, responsive: 0 },
{ title: 'IP', field: 'ip', headerSort: true, formatter: ipFormatter, width: 220, minWidth: 190, responsive: 0 },
{ title: 'Method', field: 'method', headerSort: true, formatter: methodFormatter, width: 96, minWidth: 88, responsive: 4 },
{ title: 'Source', field: 'source', headerSort: true, formatter: function(cell) { return escapeHtml(cell.getRow().getData().source_name || '—'); }, width: 120, minWidth: 104, responsive: 5 },
{ title: 'Request', field: 'request', headerSort: true, formatter: requestFormatter, minWidth: 220, widthGrow: 4, responsive: 0 },
{ title: 'Status', field: 'status', headerSort: true, formatter: statusFormatter, hozAlign: 'center', width: 92, minWidth: 88, responsive: 2 },
{ title: 'State', field: 'state', headerSort: true, formatter: stateFormatter, width: 112, minWidth: 104, responsive: 6 },
{ title: 'Reason', field: 'reason', headerSort: true, formatter: reasonFormatter, minWidth: 180, widthGrow: 2, responsive: 7 },
{ title: '', field: 'actions', headerSort: false, formatter: actionFormatter, hozAlign: 'center', width: 62, minWidth: 58, responsive: 1 },
],
}); });
if (rows.length) {
document.getElementById('events-body').innerHTML = rows.join('');
applyVisibleColumns();
} else {
renderEmptyState('No requests match the current filters in the last 24 hours.');
}
updatePager(payload || {});
}
async function refresh() { table.on('tableBuilt', function() {
const params = new URLSearchParams(); applyColumnPreferences();
params.set('hours', String(recentHours)); updateControlsSummary();
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) {
renderEmptyState(payload.error || response.statusText);
updatePager({ page: currentPage, has_prev: currentPage > 1, has_next: false });
scheduleRefresh(); scheduleRefresh();
return; });
}
currentPage = Number(payload.page || currentPage || 1); table.on('sortChanged', function(sorters) {
renderEvents(payload); if (Array.isArray(sorters) && sorters.length > 0) {
scheduleRefresh(); saveStringPreference('cob.requests.sortField', String(sorters[0].field || 'time'));
saveStringPreference('cob.requests.sortDirTabulator', String(sorters[0].dir || 'desc'));
} else {
saveStringPreference('cob.requests.sortField', 'time');
saveStringPreference('cob.requests.sortDirTabulator', 'desc');
}
updateControlsSummary();
scheduleRefresh();
});
table.on('pageLoaded', function() {
scheduleRefresh();
});
table.on('dataLoadError', function(error) {
document.getElementById('table-status').textContent = String(error || 'Unable to load requests.');
scheduleRefresh();
});
} }
document.getElementById('options-panel').addEventListener('toggle', () => { document.getElementById('options-panel').addEventListener('toggle', function() {
panelOpen = document.getElementById('options-panel').open; panelOpen = document.getElementById('options-panel').open;
saveBooleanPreference('cob.requests.panelOpen', panelOpen); saveBooleanPreference('cob.requests.panelOpen', panelOpen);
}); });
applyControls(); applyControls();
refresh(); createTable();
</script> </script>
</body> </body>
</html>` </html>`
const ipDetailsHTML = `<!doctype html> const ipDetailsHTML = `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>

View File

@@ -69,6 +69,9 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if eventPage.Page != 2 || !eventPage.HasPrev { if eventPage.Page != 2 || !eventPage.HasPrev {
t.Fatalf("unexpected event page payload: %+v", eventPage) t.Fatalf("unexpected event page payload: %+v", eventPage)
} }
if eventPage.LastPage != 2 || eventPage.TotalItems != 251 || len(eventPage.Data) != len(eventPage.Items) {
t.Fatalf("event page should expose tabulator pagination metadata: %+v", eventPage)
}
if app.lastEventOptions.Offset != 250 || if app.lastEventOptions.Offset != 250 ||
app.lastEventOptions.Source != "main" || app.lastEventOptions.Source != "main" ||
app.lastEventOptions.Method != "GET" || app.lastEventOptions.Method != "GET" ||
@@ -166,7 +169,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
t.Fatalf("unexpected requests log page status: %d", recorder.Code) t.Fatalf("unexpected requests log page status: %d", recorder.Code)
} }
queryLogBody := recorder.Body.String() queryLogBody := recorder.Body.String()
if !strings.Contains(queryLogBody, "Filters, sorting, and pagination") { if !strings.Contains(queryLogBody, "Filters, pagination, and columns") {
t.Fatalf("requests log page should expose the collapsible controls panel") t.Fatalf("requests log page should expose the collapsible controls panel")
} }
if !strings.Contains(queryLogBody, `<select id="source-filter">`) || !strings.Contains(queryLogBody, `<option value="main">main</option>`) { if !strings.Contains(queryLogBody, `<select id="source-filter">`) || !strings.Contains(queryLogBody, `<option value="main">main</option>`) {
@@ -187,20 +190,23 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(queryLogBody, `id="column-source"`) || !strings.Contains(queryLogBody, `id="column-reason"`) { if !strings.Contains(queryLogBody, `id="column-source"`) || !strings.Contains(queryLogBody, `id="column-reason"`) {
t.Fatalf("requests log page should expose column visibility controls") t.Fatalf("requests log page should expose column visibility controls")
} }
if !strings.Contains(queryLogBody, "Request") { if !strings.Contains(queryLogBody, `id="requests-table"`) {
t.Fatalf("requests log page should render the request table") t.Fatalf("requests log page should render the tabulator mount point")
} }
if !strings.Contains(queryLogBody, "Auto refresh") { if !strings.Contains(queryLogBody, "Auto refresh") {
t.Fatalf("requests log page should expose the auto refresh toggle") t.Fatalf("requests log page should expose the auto refresh toggle")
} }
if !strings.Contains(queryLogBody, "onclick=\"applySort('status')\"") { if !strings.Contains(queryLogBody, `/assets/tabulator/tabulator.min.js`) || !strings.Contains(queryLogBody, `/assets/tabulator/tabulator_midnight.min.css`) {
t.Fatalf("requests log page should expose clickable sortable columns") t.Fatalf("requests log page should load local tabulator assets")
} }
if !strings.Contains(queryLogBody, "Source") || !strings.Contains(queryLogBody, "Bots") || !strings.Contains(queryLogBody, "HTTP status") { 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") t.Fatalf("requests log page should expose source, bot, and status filters")
} }
if !strings.Contains(queryLogBody, "Previous") || !strings.Contains(queryLogBody, "Next") { recorder = httptest.NewRecorder()
t.Fatalf("requests log page should expose pagination controls") request = httptest.NewRequest(http.MethodGet, "/assets/tabulator/tabulator.min.js", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.Len() == 0 {
t.Fatalf("tabulator asset should be served locally: status=%d len=%d", recorder.Code, recorder.Body.Len())
} }
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
@@ -239,6 +245,10 @@ type stubApp struct {
lastEventOptions model.EventListOptions lastEventOptions model.EventListOptions
} }
func (s *stubApp) CountEvents(context.Context, time.Time, model.EventListOptions) (int64, error) {
return 251, nil
}
func (s *stubApp) ListSourceNames() []string { func (s *stubApp) ListSourceNames() []string {
return []string{"gitea", "main"} return []string{"gitea", "main"}
} }