2

Polish requests log mobile controls

This commit is contained in:
2026-03-12 20:40:10 +01:00
parent cec72a90b9
commit c213054f82
2 changed files with 176 additions and 35 deletions

View File

@@ -917,7 +917,8 @@ const queryLogHTML = `<!doctype html>
.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; }
.page-status { color: #cbd5e1; font-size: .92rem; }
.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; }
@@ -932,6 +933,10 @@ const queryLogHTML = `<!doctype html>
.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; }
.table-panel { padding: 1rem; }
.table-shell { overflow: hidden; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
@@ -949,7 +954,7 @@ const queryLogHTML = `<!doctype html>
.col-status { width: 4.75rem; }
.col-state { width: 6.5rem; }
.col-reason { width: 11rem; overflow: hidden; }
.col-actions { width: 7.5rem; }
.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; }
@@ -969,8 +974,10 @@ const queryLogHTML = `<!doctype html>
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: 100%; min-width: 0; }
.actions button { display: block; width: 2.15rem; height: 2.15rem; min-width: 0; margin-left: auto; padding: 0; }
.actions .muted { display: block; text-align: center; }
.ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; max-width: 100%; }
.ip-link { display: block; flex: 1 1 auto; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -997,6 +1004,9 @@ const queryLogHTML = `<!doctype html>
@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; }
}
@@ -1113,6 +1123,7 @@ const queryLogHTML = `<!doctype html>
<div class="field">
<label for="page-size">Rows per page</label>
<select id="page-size">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
@@ -1122,6 +1133,18 @@ const queryLogHTML = `<!doctype html>
<input id="auto-refresh-toggle" type="checkbox" onchange="toggleAutoRefresh()">
<label for="auto-refresh-toggle">Auto refresh</label>
</div>
<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>
<label class="column-chip"><input id="column-state" type="checkbox" onchange="applyColumnChanges()">State</label>
<label class="column-chip"><input id="column-reason" type="checkbox" onchange="applyColumnChanges()">Reason</label>
<label class="column-chip"><input id="column-actions" type="checkbox" onchange="applyColumnChanges()">Actions</label>
</div>
</div>
</form>
<div class="panel-actions">
<button type="button" onclick="applyFilters()">Apply</button>
@@ -1138,25 +1161,27 @@ const queryLogHTML = `<!doctype html>
<div class="muted">Click an IP to open its detail page</div>
</div>
<div class="toolbar-actions">
<button class="secondary" type="button" onclick="refreshNow()">Refresh now</button>
<div class="page-status" id="page-status">Page 1</div>
<button class="secondary" type="button" id="prev-page" onclick="goToPreviousPage()">Previous</button>
<button class="secondary" type="button" id="next-page" onclick="goToNextPage()">Next</button>
<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" onclick="applySort('time')">Time<span class="sort-indicator" id="sort-time"></span></th>
<th class="col-ip sortable" data-sort="ip" onclick="applySort('ip')">IP<span class="sort-indicator" id="sort-ip"></span></th>
<th class="col-method sortable" data-sort="method" onclick="applySort('method')">Method<span class="sort-indicator" id="sort-method"></span></th>
<th class="col-source sortable" data-sort="source" onclick="applySort('source')">Source<span class="sort-indicator" id="sort-source"></span></th>
<th class="col-request sortable" data-sort="request" onclick="applySort('request')">Request<span class="sort-indicator" id="sort-request"></span></th>
<th class="col-status sortable" data-sort="status" onclick="applySort('status')">Status<span class="sort-indicator" id="sort-status"></span></th>
<th class="col-state sortable" data-sort="state" onclick="applySort('state')">State<span class="sort-indicator" id="sort-state"></span></th>
<th class="col-reason sortable" data-sort="reason" onclick="applySort('reason')">Reason<span class="sort-indicator" id="sort-reason"></span></th>
<th class="col-actions">Actions</th>
<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">
@@ -1164,6 +1189,16 @@ const queryLogHTML = `<!doctype html>
</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>
</section>
</main>
<script>
@@ -1175,7 +1210,9 @@ const queryLogHTML = `<!doctype html>
let botFilter = loadStringPreference('cob.requests.botFilter', 'all');
let sortBy = loadStringPreference('cob.requests.sortBy', 'time');
let sortDir = loadStringPreference('cob.requests.sortDir', 'desc');
let pageSize = loadStringPreference('cob.requests.pageSize', '100');
const defaultVisibleColumns = { time: true, method: true, source: true, status: true, state: true, reason: true, actions: true };
let visibleColumns = loadColumnPreferences();
let pageSize = loadStringPreference('cob.requests.pageSizeV2', '25');
let autoRefresh = loadBooleanPreference('cob.requests.autoRefresh', false);
let panelOpen = loadBooleanPreference('cob.requests.panelOpen', false);
let currentPage = 1;
@@ -1207,6 +1244,29 @@ const queryLogHTML = `<!doctype html>
localStorage.setItem(key, value);
}
function loadColumnPreferences() {
const next = { ...defaultVisibleColumns };
try {
const raw = localStorage.getItem('cob.requests.visibleColumns');
if (!raw) {
return next;
}
const parsed = JSON.parse(raw);
for (const key of Object.keys(defaultVisibleColumns)) {
if (typeof parsed[key] === 'boolean') {
next[key] = parsed[key];
}
}
} catch (error) {
return next;
}
return next;
}
function saveColumnPreferences() {
localStorage.setItem('cob.requests.visibleColumns', JSON.stringify(visibleColumns));
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
@@ -1273,10 +1333,10 @@ const queryLogHTML = `<!doctype html>
function renderActions(item) {
const actions = item.actions || {};
if (actions.can_unblock) {
return '<div class="actions"><button class="secondary" data-ip="' + escapeHtml(item.client_ip) + '" onclick="sendAction(this.dataset.ip, \'unblock\', \'Reason for manual unblock\')">Unblock</button></div>';
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>';
}
if (actions.can_block) {
return '<div class="actions"><button class="danger" data-ip="' + escapeHtml(item.client_ip) + '" onclick="sendAction(this.dataset.ip, \'block\', \'Reason for manual block\')">Block</button></div>';
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 '<div class="actions"><span class="muted">—</span></div>';
}
@@ -1292,6 +1352,34 @@ const queryLogHTML = `<!doctype html>
return '';
}
function applyColumnControls() {
for (const key of Object.keys(defaultVisibleColumns)) {
const input = document.getElementById('column-' + key);
if (input) {
input.checked = visibleColumns[key] !== false;
}
}
}
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;
@@ -1303,6 +1391,8 @@ const queryLogHTML = `<!doctype html>
document.getElementById('page-size').value = pageSize;
document.getElementById('auto-refresh-toggle').checked = autoRefresh;
document.getElementById('options-panel').open = panelOpen;
applyColumnControls();
applyVisibleColumns();
updateSortIndicators();
updateControlsSummary();
}
@@ -1329,6 +1419,8 @@ const queryLogHTML = `<!doctype html>
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'); }
@@ -1343,9 +1435,19 @@ const queryLogHTML = `<!doctype html>
saveStringPreference('cob.requests.botFilter', botFilter);
saveStringPreference('cob.requests.sortBy', sortBy);
saveStringPreference('cob.requests.sortDir', sortDir);
saveStringPreference('cob.requests.pageSize', pageSize);
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);
if (input) {
visibleColumns[key] = input.checked;
}
}
}
function readControls() {
@@ -1358,6 +1460,15 @@ const queryLogHTML = `<!doctype html>
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 applyFilters(event) {
@@ -1380,8 +1491,9 @@ const queryLogHTML = `<!doctype html>
botFilter = 'all';
sortBy = 'time';
sortDir = 'desc';
pageSize = '100';
pageSize = '25';
autoRefresh = false;
visibleColumns = { ...defaultVisibleColumns };
saveControls();
applyControls();
currentPage = 1;
@@ -1408,9 +1520,15 @@ const queryLogHTML = `<!doctype html>
function updatePager(payload) {
const page = Number(payload.page || currentPage || 1);
document.getElementById('page-status').textContent = 'Page ' + page + ' · ' + pageSize + ' rows';
document.getElementById('prev-page').disabled = !payload.has_prev;
document.getElementById('next-page').disabled = !payload.has_next;
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 scheduleRefresh() {
@@ -1465,25 +1583,42 @@ const queryLogHTML = `<!doctype html>
refresh();
}
function syncEmptyStateColspan() {
const cell = document.querySelector('#events-body tr td');
if (cell && cell.colSpan) {
cell.colSpan = visibleColumnCount();
}
}
function renderEmptyState(message) {
document.getElementById('events-body').innerHTML = '<tr><td colspan="' + visibleColumnCount() + '" class="muted">' + escapeHtml(message) + '</td></tr>';
applyVisibleColumns();
}
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">' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
' <td class="col-ip mono"><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"><span class="method ' + escapeHtml(methodClass(item.method)) + '">' + escapeHtml(item.method || 'OTHER') + '</span></td>',
' <td class="col-source">' + escapeHtml(item.source_name || '—') + '</td>',
' <td class="col-request mono"><span class="request-text" title="' + escapeHtml(requestLabel) + '">' + escapeHtml(requestLabel) + '</span></td>',
' <td class="col-status"><span class="status-code ' + escapeHtml(statusCodeClass(item.status)) + '">' + escapeHtml(String(item.status || 0)) + '</span></td>',
' <td class="col-state"><span class="status ' + escapeHtml(item.current_state || 'observed') + '">' + escapeHtml(item.current_state || 'observed') + '</span></td>',
' <td class="col-reason"><span class="reason-text" title="' + escapeHtml(item.decision_reason || '—') + '">' + escapeHtml(item.decision_reason || '—') + '</span></td>',
' <td class="col-actions">' + renderActions(item) + '</td>',
' <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('');
});
document.getElementById('events-body').innerHTML = rows.length ? rows.join('') : '<tr><td colspan="9" class="muted">No requests match the current filters in the last 24 hours.</td></tr>';
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 || {});
}
@@ -1502,7 +1637,7 @@ const queryLogHTML = `<!doctype html>
const response = await fetch('/api/events?' + params.toString());
const payload = await response.json().catch(() => ({ items: [] }));
if (!response.ok) {
document.getElementById('events-body').innerHTML = '<tr><td colspan="9" class="muted">' + escapeHtml(payload.error || response.statusText) + '</td></tr>';
renderEmptyState(payload.error || response.statusText);
updatePager({ page: currentPage, has_prev: currentPage > 1, has_next: false });
scheduleRefresh();
return;

View File

@@ -181,6 +181,12 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(queryLogBody, "Rows per page") {
t.Fatalf("requests log page should expose pagination settings")
}
if !strings.Contains(queryLogBody, `<option value="25">25</option>`) {
t.Fatalf("requests log page should expose the 25 rows per page option")
}
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")
}