You've already forked caddy-opnsense-blocker
Adopt Tabulator for the requests log
This commit is contained in:
@@ -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,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
HasPrev: page > 1,
|
||||
HasNext: hasNext,
|
||||
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>
|
||||
<button class="secondary" type="button" onclick="refreshNow()">Refresh now</button>
|
||||
</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>
|
||||
</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) {
|
||||
localStorage.setItem(key, value ? 'true' : 'false');
|
||||
try {
|
||||
localStorage.setItem(key, value ? 'true' : 'false');
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
function saveStringPreference(key, value) {
|
||||
localStorage.setItem(key, value);
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadColumnPreferences() {
|
||||
@@ -1264,7 +1223,10 @@ const queryLogHTML = `<!doctype html>
|
||||
}
|
||||
|
||||
function saveColumnPreferences() {
|
||||
localStorage.setItem('cob.requests.visibleColumns', JSON.stringify(visibleColumns));
|
||||
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();
|
||||
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 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() {
|
||||
sourceFilter = '';
|
||||
methodFilter = '';
|
||||
statusFilter = '';
|
||||
stateFilter = '';
|
||||
botFilter = 'all';
|
||||
sortBy = 'time';
|
||||
sortDir = 'desc';
|
||||
pageSize = '25';
|
||||
autoRefresh = false;
|
||||
visibleColumns = { ...defaultVisibleColumns };
|
||||
saveControls();
|
||||
applyControls();
|
||||
currentPage = 1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function defaultSortDirection(field) {
|
||||
return field === 'time' || field === 'status' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function applySort(field) {
|
||||
if (sortBy === field) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
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 {
|
||||
sortBy = field;
|
||||
sortDir = defaultSortDirection(field);
|
||||
const sort = currentSort();
|
||||
query.set('sort_by', sort.field || 'time');
|
||||
query.set('sort_dir', sort.dir || 'desc');
|
||||
}
|
||||
saveControls();
|
||||
updateSortIndicators();
|
||||
updateControlsSummary();
|
||||
currentPage = 1;
|
||||
refresh();
|
||||
return url + '?' + query.toString();
|
||||
}
|
||||
|
||||
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;
|
||||
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();
|
||||
} else {
|
||||
renderEmptyState('No requests match the current filters in the last 24 hours.');
|
||||
}
|
||||
updatePager(payload || {});
|
||||
}
|
||||
|
||||
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 });
|
||||
table.on('tableBuilt', function() {
|
||||
applyColumnPreferences();
|
||||
updateControlsSummary();
|
||||
scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
currentPage = Number(payload.page || currentPage || 1);
|
||||
renderEvents(payload);
|
||||
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 {
|
||||
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;
|
||||
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
|
||||
});
|
||||
|
||||
applyControls();
|
||||
refresh();
|
||||
createTable();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const ipDetailsHTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
Reference in New Issue
Block a user