You've already forked caddy-opnsense-blocker
Use Tabulator in IP detail lists
This commit is contained in:
@@ -426,6 +426,7 @@ const overviewHTML = `<!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; }
|
||||
@@ -1817,6 +1818,7 @@ const ipDetailsHTML = `<!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: #020617; color: #e2e8f0; }
|
||||
@@ -1852,19 +1854,44 @@ const ipDetailsHTML = `<!doctype html>
|
||||
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
|
||||
button.secondary { background: #475569; }
|
||||
button.danger { background: #dc2626; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: .55rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
|
||||
th { color: #93c5fd; }
|
||||
.detail-table { border: 1px solid #1e293b; border-radius: .75rem; overflow: hidden; width: 100%; }
|
||||
.tabulator { background: transparent; border: 0; font-size: .92rem; width: 100% !important; }
|
||||
.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: .7rem .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-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; }
|
||||
.mono { font-family: ui-monospace, monospace; }
|
||||
a { color: #93c5fd; text-decoration: none; }
|
||||
.hint { font-size: .9rem; color: #94a3b8; margin-top: .75rem; }
|
||||
.cell-truncate { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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-code { display: inline-block; font-size: .84rem; font-weight: 700; color: #e2e8f0; }
|
||||
.status-code.client-error { color: #facc15; }
|
||||
.status-code.server-error { color: #fb923c; }
|
||||
.result-pill { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .78rem; font-weight: 700; background: #334155; color: #e2e8f0; }
|
||||
.result-pill.done, .result-pill.ok, .result-pill.success { background: #14532d; color: #dcfce7; }
|
||||
.result-pill.error, .result-pill.failed { background: #7f1d1d; color: #fecaca; }
|
||||
@media (max-width: 720px) {
|
||||
header { padding: .9rem 1rem; }
|
||||
main { padding: 1rem; gap: 1rem; }
|
||||
.panel { padding: .85rem; -webkit-overflow-scrolling: touch; }
|
||||
table { min-width: 720px; }
|
||||
.actions { width: 100%; }
|
||||
button { min-height: 2.2rem; }
|
||||
.tabulator { font-size: .88rem; }
|
||||
.tabulator .tabulator-header .tabulator-col .tabulator-col-content,
|
||||
.tabulator .tabulator-cell { padding-left: .5rem; padding-right: .5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -1886,34 +1913,21 @@ const ipDetailsHTML = `<!doctype html>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Decisions</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Kind</th><th>Action</th><th>Reason</th><th>Actor</th></tr>
|
||||
</thead>
|
||||
<tbody id="decisions-body"></tbody>
|
||||
</table>
|
||||
<div id="decisions-table" class="detail-table"></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Requests from this IP</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Source</th><th>Host</th><th>Method</th><th>URI</th><th>Status</th><th>Decision</th><th>User agent</th></tr>
|
||||
</thead>
|
||||
<tbody id="events-body"></tbody>
|
||||
</table>
|
||||
<div id="events-table" class="detail-table"></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Backend actions</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Action</th><th>Result</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody id="backend-body"></tbody>
|
||||
</table>
|
||||
<div id="backend-table" class="detail-table"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/tabulator/tabulator.min.js"></script>
|
||||
<script>
|
||||
const ip = document.body.dataset.ip || '';
|
||||
const detailTables = { events: null, decisions: null, backend: null };
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||
@@ -1926,6 +1940,107 @@ const ipDetailsHTML = `<!doctype html>
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function methodClass(method) {
|
||||
const normalized = String(method || '').toLowerCase();
|
||||
if (normalized === 'get' || normalized === 'post' || normalized === 'head') {
|
||||
return normalized;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function statusCodeClass(status) {
|
||||
const code = Number(status || 0);
|
||||
if (code >= 500) {
|
||||
return 'server-error';
|
||||
}
|
||||
if (code >= 400) {
|
||||
return 'client-error';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resultClass(result) {
|
||||
return String(result || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
||||
}
|
||||
|
||||
function destroyDetailTable(key) {
|
||||
if (detailTables[key]) {
|
||||
detailTables[key].destroy();
|
||||
detailTables[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
function upsertDetailTable(key, targetId, columns, rows, emptyText, options) {
|
||||
const host = document.getElementById(targetId);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
if (typeof Tabulator === 'undefined') {
|
||||
host.className = 'detail-table';
|
||||
host.innerHTML = '<div class="tabulator-placeholder">Tabulator failed to load.</div>';
|
||||
return;
|
||||
}
|
||||
const settings = options || {};
|
||||
if (!detailTables[key]) {
|
||||
detailTables[key] = new Tabulator('#' + targetId, {
|
||||
data: Array.isArray(rows) ? rows : [],
|
||||
index: 'id',
|
||||
layout: 'fitColumns',
|
||||
responsiveLayout: 'collapse',
|
||||
responsiveLayoutCollapseStartOpen: false,
|
||||
responsiveLayoutCollapseUseFormatters: false,
|
||||
placeholder: emptyText,
|
||||
pagination: true,
|
||||
paginationMode: 'local',
|
||||
paginationSize: settings.pageSize || 10,
|
||||
paginationSizeSelector: [10, 25, 50],
|
||||
paginationCounter: 'rows',
|
||||
initialSort: settings.initialSort || [],
|
||||
columnDefaults: {
|
||||
headerSortTristate: false,
|
||||
},
|
||||
columns,
|
||||
});
|
||||
return;
|
||||
}
|
||||
detailTables[key].setData(Array.isArray(rows) ? rows : []);
|
||||
}
|
||||
|
||||
function textCell(value, className) {
|
||||
const classes = ['cell-truncate'];
|
||||
if (className) {
|
||||
classes.push(className);
|
||||
}
|
||||
const safeValue = value || '—';
|
||||
return '<span class="' + classes.join(' ') + '" title="' + escapeHtml(safeValue) + '">' + escapeHtml(safeValue) + '</span>';
|
||||
}
|
||||
|
||||
function dateFormatter(cell) {
|
||||
return textCell(formatDate(cell.getValue()), 'mono');
|
||||
}
|
||||
|
||||
function methodFormatter(cell) {
|
||||
const value = cell.getValue() || 'OTHER';
|
||||
return '<span class="method-pill ' + escapeHtml(methodClass(value)) + '">' + escapeHtml(value) + '</span>';
|
||||
}
|
||||
|
||||
function statusFormatter(cell) {
|
||||
const value = Number(cell.getValue() || 0);
|
||||
return '<span class="status-code ' + escapeHtml(statusCodeClass(value)) + '">' + escapeHtml(String(value || 0)) + '</span>';
|
||||
}
|
||||
|
||||
function eventDecisionFormatter(cell) {
|
||||
const row = cell.getRow().getData();
|
||||
const value = row.decision || 'none';
|
||||
const suffix = row.enforced ? ' · enforced' : '';
|
||||
return textCell(value + suffix);
|
||||
}
|
||||
|
||||
function resultFormatter(cell) {
|
||||
const value = cell.getValue() || '—';
|
||||
return '<span class="result-pill ' + escapeHtml(resultClass(value)) + '">' + escapeHtml(value) + '</span>';
|
||||
}
|
||||
|
||||
function botVisual(bot) {
|
||||
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
|
||||
const catalog = [
|
||||
@@ -2056,44 +2171,44 @@ const ipDetailsHTML = `<!doctype html>
|
||||
}
|
||||
|
||||
function renderEvents(items) {
|
||||
const rows = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.source_name) + '</td>',
|
||||
' <td>' + escapeHtml(item.host) + '</td>',
|
||||
' <td>' + escapeHtml(item.method) + '</td>',
|
||||
' <td class="mono">' + escapeHtml(item.uri || item.path) + '</td>',
|
||||
' <td>' + escapeHtml(item.status) + '</td>',
|
||||
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>',
|
||||
' <td>' + escapeHtml(item.user_agent || '—') + '</td>',
|
||||
'</tr>'
|
||||
].join(''));
|
||||
document.getElementById('events-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="8" class="muted">No requests stored for this IP yet.</td></tr>';
|
||||
upsertDetailTable('events', 'events-table', [
|
||||
{ title: 'Time', field: 'occurred_at', formatter: dateFormatter, sorter: 'datetime', width: 190, minWidth: 170, responsive: 0 },
|
||||
{ title: 'Source', field: 'source_name', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 4 },
|
||||
{ title: 'Host', field: 'host', formatter: function(cell) { return textCell(cell.getValue()); }, width: 180, minWidth: 140, responsive: 5 },
|
||||
{ title: 'Method', field: 'method', formatter: methodFormatter, width: 92, minWidth: 84, hozAlign: 'center', responsive: 2 },
|
||||
{ title: 'Request', field: 'uri', formatter: function(cell) { const row = cell.getRow().getData(); return textCell(row.uri || row.path, 'mono'); }, minWidth: 220, widthGrow: 3, responsive: 0 },
|
||||
{ title: 'Status', field: 'status', formatter: statusFormatter, hozAlign: 'center', width: 78, minWidth: 72, responsive: 1 },
|
||||
{ title: 'Decision', field: 'decision', formatter: eventDecisionFormatter, minWidth: 130, widthGrow: 1, responsive: 6 },
|
||||
{ title: 'User agent', field: 'user_agent', formatter: function(cell) { return textCell(cell.getValue()); }, minWidth: 220, widthGrow: 2, responsive: 7 },
|
||||
], items, 'No requests stored for this IP yet.', {
|
||||
pageSize: 10,
|
||||
initialSort: [{ column: 'occurred_at', dir: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
function renderDecisions(items) {
|
||||
const rows = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.kind) + '</td>',
|
||||
' <td>' + escapeHtml(item.action) + '</td>',
|
||||
' <td>' + escapeHtml(item.reason) + '</td>',
|
||||
' <td>' + escapeHtml(item.actor) + '</td>',
|
||||
'</tr>'
|
||||
].join(''));
|
||||
document.getElementById('decisions-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="5" class="muted">No decisions recorded for this IP yet.</td></tr>';
|
||||
upsertDetailTable('decisions', 'decisions-table', [
|
||||
{ title: 'Time', field: 'created_at', formatter: dateFormatter, sorter: 'datetime', width: 190, minWidth: 170, responsive: 0 },
|
||||
{ title: 'Kind', field: 'kind', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 2 },
|
||||
{ title: 'Action', field: 'action', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 1 },
|
||||
{ title: 'Reason', field: 'reason', formatter: function(cell) { return textCell(cell.getValue()); }, minWidth: 220, widthGrow: 3, responsive: 0 },
|
||||
{ title: 'Actor', field: 'actor', formatter: function(cell) { return textCell(cell.getValue()); }, width: 120, minWidth: 110, responsive: 3 },
|
||||
], items, 'No decisions recorded for this IP yet.', {
|
||||
pageSize: 8,
|
||||
initialSort: [{ column: 'created_at', dir: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
function renderBackend(items) {
|
||||
const rows = items.map(item => [
|
||||
'<tr>',
|
||||
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>',
|
||||
' <td>' + escapeHtml(item.action) + '</td>',
|
||||
' <td>' + escapeHtml(item.result) + '</td>',
|
||||
' <td>' + escapeHtml(item.message) + '</td>',
|
||||
'</tr>'
|
||||
].join(''));
|
||||
document.getElementById('backend-body').innerHTML = rows.length > 0 ? rows.join('') : '<tr><td colspan="4" class="muted">No backend actions recorded for this IP yet.</td></tr>';
|
||||
upsertDetailTable('backend', 'backend-table', [
|
||||
{ title: 'Time', field: 'created_at', formatter: dateFormatter, sorter: 'datetime', width: 190, minWidth: 170, responsive: 0 },
|
||||
{ title: 'Action', field: 'action', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 1 },
|
||||
{ title: 'Result', field: 'result', formatter: resultFormatter, width: 110, minWidth: 100, hozAlign: 'center', responsive: 2 },
|
||||
{ title: 'Message', field: 'message', formatter: function(cell) { return textCell(cell.getValue()); }, minWidth: 240, widthGrow: 3, responsive: 0 },
|
||||
], items, 'No backend actions recorded for this IP yet.', {
|
||||
pageSize: 8,
|
||||
initialSort: [{ column: 'created_at', dir: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
function renderAll(data) {
|
||||
|
||||
Reference in New Issue
Block a user