2
Files
caddy-opnsense-blocker/internal/web/handler.go

1555 lines
68 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
)
type App interface {
GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error)
ListEvents(ctx context.Context, since time.Time, limit int, options model.EventListOptions) ([]model.Event, error)
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error)
GetIPDetails(ctx context.Context, ip string) (model.IPDetails, error)
InvestigateIP(ctx context.Context, ip string) (model.IPDetails, error)
ForceBlock(ctx context.Context, ip string, actor string, reason string) error
ForceAllow(ctx context.Context, ip string, actor string, reason string) error
ClearOverride(ctx context.Context, ip string, actor string, reason string) error
}
type handler struct {
app App
overviewPage *template.Template
queryLogPage *template.Template
ipDetailsPage *template.Template
}
type pageData struct {
Title string
IP string
}
type actionPayload struct {
Reason string `json:"reason"`
Actor string `json:"actor"`
}
func NewHandler(app App) http.Handler {
h := &handler{
app: app,
overviewPage: template.Must(template.New("overview").Parse(overviewHTML)),
queryLogPage: template.Must(template.New("query-log").Parse(queryLogHTML)),
ipDetailsPage: template.Must(template.New("ip-details").Parse(ipDetailsHTML)),
}
mux := http.NewServeMux()
mux.HandleFunc("/", h.handleOverviewPage)
mux.HandleFunc("/requests", h.handleQueryLogPage)
mux.HandleFunc("/queries", h.handleQueryLogPage)
mux.HandleFunc("/healthz", h.handleHealth)
mux.HandleFunc("/ips/", h.handleIPPage)
mux.HandleFunc("/api/overview", h.handleAPIOverview)
mux.HandleFunc("/api/events", h.handleAPIEvents)
mux.HandleFunc("/api/ips", h.handleAPIIPs)
mux.HandleFunc("/api/recent-ips", h.handleAPIRecentIPs)
mux.HandleFunc("/api/ips/", h.handleAPIIP)
return mux
}
func (h *handler) handleOverviewPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
renderTemplate(w, h.overviewPage, pageData{Title: "Caddy OPNsense Blocker"})
}
func (h *handler) handleQueryLogPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/queries" {
http.Redirect(w, r, "/requests", http.StatusMovedPermanently)
return
}
if r.URL.Path != "/requests" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
renderTemplate(w, h.queryLogPage, pageData{Title: "Requests Log"})
}
func (h *handler) handleIPPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
ip, ok := extractPathValue(r.URL.Path, "/ips/")
if !ok {
http.NotFound(w, r)
return
}
renderTemplate(w, h.ipDetailsPage, pageData{Title: "IP details", IP: ip})
}
func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": time.Now().UTC()})
}
func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 50)
hours := queryInt(r, "hours", 24)
if hours <= 0 {
hours = 24
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
options := model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true}
overview, err := h.app.GetOverview(r.Context(), since, limit, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, overview)
}
func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 100)
hours := queryInt(r, "hours", 24)
if hours <= 0 {
hours = 24
}
page := queryInt(r, "page", 1)
if page <= 0 {
page = 1
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
options := model.EventListOptions{
ShowKnownBots: queryBool(r, "show_known_bots", true),
ShowAllowed: queryBool(r, "show_allowed", true),
ReviewOnly: queryBool(r, "review_only", false),
Offset: (page - 1) * limit,
}
events, err := h.app.ListEvents(r.Context(), since, limit+1, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
hasNext := len(events) > limit
if hasNext {
events = events[:limit]
}
writeJSON(w, http.StatusOK, model.EventPage{
Items: events,
Page: page,
Limit: limit,
HasPrev: page > 1,
HasNext: hasNext,
})
}
func (h *handler) handleAPIIPs(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/ips" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 100)
state := strings.TrimSpace(r.URL.Query().Get("state"))
items, err := h.app.ListIPs(r.Context(), limit, state)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *handler) handleAPIRecentIPs(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/recent-ips" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
limit := queryLimit(r, 200)
hours := queryInt(r, "hours", 24)
if hours <= 0 {
hours = 24
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
items, err := h.app.ListRecentIPs(r.Context(), since, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
ip, action, ok := extractAPIPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
if action == "" {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
details, err := h.app.GetIPDetails(r.Context(), ip)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, details)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if action == "investigate" {
details, err := h.app.InvestigateIP(r.Context(), ip)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, details)
return
}
payload, err := decodeActionPayload(r)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
switch action {
case "block":
err = h.app.ForceBlock(r.Context(), ip, payload.Actor, payload.Reason)
case "unblock":
err = h.app.ForceAllow(r.Context(), ip, payload.Actor, payload.Reason)
case "clear-override", "reset":
err = h.app.ClearOverride(r.Context(), ip, payload.Actor, payload.Reason)
default:
http.NotFound(w, r)
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
details, err := h.app.GetIPDetails(r.Context(), ip)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, details)
}
func decodeActionPayload(r *http.Request) (actionPayload, error) {
defer r.Body.Close()
var payload actionPayload
if r.ContentLength == 0 {
return payload, nil
}
limited := io.LimitReader(r.Body, 1<<20)
if err := json.NewDecoder(limited).Decode(&payload); err != nil {
return actionPayload{}, fmt.Errorf("decode action payload: %w", err)
}
return payload, nil
}
func queryLimit(r *http.Request, fallback int) int {
if fallback <= 0 {
fallback = 50
}
value := strings.TrimSpace(r.URL.Query().Get("limit"))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
if parsed > 1000 {
return 1000
}
return parsed
}
func queryInt(r *http.Request, name string, fallback int) int {
value := strings.TrimSpace(r.URL.Query().Get(name))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func queryBool(r *http.Request, name string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get(name)))
if value == "" {
return fallback
}
switch value {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]any{"error": err.Error()})
}
func extractPathValue(path string, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
value := strings.Trim(strings.TrimPrefix(path, prefix), "/")
if value == "" {
return "", false
}
decoded, err := url.PathUnescape(value)
if err != nil {
return "", false
}
return decoded, true
}
func extractAPIPath(path string) (ip string, action string, ok bool) {
if !strings.HasPrefix(path, "/api/ips/") {
return "", "", false
}
rest := strings.Trim(strings.TrimPrefix(path, "/api/ips/"), "/")
if rest == "" {
return "", "", false
}
parts := strings.Split(rest, "/")
decodedIP, err := url.PathUnescape(parts[0])
if err != nil {
return "", "", false
}
if len(parts) == 1 {
return decodedIP, "", true
}
return decodedIP, parts[1], true
}
func methodNotAllowed(w http.ResponseWriter) {
writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
}
func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
writeError(w, http.StatusInternalServerError, err)
}
}
const overviewHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: linear-gradient(180deg, #0f172a 0%, #020617 100%); color: #e2e8f0; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; background: rgba(2, 6, 23, .92); }
.header-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.nav { display: inline-flex; gap: .5rem; flex-wrap: wrap; }
.nav-link { padding: .45rem .75rem; border-radius: 999px; border: 1px solid #334155; background: #111827; color: #cbd5e1; text-decoration: none; }
.nav-link.active { background: linear-gradient(135deg, #2563eb, #0f766e); color: white; border-color: transparent; }
main { padding: 1.5rem; display: grid; gap: 1.25rem; }
h1, h2 { margin: 0; }
a { color: #93c5fd; text-decoration: none; }
a:hover { text-decoration: underline; }
.muted { color: #94a3b8; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem; }
.card { background: #111827; border: 1px solid #334155; border-radius: .85rem; padding: .95rem; position: relative; overflow: hidden; }
.card::before { content: ''; position: absolute; inset: 0 auto 0 0; width: .3rem; background: #334155; }
.card.total::before { background: #38bdf8; }
.card.tracked::before { background: #60a5fa; }
.card.blocked::before { background: #ef4444; }
.card.review::before { background: #f59e0b; }
.card.allowed::before { background: #22c55e; }
.card.observed::before { background: #a78bfa; }
.stat-value { font-size: 1.8rem; font-weight: 800; margin-top: .2rem; }
.panel, .leader-card { background: #111827; border: 1px solid #334155; border-radius: .85rem; padding: 1rem; overflow: hidden; }
.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; }
.toggle { display: inline-flex; align-items: center; gap: .45rem; font-size: .95rem; color: #cbd5e1; }
.toggle input { margin: 0; }
.dashboard-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.chart-wide { grid-column: 1 / -1; }
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: .9rem; flex-wrap: wrap; }
.legend { display: flex; flex-wrap: wrap; gap: .5rem; }
.legend-chip { display: inline-flex; align-items: center; gap: .4rem; padding: .25rem .55rem; border: 1px solid #334155; border-radius: 999px; background: #0b1220; font-size: .82rem; color: #cbd5e1; }
.legend-dot { width: .7rem; height: .7rem; border-radius: 999px; display: inline-block; }
.chart-placeholder { min-height: 15rem; display: flex; align-items: center; justify-content: center; color: #64748b; background: linear-gradient(180deg, rgba(15, 23, 42, .85), rgba(2, 6, 23, .6)); border: 1px dashed #334155; border-radius: .75rem; }
.activity-shell svg { width: 100%; height: auto; display: block; }
.activity-axis { fill: #64748b; font-size: 12px; }
.activity-gridline { stroke: #1e293b; stroke-width: 1; }
.donut-shell { min-height: 15rem; display: flex; align-items: center; justify-content: center; }
.donut-grid { display: grid; grid-template-columns: minmax(160px, 220px) 1fr; gap: 1rem; align-items: center; width: 100%; }
.donut { width: min(100%, 220px); aspect-ratio: 1; border-radius: 999px; position: relative; margin: 0 auto; }
.donut-hole { position: absolute; inset: 22%; border-radius: 999px; background: #0b1220; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; box-shadow: inset 0 0 0 1px #334155; }
.donut-hole strong { font-size: 1.6rem; }
.donut-hole span { font-size: .8rem; color: #94a3b8; }
.legend-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .45rem; }
.legend-list li { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .45rem .55rem; border-radius: .65rem; background: #0b1220; }
.legend-label { display: inline-flex; align-items: center; gap: .5rem; min-width: 0; }
.legend-label span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend-value { white-space: nowrap; font-weight: 700; }
.leaders { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.leader-card h2 { margin-bottom: .35rem; font-size: 1rem; }
.leader-list { list-style: none; margin: .9rem 0 0 0; padding: 0; display: grid; gap: .35rem; }
.leader-item { padding: .6rem .65rem; border-radius: .65rem; background: #0b1220; border: 1px solid #1e293b; }
.leader-item:nth-child(even) { background: #111a2c; }
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.leader-main .mono { overflow: hidden; text-overflow: ellipsis; }
.leader-value { font-weight: 700; white-space: nowrap; }
.leader-placeholder { color: #64748b; }
.ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; }
.bot-chip { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border-radius: 999px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: .72rem; font-weight: 700; cursor: help; flex: 0 0 auto; }
.bot-chip.verified { border-color: #2563eb; }
.bot-chip.hint { border-style: dashed; }
.bot-chip.google { background: #2563eb; color: white; }
.bot-chip.bing { background: #0284c7; color: white; }
.bot-chip.apple { background: #475569; color: white; }
.bot-chip.meta { background: #2563eb; color: white; }
.bot-chip.duckduckgo { background: #ea580c; color: white; }
.bot-chip.openai { background: #059669; color: white; }
.bot-chip.anthropic { background: #b45309; color: white; }
.bot-chip.perplexity { background: #0f766e; color: white; }
.bot-chip.semrush { background: #db2777; color: white; }
.bot-chip.yandex { background: #dc2626; color: white; }
.bot-chip.baidu { background: #7c3aed; color: white; }
.bot-chip.bytespider { background: #111827; color: white; }
@media (max-width: 1100px) {
.dashboard-grid, .leaders { grid-template-columns: 1fr; }
.donut-grid { grid-template-columns: 1fr; }
}
@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 { width: 100%; justify-content: flex-start; }
.chart-placeholder, .donut-shell { min-height: 13rem; }
}
</style>
</head>
<body>
<header>
<div class="header-row">
<div>
<h1>{{ .Title }}</h1>
<div class="muted">Local-only review and enforcement console</div>
</div>
<nav class="nav">
<a class="nav-link active" href="/">Dashboard</a>
<a class="nav-link" href="/requests">Requests Log</a>
</nav>
</div>
</header>
<main>
<section class="stats" id="stats">
<div class="card total"><div class="muted">Total events</div><div class="stat-value">—</div></div>
<div class="card tracked"><div class="muted">Tracked IPs</div><div class="stat-value">—</div></div>
<div class="card blocked"><div class="muted">Blocked</div><div class="stat-value">—</div></div>
<div class="card review"><div class="muted">Review</div><div class="stat-value">—</div></div>
<div class="card allowed"><div class="muted">Allowed</div><div class="stat-value">—</div></div>
<div class="card observed"><div class="muted">Observed</div><div class="stat-value">—</div></div>
</section>
<section class="dashboard-grid">
<section class="panel chart-card chart-wide">
<div class="panel-head">
<div>
<h2>Activity</h2>
<div class="muted">Requests per 10-minute bucket</div>
</div>
<div class="legend" id="activity-legend"></div>
</div>
<div id="activity-chart" class="chart-placeholder">Loading activity…</div>
</section>
<section class="panel chart-card">
<div class="panel-head">
<div>
<h2>Methods</h2>
<div class="muted">Last 24 hours</div>
</div>
</div>
<div id="methods-chart" class="donut-shell chart-placeholder">Loading methods…</div>
</section>
<section class="panel chart-card">
<div class="panel-head">
<div>
<h2>Bots</h2>
<div class="muted">Last 24 hours</div>
</div>
</div>
<div id="bots-chart" class="donut-shell chart-placeholder">Loading bot distribution…</div>
</section>
</section>
<section class="leaders" id="leaderboards">
<section class="leader-card">
<h2>Top bot 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 non-bot 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 bot 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 non-bot 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>
</main>
<script>
const recentHours = 24;
const sourcePalette = ['#38bdf8', '#22c55e', '#f59e0b', '#a78bfa', '#f97316', '#14b8a6', '#ec4899', '#60a5fa', '#84cc16', '#ef4444'];
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = bytes;
let unitIndex = 0;
while (amount >= 1024 && unitIndex < units.length - 1) {
amount /= 1024;
unitIndex += 1;
}
const digits = amount >= 10 || unitIndex === 0 ? 0 : 1;
return amount.toFixed(digits) + ' ' + units[unitIndex];
}
function colorForSource(sourceName) {
const input = String(sourceName || 'unknown');
let hash = 0;
for (let index = 0; index < input.length; index += 1) {
hash = ((hash << 5) - hash) + input.charCodeAt(index);
hash |= 0;
}
return sourcePalette[Math.abs(hash) % sourcePalette.length];
}
function botVisual(bot) {
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
const catalog = [
{ match: ['google'], short: 'G', className: 'google' },
{ match: ['bing', 'microsoft'], short: 'B', className: 'bing' },
{ match: ['apple'], short: 'A', className: 'apple' },
{ match: ['facebook', 'meta'], short: 'M', className: 'meta' },
{ match: ['duckduckgo', 'duckduckbot'], short: 'D', className: 'duckduckgo' },
{ match: ['gptbot', 'openai'], short: 'O', className: 'openai' },
{ match: ['claudebot', 'anthropic'], short: 'C', className: 'anthropic' },
{ match: ['perplexity'], short: 'P', className: 'perplexity' },
{ match: ['semrush'], short: 'S', className: 'semrush' },
{ match: ['yandex'], short: 'Y', className: 'yandex' },
{ match: ['baidu'], short: 'B', className: 'baidu' },
{ match: ['bytespider', 'tiktok'], short: 'T', className: 'bytespider' },
];
for (const entry of catalog) {
if (entry.match.some(fragment => candidate.includes(fragment))) {
return entry;
}
}
const name = String((bot || {}).name || '').trim();
return { short: (name[0] || '?').toUpperCase(), className: 'generic' };
}
function renderBotChip(bot) {
if (!bot) {
return '';
}
const visual = botVisual(bot);
const statusClass = bot.verified ? 'verified' : 'hint';
const title = (bot.name || 'Bot') + (bot.verified ? '' : ' (possible)');
return '<span class="bot-chip ' + escapeHtml(visual.className) + ' ' + statusClass + '" title="' + escapeHtml(title) + '">' + escapeHtml(visual.short) + '</span>';
}
function renderTopIPs(items, primaryMetric) {
const visibleItems = Array.isArray(items) ? items : [];
if (!visibleItems.length) {
return '<div class="muted">No matching IP activity in the selected window.</div>';
}
return '<ol class="leader-list">' + visibleItems.map(item => {
const primaryValue = primaryMetric === 'traffic' ? formatBytes(item.traffic_bytes) : String(item.events || 0);
return [
'<li class="leader-item">',
' <div class="leader-main">',
' <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>',
' </div>',
'</li>'
].join('');
}).join('') + '</ol>';
}
function renderTopSources(items) {
if (!Array.isArray(items) || !items.length) {
return '<div class="muted">No source activity in the selected window.</div>';
}
return '<ol class="leader-list">' + items.map(item => [
'<li class="leader-item">',
' <div class="leader-main">',
' <span class="mono">' + escapeHtml(item.source_name || '—') + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
' </div>',
'</li>'
].join('')).join('') + '</ol>';
}
function renderTopURLs(items) {
if (!Array.isArray(items) || !items.length) {
return '<div class="muted">No URL activity in the selected window.</div>';
}
return '<ol class="leader-list">' + items.map(item => {
const label = ((item.host || '') ? (item.host + item.uri) : (item.uri || '—'));
return [
'<li class="leader-item">',
' <div class="leader-main">',
' <span class="mono">' + escapeHtml(label) + '</span>',
' <span class="leader-value">' + escapeHtml(String(item.events || 0)) + '</span>',
' </div>',
'</li>'
].join('');
}).join('') + '</ol>';
}
function renderStats(data) {
const cards = [
{ className: 'total', label: 'Total events', value: data.total_events },
{ className: 'tracked', label: 'Tracked IPs', value: data.total_ips },
{ className: 'blocked', label: 'Blocked', value: data.blocked_ips },
{ className: 'review', label: 'Review', value: data.review_ips },
{ className: 'allowed', label: 'Allowed', value: data.allowed_ips },
{ className: 'observed', label: 'Observed', value: data.observed_ips },
];
document.getElementById('stats').innerHTML = cards.map(card => [
'<div class="card ' + escapeHtml(card.className) + '">',
' <div class="muted">' + escapeHtml(card.label) + '</div>',
' <div class="stat-value">' + escapeHtml(card.value ?? '0') + '</div>',
'</div>'
].join('')).join('');
}
function renderLeaderboards(data) {
const cards = [
{ title: 'Top bot IPs by events', subtitle: 'Last 24 hours', body: renderTopIPs(data.top_bot_ips_by_events, 'events') },
{ title: 'Top non-bot IPs by events', subtitle: 'Last 24 hours', body: renderTopIPs(data.top_non_bot_ips_by_events, 'events') },
{ title: 'Top bot IPs by traffic', subtitle: 'Last 24 hours', body: renderTopIPs(data.top_bot_ips_by_traffic, 'traffic') },
{ title: 'Top non-bot IPs by traffic', subtitle: 'Last 24 hours', body: renderTopIPs(data.top_non_bot_ips_by_traffic, 'traffic') },
{ title: 'Top sources by events', subtitle: 'Last 24 hours', body: renderTopSources(data.top_sources) },
{ title: 'Top URLs by events', subtitle: 'Last 24 hours', body: renderTopURLs(data.top_urls) },
];
document.getElementById('leaderboards').innerHTML = cards.map(card => [
'<section class="leader-card">',
' <h2>' + escapeHtml(card.title) + '</h2>',
' <div class="muted">' + escapeHtml(card.subtitle) + '</div>',
card.body,
'</section>'
].join('')).join('');
}
function formatBucketLabel(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function renderActivity(data) {
const buckets = Array.isArray(data.activity_buckets) ? data.activity_buckets : [];
const chart = document.getElementById('activity-chart');
const legend = document.getElementById('activity-legend');
if (!buckets.length) {
chart.className = 'chart-placeholder';
chart.innerHTML = 'No activity in the selected window.';
legend.innerHTML = '';
return;
}
const totalsBySource = new Map();
let maxTotal = 0;
for (const bucket of buckets) {
maxTotal = Math.max(maxTotal, Number(bucket.total_events || 0));
for (const source of Array.isArray(bucket.sources) ? bucket.sources : []) {
totalsBySource.set(source.source_name, (totalsBySource.get(source.source_name) || 0) + Number(source.events || 0));
}
}
const orderedSources = [...totalsBySource.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
legend.innerHTML = orderedSources.map(entry => '<span class="legend-chip"><span class="legend-dot" style="background:' + colorForSource(entry[0]) + '"></span>' + escapeHtml(entry[0]) + '<span class="muted">' + escapeHtml(String(entry[1])) + '</span></span>').join('');
const width = 1100;
const height = 260;
const chartHeight = 190;
const chartTop = 18;
const chartLeft = 18;
const chartWidth = width - (chartLeft * 2);
const step = chartWidth / Math.max(buckets.length, 1);
const barWidth = Math.max(2, step - 1);
const lines = [0.25, 0.5, 0.75, 1].map(fraction => {
const y = chartTop + chartHeight - (chartHeight * fraction);
return '<line class="activity-gridline" x1="' + chartLeft + '" y1="' + y + '" x2="' + (width - chartLeft) + '" y2="' + y + '"></line>';
}).join('');
const bars = buckets.map((bucket, index) => {
const x = chartLeft + (index * step);
let currentY = chartTop + chartHeight;
return (Array.isArray(bucket.sources) ? bucket.sources : []).map(source => {
const segmentHeight = maxTotal > 0 ? Math.max((Number(source.events || 0) / maxTotal) * chartHeight, 0) : 0;
currentY -= segmentHeight;
if (segmentHeight <= 0) {
return '';
}
return '<rect x="' + x.toFixed(2) + '" y="' + currentY.toFixed(2) + '" width="' + barWidth.toFixed(2) + '" height="' + segmentHeight.toFixed(2) + '" rx="1.5" fill="' + colorForSource(source.source_name) + '"><title>' + escapeHtml((source.source_name || 'unknown') + ': ' + source.events + ' request(s)') + '</title></rect>';
}).join('');
}).join('');
const tickStep = Math.max(1, Math.floor(buckets.length / 6));
const ticks = buckets.map((bucket, index) => {
if (index % tickStep !== 0 && index !== buckets.length - 1) {
return '';
}
const x = chartLeft + (index * step) + (barWidth / 2);
return '<text class="activity-axis" x="' + x.toFixed(2) + '" y="' + (height - 10) + '" text-anchor="middle">' + escapeHtml(formatBucketLabel(bucket.bucket_start)) + '</text>';
}).join('');
chart.className = 'activity-shell';
chart.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" role="img" aria-label="Activity histogram">' + lines + bars + ticks + '</svg>';
}
function renderDonut(targetId, items, colors, totalLabel) {
const host = document.getElementById(targetId);
const visibleItems = (Array.isArray(items) ? items : []).filter(item => Number(item.events || 0) > 0);
if (!visibleItems.length) {
host.className = 'donut-shell chart-placeholder';
host.innerHTML = 'No matching requests in the selected window.';
return;
}
const total = visibleItems.reduce((sum, item) => sum + Number(item.events || 0), 0);
let offset = 0;
const gradientParts = visibleItems.map(item => {
const key = item.key || item.method;
const color = colors[key] || '#64748b';
const slice = total > 0 ? (Number(item.events || 0) / total) * 360 : 0;
const part = color + ' ' + offset + 'deg ' + (offset + slice) + 'deg';
offset += slice;
return part;
});
host.className = 'donut-shell';
host.innerHTML = [
'<div class="donut-grid">',
' <div class="donut" style="background: conic-gradient(' + gradientParts.join(', ') + ')">',
' <div class="donut-hole"><strong>' + escapeHtml(total) + '</strong><span>' + escapeHtml(totalLabel) + '</span></div>',
' </div>',
' <ul class="legend-list">',
visibleItems.map(item => '<li><span class="legend-label"><span class="legend-dot" style="background:' + (colors[item.key || item.method] || '#64748b') + '"></span><span>' + escapeHtml(item.label || item.method || item.key || 'Other') + '</span></span><span class="legend-value">' + escapeHtml(String(item.events || 0)) + '</span></li>').join(''),
' </ul>',
'</div>'
].join('');
}
function renderMethods(data) {
const colors = { GET: '#22c55e', POST: '#f59e0b', HEAD: '#38bdf8', PUT: '#a78bfa', DELETE: '#ef4444', PATCH: '#14b8a6', OPTIONS: '#f97316', OTHER: '#64748b' };
const items = (Array.isArray(data.methods) ? data.methods : []).map(item => ({ method: item.method || 'OTHER', label: item.method || 'OTHER', events: item.events || 0 }));
renderDonut('methods-chart', items, colors, 'requests');
}
function renderBots(data) {
const colors = { known: '#2563eb', possible: '#f59e0b', other: '#475569' };
renderDonut('bots-chart', Array.isArray(data.bots) ? data.bots : [], colors, 'requests');
}
async function refresh() {
const response = await fetch('/api/overview?hours=' + recentHours + '&limit=10');
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return;
}
renderStats(payload || {});
renderActivity(payload || {});
renderMethods(payload || {});
renderBots(payload || {});
renderLeaderboards(payload || {});
}
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>`
const queryLogHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: linear-gradient(180deg, #0f172a 0%, #020617 100%); color: #e2e8f0; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; background: rgba(2, 6, 23, .92); }
.header-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.nav { display: inline-flex; gap: .5rem; flex-wrap: wrap; }
.nav-link { padding: .45rem .75rem; border-radius: 999px; border: 1px solid #334155; background: #111827; color: #cbd5e1; text-decoration: none; }
.nav-link.active { background: linear-gradient(135deg, #2563eb, #0f766e); color: white; border-color: transparent; }
main { padding: 1.5rem; display: grid; gap: 1rem; }
h1, h2 { margin: 0; }
a { color: #93c5fd; text-decoration: none; }
a:hover { text-decoration: underline; }
.muted { color: #94a3b8; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .85rem; padding: 1rem; overflow: hidden; }
.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; }
.toggle { display: inline-flex; align-items: center; gap: .45rem; font-size: .95rem; color: #cbd5e1; }
.toggle input { margin: 0; }
.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; }
.table-shell { overflow: hidden; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { padding: .6rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
thead th { color: #93c5fd; }
tbody tr:nth-child(even) { background: rgba(15, 23, 42, .55); }
th.tight, td.tight { white-space: nowrap; width: 1%; }
th.request-col, td.request-cell { width: auto; }
td.request-cell { overflow: hidden; }
.request-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; }
.status.blocked { background: #7f1d1d; }
.status.review { background: #78350f; }
.status.allowed { background: #14532d; }
.status.observed { background: #1e293b; }
.method { display: inline-block; padding: .2rem .45rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
.method.get { background: #14532d; color: #dcfce7; }
.method.post { background: #78350f; color: #fef3c7; }
.method.head { background: #0c4a6e; color: #e0f2fe; }
.method.other { background: #334155; color: #e2e8f0; }
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
.action-link, button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; border-radius: .45rem; padding: .3rem .6rem; font-size: .9rem; }
.action-link { background: #1e293b; color: #e2e8f0; text-decoration: none; }
button { background: #2563eb; color: white; border: 0; cursor: pointer; }
button.secondary { background: #475569; }
button.danger { background: #dc2626; }
button[disabled] { opacity: .5; cursor: default; }
.ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; }
.bot-chip { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border-radius: 999px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: .72rem; font-weight: 700; cursor: help; flex: 0 0 auto; }
.bot-chip.verified { border-color: #2563eb; }
.bot-chip.hint { border-style: dashed; }
.bot-chip.google { background: #2563eb; color: white; }
.bot-chip.bing { background: #0284c7; color: white; }
.bot-chip.apple { background: #475569; color: white; }
.bot-chip.meta { background: #2563eb; color: white; }
.bot-chip.duckduckgo { background: #ea580c; color: white; }
.bot-chip.openai { background: #059669; color: white; }
.bot-chip.anthropic { background: #b45309; color: white; }
.bot-chip.perplexity { background: #0f766e; color: white; }
.bot-chip.semrush { background: #db2777; color: white; }
.bot-chip.yandex { background: #dc2626; color: white; }
.bot-chip.baidu { background: #7c3aed; color: white; }
.bot-chip.bytespider { background: #111827; color: white; }
@media (max-width: 960px) {
.toolbar, .controls { align-items: flex-start; }
.toolbar-actions, .controls-group { width: 100%; justify-content: flex-start; }
th, td { font-size: .88rem; }
}
@media (max-width: 720px) {
header { padding: .9rem 1rem; }
main { padding: 1rem; }
.panel { padding: .85rem; }
.table-shell { overflow-x: auto; }
table { min-width: 900px; }
}
</style>
</head>
<body>
<header>
<div class="header-row">
<div>
<h1>{{ .Title }}</h1>
<div class="muted">Last 24 hours, newest first</div>
</div>
<nav class="nav">
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link active" href="/requests">Requests Log</a>
</nav>
</div>
</header>
<main>
<section class="panel controls">
<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-allowed-toggle" type="checkbox" checked onchange="toggleAllowed()">Show allowed</label>
<label class="toggle"><input id="show-review-toggle" type="checkbox" onchange="toggleReviewOnly()">Review only</label>
<label class="toggle"><input id="auto-refresh-toggle" type="checkbox" onchange="toggleAutoRefresh()">Auto refresh</label>
</div>
<div class="muted">These filters affect the full Requests Log.</div>
</section>
<section class="panel">
<div class="toolbar">
<div>
<h2>Recent requests</h2>
<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>
</div>
</div>
<div class="table-shell">
<table>
<thead>
<tr>
<th class="tight">Time</th>
<th class="tight">Source</th>
<th class="tight">IP</th>
<th class="tight">Method</th>
<th class="request-col">Request</th>
<th class="tight">Status</th>
<th class="tight">State</th>
<th class="tight">Reason</th>
<th class="tight">Actions</th>
</tr>
</thead>
<tbody id="events-body">
<tr><td colspan="9" class="muted">Loading requests log…</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<script>
const recentHours = 24;
const pageSize = 100;
let showKnownBots = loadBooleanPreference('cob.dashboard.showKnownBots', true);
let showAllowed = loadBooleanPreference('cob.dashboard.showAllowed', true);
let showReviewOnly = loadBooleanPreference('cob.queryLog.showReviewOnly', false);
let autoRefresh = loadBooleanPreference('cob.requests.autoRefresh', false);
let currentPage = 1;
let refreshTimer = null;
function loadBooleanPreference(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : raw === 'true';
} catch (error) {
return fallback;
}
}
function saveBooleanPreference(key, value) {
localStorage.setItem(key, value ? 'true' : 'false');
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatDate(value) {
if (!value) {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString();
}
function methodClass(method) {
const normalized = String(method || '').toLowerCase();
if (normalized === 'get' || normalized === 'post' || normalized === 'head') {
return normalized;
}
return 'other';
}
function botVisual(bot) {
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
const catalog = [
{ match: ['google'], short: 'G', className: 'google' },
{ match: ['bing', 'microsoft'], short: 'B', className: 'bing' },
{ match: ['apple'], short: 'A', className: 'apple' },
{ match: ['facebook', 'meta'], short: 'M', className: 'meta' },
{ match: ['duckduckgo', 'duckduckbot'], short: 'D', className: 'duckduckgo' },
{ match: ['gptbot', 'openai'], short: 'O', className: 'openai' },
{ match: ['claudebot', 'anthropic'], short: 'C', className: 'anthropic' },
{ match: ['perplexity'], short: 'P', className: 'perplexity' },
{ match: ['semrush'], short: 'S', className: 'semrush' },
{ match: ['yandex'], short: 'Y', className: 'yandex' },
{ match: ['baidu'], short: 'B', className: 'baidu' },
{ match: ['bytespider', 'tiktok'], short: 'T', className: 'bytespider' },
];
for (const entry of catalog) {
if (entry.match.some(fragment => candidate.includes(fragment))) {
return entry;
}
}
const name = String((bot || {}).name || '').trim();
return { short: (name[0] || '?').toUpperCase(), className: 'generic' };
}
function renderBotChip(bot) {
if (!bot) {
return '';
}
const visual = botVisual(bot);
const statusClass = bot.verified ? 'verified' : 'hint';
const title = (bot.name || 'Bot') + (bot.verified ? '' : ' (possible)');
return '<span class="bot-chip ' + escapeHtml(visual.className) + ' ' + statusClass + '" title="' + escapeHtml(title) + '">' + escapeHtml(visual.short) + '</span>';
}
function renderActions(item) {
const actions = item.actions || {};
const buttons = ['<a class="action-link" href="/ips/' + encodeURIComponent(item.client_ip) + '">Open</a>'];
if (actions.can_unblock) {
buttons.push('<button class="secondary" data-ip="' + escapeHtml(item.client_ip) + '" onclick="sendAction(this.dataset.ip, \'unblock\', \'Reason for manual unblock\')">Unblock</button>');
} else if (actions.can_block) {
buttons.push('<button class="danger" data-ip="' + escapeHtml(item.client_ip) + '" onclick="sendAction(this.dataset.ip, \'block\', \'Reason for manual block\')">Block</button>');
}
return '<div class="actions">' + buttons.join('') + '</div>';
}
function applyToggles() {
document.getElementById('show-bots-toggle').checked = showKnownBots;
document.getElementById('show-allowed-toggle').checked = showAllowed;
document.getElementById('show-review-toggle').checked = showReviewOnly;
document.getElementById('auto-refresh-toggle').checked = autoRefresh;
}
function updatePager(payload) {
const page = Number(payload.page || currentPage || 1);
document.getElementById('page-status').textContent = 'Page ' + page;
document.getElementById('prev-page').disabled = !payload.has_prev;
document.getElementById('next-page').disabled = !payload.has_next;
}
function scheduleRefresh() {
if (refreshTimer) {
window.clearTimeout(refreshTimer);
refreshTimer = null;
}
if (autoRefresh) {
refreshTimer = window.setTimeout(refresh, 5000);
}
}
function toggleKnownBots() {
showKnownBots = document.getElementById('show-bots-toggle').checked;
saveBooleanPreference('cob.dashboard.showKnownBots', showKnownBots);
currentPage = 1;
refresh();
}
function toggleAllowed() {
showAllowed = document.getElementById('show-allowed-toggle').checked;
saveBooleanPreference('cob.dashboard.showAllowed', showAllowed);
currentPage = 1;
refresh();
}
function toggleReviewOnly() {
showReviewOnly = document.getElementById('show-review-toggle').checked;
saveBooleanPreference('cob.queryLog.showReviewOnly', showReviewOnly);
currentPage = 1;
refresh();
}
function toggleAutoRefresh() {
autoRefresh = document.getElementById('auto-refresh-toggle').checked;
saveBooleanPreference('cob.requests.autoRefresh', autoRefresh);
scheduleRefresh();
}
function goToPreviousPage() {
if (currentPage <= 1) {
return;
}
currentPage -= 1;
refresh();
}
function goToNextPage() {
currentPage += 1;
refresh();
}
function refreshNow() {
refresh();
}
async function sendAction(ip, action, promptLabel) {
const reason = window.prompt(promptLabel, '');
if (reason === null) {
return;
}
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, actor: 'web-ui' }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
window.alert(payload.error || 'Request failed');
return;
}
refresh();
}
function renderEvents(payload) {
const items = Array.isArray(payload.items) ? payload.items : [];
const rows = items.map(item => {
const requestLabel = ((item.host || '') ? (item.host + item.uri) : (item.uri || '—'));
return [
'<tr>',
' <td class="tight">' + escapeHtml(formatDate(item.occurred_at)) + '</td>',
' <td class="tight">' + escapeHtml(item.source_name || '—') + '</td>',
' <td class="tight mono"><div class="ip-cell">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.client_ip) + '">' + escapeHtml(item.client_ip || '—') + '</a></div></td>',
' <td class="tight"><span class="method ' + escapeHtml(methodClass(item.method)) + '">' + escapeHtml(item.method || 'OTHER') + '</span></td>',
' <td class="request-cell mono"><span class="request-text" title="' + escapeHtml(requestLabel) + '">' + escapeHtml(requestLabel) + '</span></td>',
' <td class="tight">' + escapeHtml(String(item.status || 0)) + '</td>',
' <td class="tight"><span class="status ' + escapeHtml(item.current_state || 'observed') + '">' + escapeHtml(item.current_state || 'observed') + '</span></td>',
' <td class="tight">' + escapeHtml(item.decision_reason || '—') + '</td>',
' <td class="tight">' + 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>';
updatePager(payload || {});
}
async function refresh() {
applyToggles();
const response = await fetch('/api/events?hours=' + recentHours + '&limit=' + pageSize + '&page=' + currentPage + '&show_known_bots=' + (showKnownBots ? 'true' : 'false') + '&show_allowed=' + (showAllowed ? 'true' : 'false') + '&review_only=' + (showReviewOnly ? 'true' : 'false'));
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>';
updatePager({ page: currentPage, has_prev: currentPage > 1, has_next: false });
scheduleRefresh();
return;
}
currentPage = Number(payload.page || currentPage || 1);
renderEvents(payload);
scheduleRefresh();
}
applyToggles();
refresh();
</script>
</body>
</html>`
const ipDetailsHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: #020617; color: #e2e8f0; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; background: #020617; }
main { padding: 1.5rem; display: grid; gap: 1.5rem; }
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
h1, h2 { margin: 0 0 .75rem 0; }
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
.status.blocked { background: #7f1d1d; }
.status.review { background: #78350f; }
.status.allowed { background: #14532d; }
.status.observed { background: #1e293b; }
.muted { color: #94a3b8; }
.badge { display: inline-flex; align-items: center; gap: .35rem; padding: .2rem .55rem; border-radius: 999px; background: #1d4ed8; color: white; font-size: .8rem; }
.bot-badge { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; }
.bot-badge.bot-verified { border-color: #1d4ed8; }
.bot-badge.bot-hint { border-style: dashed; }
.bot-mark { display: inline-flex; align-items: center; justify-content: center; width: 1.15rem; height: 1.15rem; border-radius: 999px; font-size: .72rem; font-weight: 700; color: white; background: #475569; }
.bot-mark.google { background: #2563eb; }
.bot-mark.bing { background: #0284c7; }
.bot-mark.apple { background: #475569; }
.bot-mark.meta { background: #2563eb; }
.bot-mark.duckduckgo { background: #ea580c; }
.bot-mark.openai { background: #059669; }
.bot-mark.anthropic { background: #b45309; }
.bot-mark.perplexity { background: #0f766e; }
.bot-mark.semrush { background: #db2777; }
.bot-mark.yandex { background: #dc2626; }
.bot-mark.baidu { background: #7c3aed; }
.bot-mark.bytespider { background: #111827; }
.kv { display: grid; gap: .45rem; }
.actions { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .9rem; }
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; }
.mono { font-family: ui-monospace, monospace; }
a { color: #93c5fd; text-decoration: none; }
.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>
</head>
<body data-ip="{{ .IP }}">
<header>
<div><a href="/">← Back</a></div>
<h1 class="mono">{{ .IP }}</h1>
</header>
<main>
<section class="panel">
<h2>State</h2>
<div id="state" class="kv"></div>
<div id="actions" class="actions"></div>
<div class="hint">Clear override removes the local manual override only. It does not change the current OPNsense alias entry.</div>
</section>
<section class="panel">
<h2>Investigation</h2>
<div id="investigation" class="kv"></div>
</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>
</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>
</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>
</section>
</main>
<script>
const ip = document.body.dataset.ip || '';
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character]));
}
function formatDate(value) {
if (!value) {
return '—';
}
return new Date(value).toLocaleString();
}
function botVisual(bot) {
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
const catalog = [
{ match: ['google'], short: 'G', className: 'google' },
{ match: ['bing', 'microsoft'], short: 'B', className: 'bing' },
{ match: ['apple'], short: 'A', className: 'apple' },
{ match: ['facebook', 'meta'], short: 'M', className: 'meta' },
{ match: ['duckduckgo', 'duckduckbot'], short: 'D', className: 'duckduckgo' },
{ match: ['gptbot', 'openai'], short: 'O', className: 'openai' },
{ match: ['claudebot', 'anthropic'], short: 'C', className: 'anthropic' },
{ match: ['perplexity'], short: 'P', className: 'perplexity' },
{ match: ['semrush'], short: 'S', className: 'semrush' },
{ match: ['yandex'], short: 'Y', className: 'yandex' },
{ match: ['baidu'], short: 'B', className: 'baidu' },
{ match: ['bytespider', 'tiktok'], short: 'T', className: 'bytespider' },
];
for (const entry of catalog) {
if (entry.match.some(fragment => candidate.includes(fragment))) {
return entry;
}
}
const name = String((bot || {}).name || '').trim();
return { short: (name[0] || '?').toUpperCase(), className: 'generic' };
}
function renderBotBadge(bot) {
const visual = botVisual(bot);
const badgeClass = bot.verified ? 'bot-verified' : 'bot-hint';
return '<span class="badge bot-badge ' + badgeClass + '"><span class="bot-mark ' + escapeHtml(visual.className) + '">' + escapeHtml(visual.short) + '</span><span>' + escapeHtml(bot.name || 'Bot') + '</span></span>';
}
async function sendAction(action, promptLabel) {
const reason = window.prompt(promptLabel, '');
if (reason === null) {
return;
}
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, actor: 'web-ui' }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
window.alert(payload.error || 'Request failed');
return;
}
const data = await response.json();
renderAll(data);
}
async function investigate() {
document.getElementById('investigation').innerHTML = '<div class="muted">Refreshing investigation…</div>';
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/investigate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actor: 'web-ui' }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
document.getElementById('investigation').innerHTML = '<div class="muted">' + escapeHtml(payload.error || 'Investigation failed') + '</div>';
return;
}
const data = await response.json();
renderAll(data);
}
function renderState(data) {
const state = data.state || {};
const opnsense = data.opnsense || {};
const opnsenseLabel = opnsense.configured ? (opnsense.error ? ('unknown (' + opnsense.error + ')') : (opnsense.present ? 'blocked' : 'not blocked')) : 'disabled';
document.getElementById('state').innerHTML = [
'<div><strong>State</strong>: <span class="status ' + escapeHtml(state.state) + '">' + escapeHtml(state.state) + '</span></div>',
'<div><strong>Override</strong>: ' + escapeHtml(state.manual_override) + '</div>',
'<div><strong>Total events</strong>: ' + escapeHtml(state.total_events) + '</div>',
'<div><strong>Last seen</strong>: ' + escapeHtml(formatDate(state.last_seen_at)) + '</div>',
'<div><strong>Reason</strong>: ' + escapeHtml(state.state_reason) + '</div>',
'<div><strong>OPNsense alias</strong>: ' + escapeHtml(opnsenseLabel) + '</div>'
].join('');
}
function renderActions(data) {
const actions = data.actions || {};
const buttons = [];
if (actions.can_unblock) {
buttons.push('<button onclick="sendAction(&quot;unblock&quot;, &quot;Reason for manual unblock&quot;)">Unblock</button>');
} else if (actions.can_block) {
buttons.push('<button class="danger" onclick="sendAction(&quot;block&quot;, &quot;Reason for manual block&quot;)">Block</button>');
}
if (actions.can_clear_override) {
buttons.push('<button class="secondary" onclick="sendAction(&quot;clear-override&quot;, &quot;Reason for clearing the manual override&quot;)">Clear override</button>');
}
buttons.push('<button class="secondary" onclick="investigate()">Refresh investigation</button>');
document.getElementById('actions').innerHTML = buttons.join('');
}
function renderInvestigation(investigation) {
if (!investigation) {
document.getElementById('investigation').innerHTML = '<div class="muted">No cached investigation yet.</div>';
return;
}
const rows = [];
if (investigation.bot) {
rows.push('<div><strong>' + (investigation.bot.verified ? 'Bot' : 'Possible bot') + '</strong>: ' + renderBotBadge(investigation.bot) + ' via ' + escapeHtml(investigation.bot.method) + (investigation.bot.verified ? '' : ' (not verified)') + '</div>');
} else {
rows.push('<div><strong>Bot</strong>: no verified bot match</div>');
}
if (investigation.reverse_dns) {
rows.push('<div><strong>Reverse DNS</strong>: <span class="mono">' + escapeHtml(investigation.reverse_dns.ptr || '—') + '</span>' + (investigation.reverse_dns.forward_confirmed ? ' · forward-confirmed' : '') + '</div>');
}
if (investigation.registration) {
rows.push('<div><strong>Registration</strong>: ' + escapeHtml(investigation.registration.organization || investigation.registration.name || '—') + '</div>');
rows.push('<div><strong>Prefix</strong>: <span class="mono">' + escapeHtml(investigation.registration.prefix || '—') + '</span></div>');
rows.push('<div><strong>Country</strong>: ' + escapeHtml(investigation.registration.country || '—') + '</div>');
rows.push('<div><strong>Abuse contact</strong>: ' + escapeHtml(investigation.registration.abuse_email || '—') + '</div>');
}
if (investigation.reputation) {
const label = investigation.reputation.spamhaus_listed ? 'Yes' : 'No';
rows.push('<div><strong>Spamhaus</strong>: ' + escapeHtml(label) + '</div>');
if (investigation.reputation.error) {
rows.push('<div><strong>Spamhaus error</strong>: ' + escapeHtml(investigation.reputation.error) + '</div>');
}
}
rows.push('<div><strong>Updated</strong>: ' + escapeHtml(formatDate(investigation.updated_at)) + '</div>');
if (investigation.error) {
rows.push('<div><strong>Lookup warning</strong>: ' + escapeHtml(investigation.error) + '</div>');
}
document.getElementById('investigation').innerHTML = rows.join('');
}
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>';
}
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>';
}
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>';
}
function renderAll(data) {
renderState(data || {});
renderActions(data || {});
renderInvestigation((data || {}).investigation || null);
renderEvents((data || {}).recent_events || []);
renderDecisions((data || {}).decisions || []);
renderBackend((data || {}).backend_actions || []);
}
async function refresh() {
const response = await fetch('/api/ips/' + encodeURIComponent(ip));
const data = await response.json();
renderAll(data);
}
refresh();
</script>
</body>
</html>`