2

Use Tabulator in IP detail lists

This commit is contained in:
2026-03-12 22:09:16 +01:00
parent 735ae52905
commit a72348b214
2 changed files with 181 additions and 54 deletions

View File

@@ -426,6 +426,7 @@ const overviewHTML = `<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title> <title>{{ .Title }}</title>
<link rel="stylesheet" href="/assets/tabulator/tabulator_midnight.min.css">
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: linear-gradient(180deg, #0f172a 0%, #020617 100%); color: #e2e8f0; } body { font-family: system-ui, sans-serif; margin: 0; background: linear-gradient(180deg, #0f172a 0%, #020617 100%); color: #e2e8f0; }
@@ -1817,6 +1818,7 @@ const ipDetailsHTML = `<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title> <title>{{ .Title }}</title>
<link rel="stylesheet" href="/assets/tabulator/tabulator_midnight.min.css">
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: #020617; color: #e2e8f0; } 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 { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
button.secondary { background: #475569; } button.secondary { background: #475569; }
button.danger { background: #dc2626; } button.danger { background: #dc2626; }
table { width: 100%; border-collapse: collapse; } .detail-table { border: 1px solid #1e293b; border-radius: .75rem; overflow: hidden; width: 100%; }
th, td { padding: .55rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; } .tabulator { background: transparent; border: 0; font-size: .92rem; width: 100% !important; }
th { color: #93c5fd; } .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; } .mono { font-family: ui-monospace, monospace; }
a { color: #93c5fd; text-decoration: none; } a { color: #93c5fd; text-decoration: none; }
.hint { font-size: .9rem; color: #94a3b8; margin-top: .75rem; } .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) { @media (max-width: 720px) {
header { padding: .9rem 1rem; } header { padding: .9rem 1rem; }
main { padding: 1rem; gap: 1rem; } main { padding: 1rem; gap: 1rem; }
.panel { padding: .85rem; -webkit-overflow-scrolling: touch; } .panel { padding: .85rem; -webkit-overflow-scrolling: touch; }
table { min-width: 720px; }
.actions { width: 100%; } .actions { width: 100%; }
button { min-height: 2.2rem; } 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> </style>
</head> </head>
@@ -1886,34 +1913,21 @@ const ipDetailsHTML = `<!doctype html>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Decisions</h2> <h2>Decisions</h2>
<table> <div id="decisions-table" class="detail-table"></div>
<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>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Requests from this IP</h2> <h2>Requests from this IP</h2>
<table> <div id="events-table" class="detail-table"></div>
<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>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Backend actions</h2> <h2>Backend actions</h2>
<table> <div id="backend-table" class="detail-table"></div>
<thead>
<tr><th>Time</th><th>Action</th><th>Result</th><th>Message</th></tr>
</thead>
<tbody id="backend-body"></tbody>
</table>
</section> </section>
</main> </main>
<script src="/assets/tabulator/tabulator.min.js"></script>
<script> <script>
const ip = document.body.dataset.ip || ''; const ip = document.body.dataset.ip || '';
const detailTables = { events: null, decisions: null, backend: null };
function escapeHtml(value) { function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])); return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
@@ -1926,6 +1940,107 @@ const ipDetailsHTML = `<!doctype html>
return new Date(value).toLocaleString(); 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) { function botVisual(bot) {
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase(); const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
const catalog = [ const catalog = [
@@ -2056,44 +2171,44 @@ const ipDetailsHTML = `<!doctype html>
} }
function renderEvents(items) { function renderEvents(items) {
const rows = items.map(item => [ upsertDetailTable('events', 'events-table', [
'<tr>', { title: 'Time', field: 'occurred_at', formatter: dateFormatter, sorter: 'datetime', width: 190, minWidth: 170, responsive: 0 },
' <td>' + escapeHtml(formatDate(item.occurred_at)) + '</td>', { title: 'Source', field: 'source_name', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 4 },
' <td>' + escapeHtml(item.source_name) + '</td>', { title: 'Host', field: 'host', formatter: function(cell) { return textCell(cell.getValue()); }, width: 180, minWidth: 140, responsive: 5 },
' <td>' + escapeHtml(item.host) + '</td>', { title: 'Method', field: 'method', formatter: methodFormatter, width: 92, minWidth: 84, hozAlign: 'center', responsive: 2 },
' <td>' + escapeHtml(item.method) + '</td>', { 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 },
' <td class="mono">' + escapeHtml(item.uri || item.path) + '</td>', { title: 'Status', field: 'status', formatter: statusFormatter, hozAlign: 'center', width: 78, minWidth: 72, responsive: 1 },
' <td>' + escapeHtml(item.status) + '</td>', { title: 'Decision', field: 'decision', formatter: eventDecisionFormatter, minWidth: 130, widthGrow: 1, responsive: 6 },
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>', { title: 'User agent', field: 'user_agent', formatter: function(cell) { return textCell(cell.getValue()); }, minWidth: 220, widthGrow: 2, responsive: 7 },
' <td>' + escapeHtml(item.user_agent || '—') + '</td>', ], items, 'No requests stored for this IP yet.', {
'</tr>' pageSize: 10,
].join('')); initialSort: [{ column: 'occurred_at', dir: 'desc' }],
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>'; });
} }
function renderDecisions(items) { function renderDecisions(items) {
const rows = items.map(item => [ upsertDetailTable('decisions', 'decisions-table', [
'<tr>', { title: 'Time', field: 'created_at', formatter: dateFormatter, sorter: 'datetime', width: 190, minWidth: 170, responsive: 0 },
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>', { title: 'Kind', field: 'kind', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 2 },
' <td>' + escapeHtml(item.kind) + '</td>', { title: 'Action', field: 'action', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 1 },
' <td>' + escapeHtml(item.action) + '</td>', { title: 'Reason', field: 'reason', formatter: function(cell) { return textCell(cell.getValue()); }, minWidth: 220, widthGrow: 3, responsive: 0 },
' <td>' + escapeHtml(item.reason) + '</td>', { title: 'Actor', field: 'actor', formatter: function(cell) { return textCell(cell.getValue()); }, width: 120, minWidth: 110, responsive: 3 },
' <td>' + escapeHtml(item.actor) + '</td>', ], items, 'No decisions recorded for this IP yet.', {
'</tr>' pageSize: 8,
].join('')); initialSort: [{ column: 'created_at', dir: 'desc' }],
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>'; });
} }
function renderBackend(items) { function renderBackend(items) {
const rows = items.map(item => [ upsertDetailTable('backend', 'backend-table', [
'<tr>', { title: 'Time', field: 'created_at', formatter: dateFormatter, sorter: 'datetime', width: 190, minWidth: 170, responsive: 0 },
' <td>' + escapeHtml(formatDate(item.created_at)) + '</td>', { title: 'Action', field: 'action', formatter: function(cell) { return textCell(cell.getValue()); }, width: 110, minWidth: 100, responsive: 1 },
' <td>' + escapeHtml(item.action) + '</td>', { title: 'Result', field: 'result', formatter: resultFormatter, width: 110, minWidth: 100, hozAlign: 'center', responsive: 2 },
' <td>' + escapeHtml(item.result) + '</td>', { title: 'Message', field: 'message', formatter: function(cell) { return textCell(cell.getValue()); }, minWidth: 240, widthGrow: 3, responsive: 0 },
' <td>' + escapeHtml(item.message) + '</td>', ], items, 'No backend actions recorded for this IP yet.', {
'</tr>' pageSize: 8,
].join('')); initialSort: [{ column: 'created_at', dir: 'desc' }],
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>'; });
} }
function renderAll(data) { function renderAll(data) {

View File

@@ -241,6 +241,18 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(body, `data-ip="203.0.113.10"`) { if !strings.Contains(body, `data-ip="203.0.113.10"`) {
t.Fatalf("ip details page did not expose expected data-ip attribute: %s", body) t.Fatalf("ip details page did not expose expected data-ip attribute: %s", body)
} }
if !strings.Contains(body, `/assets/tabulator/tabulator.min.js`) || !strings.Contains(body, `/assets/tabulator/tabulator_midnight.min.css`) {
t.Fatalf("ip details page should load local tabulator assets")
}
if !strings.Contains(body, `id="decisions-table"`) || !strings.Contains(body, `id="events-table"`) || !strings.Contains(body, `id="backend-table"`) {
t.Fatalf("ip details page should expose tabulator mount points for all detail lists")
}
if !strings.Contains(body, `function upsertDetailTable(key, targetId, columns, rows, emptyText, options)`) {
t.Fatalf("ip details page should use Tabulator for detail lists")
}
if strings.Contains(body, `id="decisions-body"`) || strings.Contains(body, `id="events-body"`) || strings.Contains(body, `id="backend-body"`) {
t.Fatalf("ip details page should no longer render raw table body placeholders")
}
if strings.Contains(body, `const ip = "\"203.0.113.10\"";`) { if strings.Contains(body, `const ip = "\"203.0.113.10\"";`) {
t.Fatalf("ip details page still renders a doubly quoted IP") t.Fatalf("ip details page still renders a doubly quoted IP")
} }