diff --git a/internal/web/handler.go b/internal/web/handler.go index 05a7738..b9bc8bf 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -360,7 +360,7 @@ const overviewHTML = ` @@ -427,7 +443,14 @@ const overviewHTML = `
Local-only review and enforcement console
-
+
+
Total events
+
Tracked IPs
+
Blocked
+
Review
+
Allowed
+
Observed
+
@@ -435,7 +458,44 @@ const overviewHTML = `
These two filters affect both the leaderboards and the Recent IPs list.
-
+
+
+

Top IPs by events

+
Last 24 hours
+
    +
  1. Loading…
  2. +
  3. Loading…
  4. +
  5. Loading…
  6. +
+
+
+

Top IPs by traffic

+
Last 24 hours
+
    +
  1. Loading…
  2. +
  3. Loading…
  4. +
  5. Loading…
  6. +
+
+
+

Top sources by events

+
Last 24 hours
+
    +
  1. Loading…
  2. +
  3. Loading…
  4. +
  5. Loading…
  6. +
+
+
+

Top URLs by events

+
Last 24 hours
+
    +
  1. Loading…
  2. +
  3. Loading…
  4. +
  5. Loading…
  6. +
+
+

Recent IPs

@@ -456,7 +516,7 @@ const overviewHTML = ` Actions - + Loading recent IPs…
@@ -478,7 +538,7 @@ const overviewHTML = ` reason: 'Reason', }; const stateOrder = { blocked: 0, review: 1, observed: 2, allowed: 3 }; - let currentItems = []; + let currentItems = null; let currentSort = loadSortPreference(); let showKnownBots = loadShowKnownBotsPreference(); let showAllowed = loadShowAllowedPreference(); @@ -675,7 +735,7 @@ const overviewHTML = ` return [ '
  • ', '
    ', - ' ' + escapeHtml(item.ip) + '', + '
    ' + renderBotChip(item.bot) + '' + escapeHtml(item.ip) + '
    ', ' ' + escapeHtml(primaryValue) + '', '
    ', '
  • ' @@ -811,6 +871,10 @@ const overviewHTML = ` } function renderIPs(items) { + if (!Array.isArray(items)) { + document.getElementById('ips-body').innerHTML = 'Loading recent IPs…'; + return; + } const filteredItems = items.filter(item => (showKnownBots || !item.bot) && (showAllowed || item.state !== 'allowed') && (!showReviewOnly || item.state === 'review')); const rows = sortItems(filteredItems).map(item => [ '', @@ -905,9 +969,11 @@ const overviewHTML = ` } 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(() => []); - refreshOverview(); if (!recentResponse.ok) { const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText); document.getElementById('ips-body').innerHTML = '' + escapeHtml(message) + ''; @@ -933,7 +999,7 @@ const ipDetailsHTML = ` diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index c24d9f2..b3b37d1 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -102,9 +102,15 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) { if !strings.Contains(recorder.Body.String(), "Top URLs by events") { 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") { 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") { 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());`) { 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, "

    Investigation

    ") decisionsIndex := strings.Index(body, "

    Decisions

    ") requestsIndex := strings.Index(body, "

    Requests from this IP

    ")