2

Polish dashboard readability and loading state

This commit is contained in:
2026-03-12 16:17:23 +01:00
parent 87d2d5f440
commit 0bc2d2b689
2 changed files with 96 additions and 13 deletions

View File

@@ -360,7 +360,7 @@ const overviewHTML = `<!doctype html>
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; } body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; position: sticky; top: 0; background: rgba(15,23,42,.97); } header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; background: #0f172a; }
main { padding: 1.5rem; display: grid; gap: 1.25rem; } main { padding: 1.5rem; display: grid; gap: 1.25rem; }
h1, h2 { margin: 0 0 .75rem 0; } h1, h2 { margin: 0 0 .75rem 0; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
@@ -378,17 +378,20 @@ const overviewHTML = `<!doctype html>
.status.observed { background: #1e293b; } .status.observed { background: #1e293b; }
.muted { color: #94a3b8; } .muted { color: #94a3b8; }
.mono { font-family: ui-monospace, monospace; } .mono { font-family: ui-monospace, monospace; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; } .panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; -webkit-overflow-scrolling: touch; }
.controls { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; } .controls { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.controls-group { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } .controls-group { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.leaders { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } .leaders { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.leader-card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; } .leader-card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; min-height: 15rem; }
.leader-card h2 { margin-bottom: .35rem; font-size: 1rem; } .leader-card h2 { margin-bottom: .35rem; font-size: 1rem; }
.leader-list { list-style: none; margin: .75rem 0 0 0; padding: 0; display: grid; gap: .65rem; } .leader-list { list-style: none; margin: .75rem 0 0 0; padding: 0; display: grid; gap: .65rem; }
.leader-item { display: grid; gap: .2rem; } .leader-item { display: grid; gap: .2rem; padding: .55rem .65rem; border-radius: .6rem; }
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; } .leader-item:nth-child(odd) { background: #0f172a; }
.leader-item:nth-child(even) { background: #111c31; }
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; min-width: 0; }
.leader-main .mono { overflow: hidden; text-overflow: ellipsis; } .leader-main .mono { overflow: hidden; text-overflow: ellipsis; }
.leader-value { font-weight: 600; white-space: nowrap; } .leader-value { font-weight: 600; white-space: nowrap; }
.leader-placeholder { color: #64748b; }
@media (max-width: 1100px) { .leaders { grid-template-columns: 1fr; } } @media (max-width: 1100px) { .leaders { grid-template-columns: 1fr; } }
.toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; } .toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
.toolbar .meta { font-size: .95rem; color: #94a3b8; } .toolbar .meta { font-size: .95rem; color: #94a3b8; }
@@ -419,6 +422,19 @@ const overviewHTML = `<!doctype html>
.bot-chip.yandex { background: #dc2626; color: white; } .bot-chip.yandex { background: #dc2626; color: white; }
.bot-chip.baidu { background: #7c3aed; color: white; } .bot-chip.baidu { background: #7c3aed; color: white; }
.bot-chip.bytespider { background: #111827; color: white; } .bot-chip.bytespider { background: #111827; color: white; }
@media (max-width: 720px) {
header { padding: .9rem 1rem; }
main { padding: 1rem; gap: 1rem; }
.panel, .leader-card, .card { padding: .85rem; }
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.controls { align-items: flex-start; }
.controls-group, .toolbar-right { width: 100%; justify-content: flex-start; }
.toolbar { flex-direction: column; align-items: flex-start; }
table { min-width: 720px; }
.leader-card { min-height: 0; }
.leader-item { padding: .5rem .55rem; }
.action-link, button { min-height: 2.2rem; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -427,7 +443,14 @@ const overviewHTML = `<!doctype html>
<div class="muted">Local-only review and enforcement console</div> <div class="muted">Local-only review and enforcement console</div>
</header> </header>
<main> <main>
<section class="stats" id="stats"></section> <section class="stats" id="stats">
<div class="card"><div class="muted">Total events</div><div class="stat-value">—</div></div>
<div class="card"><div class="muted">Tracked IPs</div><div class="stat-value">—</div></div>
<div class="card"><div class="muted">Blocked</div><div class="stat-value">—</div></div>
<div class="card"><div class="muted">Review</div><div class="stat-value">—</div></div>
<div class="card"><div class="muted">Allowed</div><div class="stat-value">—</div></div>
<div class="card"><div class="muted">Observed</div><div class="stat-value">—</div></div>
</section>
<section class="panel controls"> <section class="panel controls">
<div class="controls-group"> <div class="controls-group">
<label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label> <label class="toggle"><input id="show-bots-toggle" type="checkbox" checked onchange="toggleKnownBots()">Show known bots</label>
@@ -435,7 +458,44 @@ const overviewHTML = `<!doctype html>
</div> </div>
<div class="muted">These two filters affect both the leaderboards and the Recent IPs list.</div> <div class="muted">These two filters affect both the leaderboards and the Recent IPs list.</div>
</section> </section>
<section class="leaders" id="leaderboards"></section> <section class="leaders" id="leaderboards">
<section class="leader-card">
<h2>Top IPs by events</h2>
<div class="muted">Last 24 hours</div>
<ol class="leader-list">
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
</ol>
</section>
<section class="leader-card">
<h2>Top IPs by traffic</h2>
<div class="muted">Last 24 hours</div>
<ol class="leader-list">
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
</ol>
</section>
<section class="leader-card">
<h2>Top sources by events</h2>
<div class="muted">Last 24 hours</div>
<ol class="leader-list">
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
</ol>
</section>
<section class="leader-card">
<h2>Top URLs by events</h2>
<div class="muted">Last 24 hours</div>
<ol class="leader-list">
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
<li class="leader-item"><div class="leader-main"><span class="mono leader-placeholder">Loading…</span><span class="leader-value leader-placeholder">—</span></div></li>
</ol>
</section>
</section>
<section class="panel"> <section class="panel">
<div class="toolbar"> <div class="toolbar">
<h2>Recent IPs</h2> <h2>Recent IPs</h2>
@@ -456,7 +516,7 @@ const overviewHTML = `<!doctype html>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="ips-body"></tbody> <tbody id="ips-body"><tr><td colspan="7" class="muted">Loading recent IPs…</td></tr></tbody>
</table> </table>
</section> </section>
</main> </main>
@@ -478,7 +538,7 @@ const overviewHTML = `<!doctype html>
reason: 'Reason', reason: 'Reason',
}; };
const stateOrder = { blocked: 0, review: 1, observed: 2, allowed: 3 }; const stateOrder = { blocked: 0, review: 1, observed: 2, allowed: 3 };
let currentItems = []; let currentItems = null;
let currentSort = loadSortPreference(); let currentSort = loadSortPreference();
let showKnownBots = loadShowKnownBotsPreference(); let showKnownBots = loadShowKnownBotsPreference();
let showAllowed = loadShowAllowedPreference(); let showAllowed = loadShowAllowedPreference();
@@ -675,7 +735,7 @@ const overviewHTML = `<!doctype html>
return [ return [
'<li class="leader-item">', '<li class="leader-item">',
' <div class="leader-main">', ' <div class="leader-main">',
' <a class="mono" href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a>', ' <div class="ip-cell mono">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></div>',
' <span class="leader-value">' + escapeHtml(primaryValue) + '</span>', ' <span class="leader-value">' + escapeHtml(primaryValue) + '</span>',
' </div>', ' </div>',
'</li>' '</li>'
@@ -811,6 +871,10 @@ const overviewHTML = `<!doctype html>
} }
function renderIPs(items) { function renderIPs(items) {
if (!Array.isArray(items)) {
document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">Loading recent IPs…</td></tr>';
return;
}
const filteredItems = items.filter(item => (showKnownBots || !item.bot) && (showAllowed || item.state !== 'allowed') && (!showReviewOnly || item.state === 'review')); const filteredItems = items.filter(item => (showKnownBots || !item.bot) && (showAllowed || item.state !== 'allowed') && (!showReviewOnly || item.state === 'review'));
const rows = sortItems(filteredItems).map(item => [ const rows = sortItems(filteredItems).map(item => [
'<tr>', '<tr>',
@@ -905,9 +969,11 @@ const overviewHTML = `<!doctype html>
} }
async function refresh() { async function refresh() {
const recentResponse = await fetch('/api/recent-ips?hours=' + recentHours + '&limit=250'); const [_, recentResponse] = await Promise.all([
refreshOverview(),
fetch('/api/recent-ips?hours=' + recentHours + '&limit=250')
]);
const recentPayload = await recentResponse.json().catch(() => []); const recentPayload = await recentResponse.json().catch(() => []);
refreshOverview();
if (!recentResponse.ok) { if (!recentResponse.ok) {
const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText); const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText);
document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">' + escapeHtml(message) + '</td></tr>'; document.getElementById('ips-body').innerHTML = '<tr><td colspan="7" class="muted">' + escapeHtml(message) + '</td></tr>';
@@ -933,7 +999,7 @@ const ipDetailsHTML = `<!doctype html>
<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; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; position: sticky; top: 0; background: rgba(2,6,23,.97); } header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; background: #020617; }
main { padding: 1.5rem; display: grid; gap: 1.5rem; } main { padding: 1.5rem; display: grid; gap: 1.5rem; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; } .panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
h1, h2 { margin: 0 0 .75rem 0; } h1, h2 { margin: 0 0 .75rem 0; }
@@ -971,6 +1037,14 @@ const ipDetailsHTML = `<!doctype html>
.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; }
@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; }
}
</style> </style>
</head> </head>
<body data-ip="{{ .IP }}"> <body data-ip="{{ .IP }}">

View File

@@ -102,9 +102,15 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Top URLs by events") { if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
t.Fatalf("overview page should expose the top URLs block") t.Fatalf("overview page should expose the top URLs block")
} }
if !strings.Contains(recorder.Body.String(), "Loading…") {
t.Fatalf("overview page should render stable loading placeholders")
}
if !strings.Contains(recorder.Body.String(), "These two filters affect both the leaderboards and the Recent IPs list") { if !strings.Contains(recorder.Body.String(), "These two filters affect both the leaderboards and the Recent IPs list") {
t.Fatalf("overview page should explain the scope of the shared filters") t.Fatalf("overview page should explain the scope of the shared filters")
} }
if strings.Contains(recorder.Body.String(), "position: sticky") {
t.Fatalf("overview page header should no longer be sticky")
}
if !strings.Contains(recorder.Body.String(), "Show allowed") { if !strings.Contains(recorder.Body.String(), "Show allowed") {
t.Fatalf("overview page should expose the allowed toggle") t.Fatalf("overview page should expose the allowed toggle")
} }
@@ -131,6 +137,9 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if strings.Contains(body, `refresh().then(() => investigate());`) { if strings.Contains(body, `refresh().then(() => investigate());`) {
t.Fatalf("ip details page should not auto-refresh investigation on load") t.Fatalf("ip details page should not auto-refresh investigation on load")
} }
if strings.Contains(body, "position: sticky") {
t.Fatalf("ip details page header should no longer be sticky")
}
investigationIndex := strings.Index(body, "<h2>Investigation</h2>") investigationIndex := strings.Index(body, "<h2>Investigation</h2>")
decisionsIndex := strings.Index(body, "<h2>Decisions</h2>") decisionsIndex := strings.Index(body, "<h2>Decisions</h2>")
requestsIndex := strings.Index(body, "<h2>Requests from this IP</h2>") requestsIndex := strings.Index(body, "<h2>Requests from this IP</h2>")