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.
- 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.
- 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.
- 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.

View File

@@ -287,8 +287,11 @@ type Overview struct {
type EventPage struct {
Items []Event `json:"items"`
Data []Event `json:"data"`
Page int `json:"page"`
Limit int `json:"limit"`
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
}
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 {
if s == nil || s.cfg == nil || len(s.cfg.Sources) == 0 {
return nil

View File

@@ -987,6 +987,31 @@ func (s *Store) ListEvents(ctx context.Context, since time.Time, limit int, opti
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) {
if limit <= 0 {
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 {
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)
CountEvents(ctx context.Context, since time.Time, options model.EventListOptions) (int64, error)
ListSourceNames() []string
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, 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/recent-ips", h.handleAPIRecentIPs)
mux.HandleFunc("/api/ips/", h.handleAPIIP)
mux.Handle("/assets/", assetHandler())
return mux
}
@@ -175,16 +177,28 @@ func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err)
return
}
totalItems, err := h.app.CountEvents(r.Context(), since, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
hasNext := len(events) > limit
if hasNext {
events = events[:limit]
}
lastPage := 1
if totalItems > 0 {
lastPage = int((totalItems + int64(limit) - 1) / int64(limit))
}
writeJSON(w, http.StatusOK, model.EventPage{
Items: events,
Data: events,
Page: page,
Limit: limit,
HasPrev: page > 1,
HasNext: hasNext,
LastPage: lastPage,
TotalItems: totalItems,
})
}
@@ -900,6 +914,7 @@ const queryLogHTML = `<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/assets/tabulator/tabulator_midnight.min.css">
<style>
:root { color-scheme: dark; }
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: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; 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; }
.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::-webkit-details-marker { display: none; }
.controls-panel[open] summary { border-bottom: 1px solid #334155; }
@@ -928,59 +940,24 @@ const queryLogHTML = `<!doctype html>
.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; }
.columns-field { grid-column: 1 / -1; }
.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 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-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); }
.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; }
.tabulator-shell { border: 1px solid #1e293b; border-radius: .75rem; overflow: hidden; }
#requests-table { width: 100%; }
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; }
.icon-button { min-width: 2.15rem; width: 2.15rem; height: 2.15rem; padding: 0; font-size: 1rem; }
.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; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.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.verified { border-color: #2563eb; }
.bot-chip.hint { border-style: dashed; }
@@ -996,19 +973,48 @@ const queryLogHTML = `<!doctype html>
.bot-chip.yandex { background: #dc2626; color: white; }
.bot-chip.baidu { background: #7c3aed; 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) {
.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; }
.toolbar-actions { justify-content: space-between; }
.pager { width: 100%; justify-content: space-between; }
.pager-button .button-label { display: none; }
.table-shell { overflow-x: auto; }
table { min-width: 980px; }
.filters-grid { grid-template-columns: 1fr; }
.toolbar-actions { justify-content: flex-start; }
.tabulator { font-size: .88rem; }
.tabulator .tabulator-header .tabulator-col .tabulator-col-content,
.tabulator .tabulator-cell { padding-left: .5rem; padding-right: .5rem; }
}
</style>
</head>
@@ -1027,9 +1033,9 @@ const queryLogHTML = `<!doctype html>
</header>
<main>
<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-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)">
<div class="field">
<label for="source-filter">Source</label>
@@ -1094,30 +1100,10 @@ const queryLogHTML = `<!doctype html>
<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="known">Known bots only</option>
<option value="possible">Possible bots only</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>
<option value="non-bot">Non-bots only</option>
</select>
</div>
<div class="field">
@@ -1136,7 +1122,6 @@ const queryLogHTML = `<!doctype html>
<div class="field columns-field">
<label>Columns</label>
<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-source" type="checkbox" onchange="applyColumnChanges()">Source</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>
<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 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>
<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 class="table-shell">
<table>
<thead>
<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>
<button class="secondary" type="button" onclick="refreshNow()">Refresh now</button>
</div>
</div>
<div class="muted" id="table-status"></div>
<div class="tabulator-shell">
<div id="requests-table"></div>
</div>
</section>
</main>
<script src="/assets/tabulator/tabulator.min.js"></script>
<script>
const recentHours = 24;
const defaultVisibleColumns = { method: true, source: true, status: true, state: true, reason: true, actions: true };
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');
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 pageSize = loadStringPreference('cob.requests.pageSizeTabulator', '25');
let autoRefresh = loadBooleanPreference('cob.requests.autoRefresh', false);
let panelOpen = loadBooleanPreference('cob.requests.panelOpen', false);
let currentPage = 1;
let visibleColumns = loadColumnPreferences();
let table = null;
let refreshTimer = null;
function loadBooleanPreference(key, fallback) {
@@ -1237,11 +1190,17 @@ const queryLogHTML = `<!doctype html>
}
function saveBooleanPreference(key, value) {
try {
localStorage.setItem(key, value ? 'true' : 'false');
} catch (error) {
}
}
function saveStringPreference(key, value) {
try {
localStorage.setItem(key, value);
} catch (error) {
}
}
function loadColumnPreferences() {
@@ -1264,7 +1223,10 @@ const queryLogHTML = `<!doctype html>
}
function saveColumnPreferences() {
try {
localStorage.setItem('cob.requests.visibleColumns', JSON.stringify(visibleColumns));
} catch (error) {
}
}
function escapeHtml(value) {
@@ -1333,12 +1295,12 @@ const queryLogHTML = `<!doctype html>
function renderActions(item) {
const actions = item.actions || {};
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) {
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) {
@@ -1352,7 +1314,27 @@ const queryLogHTML = `<!doctype html>
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)) {
const input = document.getElementById('column-' + key);
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() {
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;
applyColumnControls();
applyVisibleColumns();
updateSortIndicators();
syncColumnControls();
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() {
for (const key of Object.keys(defaultVisibleColumns)) {
const input = document.getElementById('column-' + key);
@@ -1456,79 +1371,91 @@ const queryLogHTML = `<!doctype html>
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;
readColumnControls();
}
function applyColumnChanges() {
readColumnControls();
saveControls();
applyVisibleColumns();
updateControlsSummary();
syncEmptyStateColspan();
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.pageSizeTabulator', pageSize);
saveBooleanPreference('cob.requests.autoRefresh', autoRefresh);
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
saveColumnPreferences();
}
function applyFilters(event) {
if (event) {
event.preventDefault();
}
readControls();
currentPage = 1;
saveControls();
updateSortIndicators();
updateControlsSummary();
refresh();
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 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 resetControls() {
sourceFilter = '';
methodFilter = '';
statusFilter = '';
stateFilter = '';
botFilter = 'all';
sortBy = 'time';
sortDir = 'desc';
pageSize = '25';
autoRefresh = false;
visibleColumns = { ...defaultVisibleColumns };
saveControls();
applyControls();
currentPage = 1;
refresh();
function applyColumnPreferences() {
if (!table) {
return;
}
function defaultSortDirection(field) {
return field === 'time' || field === 'status' ? 'desc' : 'asc';
for (const key of Object.keys(defaultVisibleColumns)) {
const column = table.getColumn(key);
if (!column) {
continue;
}
function applySort(field) {
if (sortBy === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
if (visibleColumns[key] === false) {
column.hide();
} else {
sortBy = field;
sortDir = defaultSortDirection(field);
column.show();
}
}
saveControls();
updateSortIndicators();
updateControlsSummary();
currentPage = 1;
refresh();
}
function updatePager(payload) {
const page = Number(payload.page || currentPage || 1);
document.querySelectorAll('[data-page-status]').forEach(node => {
node.textContent = 'Page ' + page + ' · ' + pageSize + ' rows';
});
document.querySelectorAll('[data-prev-page]').forEach(node => {
node.disabled = !payload.has_prev;
});
document.querySelectorAll('[data-next-page]').forEach(node => {
node.disabled = !payload.has_next;
function buildAjaxURL(url, config, params) {
const query = new URLSearchParams();
query.set('hours', String(recentHours));
query.set('page', String(params.page || 1));
query.set('limit', String(pageSize));
if (sourceFilter) { query.set('source', sourceFilter); }
if (methodFilter) { query.set('method', methodFilter); }
if (statusFilter) { query.set('status', statusFilter); }
if (stateFilter) { query.set('state', stateFilter); }
if (botFilter && botFilter !== 'all') { query.set('bot_filter', botFilter); }
const sorters = Array.isArray(params.sorters) ? params.sorters : [];
if (sorters.length > 0) {
query.set('sort_by', String(sorters[0].field || 'time'));
query.set('sort_dir', String(sorters[0].dir || 'desc'));
} else {
const sort = currentSort();
query.set('sort_by', sort.field || 'time');
query.set('sort_dir', sort.dir || 'desc');
}
return url + '?' + query.toString();
}
async function ajaxRequest(url, config, params) {
const response = await fetch(buildAjaxURL(url, config, params), {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
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() {
@@ -1537,7 +1464,7 @@ const queryLogHTML = `<!doctype html>
refreshTimer = null;
}
if (autoRefresh) {
refreshTimer = window.setTimeout(refresh, 5000);
refreshTimer = window.setTimeout(refreshNow, 5000);
}
}
@@ -1548,21 +1475,58 @@ const queryLogHTML = `<!doctype html>
scheduleRefresh();
}
function goToPreviousPage() {
if (currentPage <= 1) {
function refreshNow() {
if (!table) {
return;
}
currentPage -= 1;
refresh();
const currentPage = Number(table.getPage() || 1);
table.setPage(currentPage);
}
function goToNextPage() {
currentPage += 1;
refresh();
function applyFilters(event) {
if (event) {
event.preventDefault();
}
readControls();
saveControls();
updateControlsSummary();
if (!table) {
return;
}
table.setPageSize(Number(pageSize));
applyColumnPreferences();
table.setPage(1);
}
function refreshNow() {
refresh();
function applyColumnChanges() {
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) {
@@ -1575,88 +1539,131 @@ const queryLogHTML = `<!doctype html>
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, actor: 'web-ui' }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
window.alert(payload.error || 'Request failed');
window.alert(payload.error || response.statusText || 'Request failed');
return;
}
refresh();
refreshNow();
}
function syncEmptyStateColspan() {
const cell = document.querySelector('#events-body tr td');
if (cell && cell.colSpan) {
cell.colSpan = visibleColumnCount();
}
function timeFormatter(cell) {
return '<span class="mono">' + escapeHtml(formatDate(cell.getRow().getData().occurred_at)) + '</span>';
}
function renderEmptyState(message) {
document.getElementById('events-body').innerHTML = '<tr><td colspan="' + visibleColumnCount() + '" class="muted">' + escapeHtml(message) + '</td></tr>';
applyVisibleColumns();
function ipFormatter(cell) {
const data = cell.getRow().getData();
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) {
const items = Array.isArray(payload.items) ? payload.items : [];
const rows = items.map(item => {
const requestLabel = item.uri || '—';
return [
'<tr>',
' <td class="col-time" data-column="time">' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
' <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>',
' <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>',
' <td class="col-status" data-column="status"><span class="status-code ' + escapeHtml(statusCodeClass(item.status)) + '">' + escapeHtml(String(item.status || 0)) + '</span></td>',
' <td class="col-state" data-column="state"><span class="status ' + escapeHtml(item.current_state || 'observed') + '">' + escapeHtml(item.current_state || 'observed') + '</span></td>',
' <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>'
].join('');
function methodFormatter(cell) {
const value = cell.getRow().getData().method || 'OTHER';
return '<span class="method-pill ' + escapeHtml(methodClass(value)) + '">' + escapeHtml(value) + '</span>';
}
function requestFormatter(cell) {
const value = cell.getRow().getData().uri || '—';
return '<span class="request-text mono" title="' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
}
function statusFormatter(cell) {
const value = Number(cell.getRow().getData().status || 0);
return '<span class="status-pill ' + escapeHtml(statusCodeClass(value)) + '">' + escapeHtml(String(value || 0)) + '</span>';
}
function stateFormatter(cell) {
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();
table.on('tableBuilt', function() {
applyColumnPreferences();
updateControlsSummary();
scheduleRefresh();
});
table.on('sortChanged', function(sorters) {
if (Array.isArray(sorters) && sorters.length > 0) {
saveStringPreference('cob.requests.sortField', String(sorters[0].field || 'time'));
saveStringPreference('cob.requests.sortDirTabulator', String(sorters[0].dir || 'desc'));
} else {
renderEmptyState('No requests match the current filters in the last 24 hours.');
saveStringPreference('cob.requests.sortField', 'time');
saveStringPreference('cob.requests.sortDirTabulator', 'desc');
}
updatePager(payload || {});
updateControlsSummary();
scheduleRefresh();
});
table.on('pageLoaded', function() {
scheduleRefresh();
});
table.on('dataLoadError', function(error) {
document.getElementById('table-status').textContent = String(error || 'Unable to load requests.');
scheduleRefresh();
});
}
async function refresh() {
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) {
renderEmptyState(payload.error || response.statusText);
updatePager({ page: currentPage, has_prev: currentPage > 1, has_next: false });
scheduleRefresh();
return;
}
currentPage = Number(payload.page || currentPage || 1);
renderEvents(payload);
scheduleRefresh();
}
document.getElementById('options-panel').addEventListener('toggle', () => {
document.getElementById('options-panel').addEventListener('toggle', function() {
panelOpen = document.getElementById('options-panel').open;
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
});
applyControls();
refresh();
createTable();
</script>
</body>
</html>`
const ipDetailsHTML = `<!doctype html>
<html lang="en">
<head>

View File

@@ -69,6 +69,9 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if eventPage.Page != 2 || !eventPage.HasPrev {
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 ||
app.lastEventOptions.Source != "main" ||
app.lastEventOptions.Method != "GET" ||
@@ -166,7 +169,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
t.Fatalf("unexpected requests log page status: %d", recorder.Code)
}
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")
}
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"`) {
t.Fatalf("requests log page should expose column visibility controls")
}
if !strings.Contains(queryLogBody, "Request") {
t.Fatalf("requests log page should render the request table")
if !strings.Contains(queryLogBody, `id="requests-table"`) {
t.Fatalf("requests log page should render the tabulator mount point")
}
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, `/assets/tabulator/tabulator.min.js`) || !strings.Contains(queryLogBody, `/assets/tabulator/tabulator_midnight.min.css`) {
t.Fatalf("requests log page should load local tabulator assets")
}
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")
recorder = httptest.NewRecorder()
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()
@@ -239,6 +245,10 @@ type stubApp struct {
lastEventOptions model.EventListOptions
}
func (s *stubApp) CountEvents(context.Context, time.Time, model.EventListOptions) (int64, error) {
return 251, nil
}
func (s *stubApp) ListSourceNames() []string {
return []string{"gitea", "main"}
}