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

2118 lines
90 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)
CountEvents(ctx context.Context, since time.Time, options model.EventListOptions) (int64, error)
ListSourceNames() []string
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
Sources []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)
mux.Handle("/assets/", assetHandler())
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", Sources: h.app.ListSourceNames()})
}
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
}
stateFilter := strings.TrimSpace(r.URL.Query().Get("state"))
if stateFilter == "" && queryBool(r, "review_only", false) {
stateFilter = string(model.IPStateReview)
}
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,
Source: strings.TrimSpace(r.URL.Query().Get("source")),
Method: strings.TrimSpace(r.URL.Query().Get("method")),
StatusFilter: strings.TrimSpace(r.URL.Query().Get("status")),
State: stateFilter,
BotFilter: strings.TrimSpace(r.URL.Query().Get("bot_filter")),
SortBy: strings.TrimSpace(r.URL.Query().Get("sort_by")),
SortDesc: !strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("sort_dir")), "asc"),
}
events, err := h.app.ListEvents(r.Context(), since, limit+1, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
totalItems, err := h.app.CountEvents(r.Context(), since, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
hasNext := len(events) > limit
if hasNext {
events = events[:limit]
}
lastPage := 1
if totalItems > 0 {
lastPage = int((totalItems + int64(limit) - 1) / int64(limit))
}
writeJSON(w, http.StatusOK, model.EventPage{
Items: events,
Data: events,
Page: page,
Limit: limit,
HasPrev: page > 1,
HasNext: hasNext,
LastPage: lastPage,
TotalItems: totalItems,
})
}
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 { min-height: 18rem; }
.activity-canvas-shell { position: relative; min-height: 18rem; width: 100%; }
.activity-canvas-shell canvas { display: block; width: 100% !important; height: 18rem !important; }
.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-chart-shell { width: min(100%, 220px); aspect-ratio: 1; position: relative; margin: 0 auto; }
.donut-chart-shell canvas { display: block; width: 100% !important; height: 100% !important; }
.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; pointer-events: none; }
.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 src="/assets/chartjs/chart.umd.min.js"></script>
<script>
const recentHours = 24;
const sourcePalette = ['#38bdf8', '#22c55e', '#f59e0b', '#a78bfa', '#f97316', '#14b8a6', '#ec4899', '#60a5fa', '#84cc16', '#ef4444'];
const dashboardCharts = { activity: null, methods: null, bots: null };
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#cbd5e1';
Chart.defaults.borderColor = '#1e293b';
Chart.defaults.font.family = 'system-ui, sans-serif';
Chart.defaults.animation = false;
}
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 destroyDashboardChart(key) {
if (dashboardCharts[key]) {
dashboardCharts[key].destroy();
dashboardCharts[key] = null;
}
}
function mountDashboardChart(key, canvasId, config) {
destroyDashboardChart(key);
if (typeof Chart === 'undefined') {
return;
}
const canvas = document.getElementById(canvasId);
if (!canvas) {
return;
}
dashboardCharts[key] = new Chart(canvas, config);
}
function formatRequestCount(value) {
const count = Number(value || 0);
return count + (count === 1 ? ' request' : ' requests');
}
function formatBucketTooltipLabel(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function formatPercent(value, total) {
const ratio = total > 0 ? (Number(value || 0) / total) * 100 : 0;
if (!Number.isFinite(ratio)) {
return '0%';
}
return (ratio >= 10 || Number.isInteger(ratio) ? ratio.toFixed(0) : ratio.toFixed(1)) + '%';
}
function showChartLoadError(host, message) {
host.className = 'chart-placeholder';
host.innerHTML = escapeHtml(message);
}
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) {
destroyDashboardChart('activity');
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('');
chart.className = 'activity-shell';
chart.innerHTML = '<div class="activity-canvas-shell"><canvas id="activity-canvas" aria-label="Activity histogram"></canvas></div>';
if (typeof Chart === 'undefined') {
showChartLoadError(chart, 'Chart.js failed to load.');
return;
}
const labels = buckets.map(bucket => formatBucketLabel(bucket.bucket_start));
const tooltipLabels = buckets.map(bucket => formatBucketTooltipLabel(bucket.bucket_start));
const datasets = orderedSources.map(entry => {
const sourceName = entry[0];
return {
label: sourceName,
data: buckets.map(bucket => {
const source = (Array.isArray(bucket.sources) ? bucket.sources : []).find(item => item.source_name === sourceName);
return source ? Number(source.events || 0) : 0;
}),
backgroundColor: colorForSource(sourceName),
borderColor: colorForSource(sourceName),
borderWidth: 0,
borderRadius: 3,
borderSkipped: false,
stack: 'activity',
maxBarThickness: 28,
};
});
mountDashboardChart('activity', 'activity-canvas', {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title(items) {
return items.length ? tooltipLabels[items[0].dataIndex] || '' : '';
},
label(context) {
return String(context.dataset.label || 'unknown') + ': ' + formatRequestCount(context.parsed.y);
},
footer(items) {
const total = items.reduce((sum, item) => sum + Number(item.parsed.y || 0), 0);
return 'Total: ' + formatRequestCount(total);
},
},
},
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: { color: '#64748b', autoSkip: true, maxTicksLimit: 8, maxRotation: 0, minRotation: 0 },
},
y: {
stacked: true,
beginAtZero: true,
ticks: { color: '#64748b', precision: 0 },
grid: { color: '#1e293b' },
},
},
},
});
}
function renderDonut(targetId, chartKey, items, colors, totalLabel) {
const host = document.getElementById(targetId);
const visibleItems = (Array.isArray(items) ? items : []).filter(item => Number(item.events || 0) > 0);
if (!visibleItems.length) {
destroyDashboardChart(chartKey);
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);
const canvasID = targetId + '-canvas';
host.className = 'donut-shell';
host.innerHTML = [
'<div class="donut-grid">',
' <div class="donut-chart-shell">',
' <canvas id="' + escapeHtml(canvasID) + '" aria-label="' + escapeHtml(targetId) + ' chart"></canvas>',
' <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('');
if (typeof Chart === 'undefined') {
showChartLoadError(host, 'Chart.js failed to load.');
return;
}
mountDashboardChart(chartKey, canvasID, {
type: 'doughnut',
data: {
labels: visibleItems.map(item => item.label || item.method || item.key || 'Other'),
datasets: [{
data: visibleItems.map(item => Number(item.events || 0)),
backgroundColor: visibleItems.map(item => colors[item.key || item.method] || '#64748b'),
borderColor: '#0b1220',
borderWidth: 2,
hoverOffset: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context) {
const value = Number(context.parsed || 0);
return String(context.label || 'Other') + ': ' + formatRequestCount(value) + ' (' + formatPercent(value, total) + ')';
},
},
},
},
},
});
}
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', 'methods', items, colors, 'requests');
}
function renderBots(data) {
const colors = { known: '#2563eb', possible: '#f59e0b', other: '#475569' };
renderDonut('bots-chart', 'bots', 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>
<link rel="stylesheet" href="/assets/tabulator/tabulator_midnight.min.css">
<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; }
.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; }
.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; }
.controls-body { padding: 1rem; display: grid; gap: 1rem; }
.controls-help { font-size: .92rem; }
.filters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .85rem; align-items: end; }
.field { display: grid; gap: .35rem; }
.field label { font-size: .85rem; color: #cbd5e1; }
.field input, .field select { width: 100%; box-sizing: border-box; padding: .55rem .65rem; border-radius: .55rem; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; }
.field.inline-toggle { display: flex; align-items: center; gap: .55rem; padding-top: 1.7rem; }
.field.inline-toggle input { width: auto; }
.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; }
.panel-actions { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; }
.panel-actions .spacer { flex: 1; }
.table-panel { padding: 1rem; }
.tabulator-shell { border: 1px solid #1e293b; border-radius: .75rem; overflow: hidden; width: 100%; }
#requests-table { width: 100%; }
button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; border-radius: .45rem; padding: .3rem .75rem; font-size: .9rem; white-space: nowrap; background: #2563eb; color: white; border: 0; cursor: pointer; }
button.secondary { background: #475569; }
button[disabled] { opacity: .5; cursor: default; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; max-width: 100%; }
.ip-link { display: block; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.request-text, .reason-text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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; }
.method-pill { display: inline-block; padding: .2rem .45rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
.method-pill.get { background: #14532d; color: #dcfce7; }
.method-pill.post { background: #78350f; color: #fef3c7; }
.method-pill.head { background: #0c4a6e; color: #e0f2fe; }
.method-pill.other { background: #334155; color: #e2e8f0; }
.status-code { display: inline-block; font-size: .84rem; font-weight: 700; color: #e2e8f0; }
.status-code.client-error { color: #facc15; }
.status-code.server-error { color: #fb923c; }
.state-pill { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
.state-pill.blocked { background: #7f1d1d; }
.state-pill.review { background: #78350f; }
.state-pill.allowed { background: #14532d; }
.state-pill.observed { background: #1e293b; }
.action-icon { width: 1.55rem; height: 1.55rem; min-width: 1.55rem; padding: 0; border-radius: .45rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #334155; cursor: pointer; line-height: 1; vertical-align: middle; background: #0f172a; color: #e2e8f0; }
.action-icon svg { width: .9rem; height: .9rem; display: block; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.action-icon.block { background: #3f0b14; border-color: #7f1d1d; color: #fecaca; }
.action-icon.unblock { background: #172033; border-color: #334155; color: #cbd5e1; }
.tabulator { background: transparent; border: 0; font-size: .92rem; width: 100% !important; }
.tabulator .tabulator-header { background: #0f172a; border-bottom: 1px solid #334155; }
.tabulator .tabulator-header .tabulator-header-contents { width: 100% !important; }
.tabulator .tabulator-header .tabulator-header-contents .tabulator-headers { display: block; min-width: 100% !important; width: 100% !important; }
.tabulator .tabulator-header .tabulator-col { background: #0f172a; border-right: 1px solid #1e293b; }
.tabulator .tabulator-header .tabulator-col.tabulator-sortable:hover { background: #111827; }
.tabulator .tabulator-header .tabulator-col .tabulator-col-content { padding: .75rem .65rem; }
.tabulator .tabulator-row { background: #111827; border-bottom: 1px solid #1e293b; }
.tabulator .tabulator-row:nth-child(even) { background: rgba(15, 23, 42, .55); }
.tabulator .tabulator-tableholder { width: 100% !important; }
.tabulator .tabulator-tableholder .tabulator-table { min-width: 100% !important; width: 100% !important; }
.tabulator .tabulator-cell { padding: .55rem .65rem; border-right: 1px solid #1e293b; color: #e2e8f0; }
.tabulator .tabulator-footer { background: #0b1120; border-top: 1px solid #334155; color: #cbd5e1; }
.tabulator .tabulator-footer .tabulator-paginator { color: #cbd5e1; }
.tabulator .tabulator-footer .tabulator-page { background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: .45rem; }
.tabulator .tabulator-footer .tabulator-page.active { background: #2563eb; border-color: transparent; color: white; }
.tabulator .tabulator-footer .tabulator-page:disabled { opacity: .5; }
.tabulator-placeholder { padding: 1rem; color: #94a3b8; }
@media (max-width: 960px) {
.toolbar, .panel-actions { align-items: flex-start; }
.toolbar-actions, .panel-actions, .filters-grid { width: 100%; }
}
@media (max-width: 720px) {
header { padding: .9rem 1rem; }
main { padding: 1rem; }
.filters-grid { grid-template-columns: 1fr; }
.toolbar-actions { justify-content: flex-start; }
.tabulator { font-size: .88rem; }
.tabulator .tabulator-header .tabulator-col .tabulator-col-content,
.tabulator .tabulator-cell { padding-left: .5rem; padding-right: .5rem; }
}
</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>
<details class="panel controls-panel" id="options-panel">
<summary>Filters, pagination, and columns</summary>
<div class="controls-body">
<div class="controls-help muted">Sort by clicking a column header. Filters remain server-side, while Tabulator handles rendering and pagination.</div>
<form class="filters-grid" id="controls-form" onsubmit="applyFilters(event)">
<div class="field">
<label for="source-filter">Source</label>
<select id="source-filter">
<option value="">Any source</option>
{{ range .Sources }}<option value="{{ . }}">{{ . }}</option>{{ end }}
</select>
</div>
<div class="field">
<label for="method-filter">Method</label>
<select id="method-filter">
<option value="">Any method</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="HEAD">HEAD</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
<option value="OPTIONS">OPTIONS</option>
<option value="OTHER">OTHER</option>
</select>
</div>
<div class="field">
<label for="status-filter">HTTP status</label>
<select id="status-filter">
<option value="">Any status</option>
<optgroup label="Status classes">
<option value="2xx">2xx</option>
<option value="3xx">3xx</option>
<option value="4xx">4xx</option>
<option value="5xx">5xx</option>
</optgroup>
<optgroup label="Common client errors">
<option value="400">400</option>
<option value="401">401</option>
<option value="403">403</option>
<option value="404">404</option>
<option value="405">405</option>
<option value="408">408</option>
<option value="410">410</option>
<option value="429">429</option>
</optgroup>
<optgroup label="Common server errors">
<option value="500">500</option>
<option value="502">502</option>
<option value="503">503</option>
<option value="504">504</option>
</optgroup>
</select>
</div>
<div class="field">
<label for="state-filter">State</label>
<select id="state-filter">
<option value="">All</option>
<option value="observed">Observed</option>
<option value="review">Review</option>
<option value="blocked">Blocked</option>
<option value="allowed">Allowed</option>
</select>
</div>
<div class="field">
<label for="bot-filter">Bots</label>
<select id="bot-filter">
<option value="all">All traffic</option>
<option value="known">Known bots only</option>
<option value="possible">Possible bots only</option>
<option value="any">Any bot</option>
<option value="non-bot">Non-bots only</option>
</select>
</div>
<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>
</select>
</div>
<div class="field inline-toggle">
<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-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>
<button type="button" class="secondary" onclick="resetControls()">Reset</button>
<div class="spacer"></div>
<div class="muted" id="controls-summary">No active filters.</div>
</div>
</div>
</details>
<section class="panel table-panel">
<div class="toolbar">
<div>
<h2>Recent requests</h2>
<div class="muted">Click an IP to open its detail page. Sort by clicking a column header.</div>
</div>
<div class="toolbar-actions">
<button class="secondary" type="button" onclick="refreshNow()">Refresh now</button>
</div>
</div>
<div class="muted" id="table-status"></div>
<div class="tabulator-shell">
<div id="requests-table"></div>
</div>
</section>
</main>
<script src="/assets/tabulator/tabulator.min.js"></script>
<script>
const recentHours = 24;
const defaultVisibleColumns = { method: true, source: true, status: true, state: true, reason: true, actions: true };
let sourceFilter = loadStringPreference('cob.requests.source', '');
let methodFilter = loadStringPreference('cob.requests.method', '');
let statusFilter = loadStringPreference('cob.requests.status', '');
let stateFilter = loadStringPreference('cob.requests.state', '');
let botFilter = loadStringPreference('cob.requests.botFilter', 'all');
let pageSize = loadStringPreference('cob.requests.pageSizeTabulator', '25');
let autoRefresh = loadBooleanPreference('cob.requests.autoRefresh', false);
let panelOpen = loadBooleanPreference('cob.requests.panelOpen', false);
let visibleColumns = loadColumnPreferences();
let table = null;
let refreshTimer = null;
function loadBooleanPreference(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : raw === 'true';
} catch (error) {
return fallback;
}
}
function loadStringPreference(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : raw;
} catch (error) {
return fallback;
}
}
function saveBooleanPreference(key, value) {
try {
localStorage.setItem(key, value ? 'true' : 'false');
} catch (error) {
}
}
function saveStringPreference(key, value) {
try {
localStorage.setItem(key, value);
} catch (error) {
}
}
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() {
try {
localStorage.setItem('cob.requests.visibleColumns', JSON.stringify(visibleColumns));
} catch (error) {
}
}
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 actionIconSVG(kind) {
if (kind === 'unblock') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2"></rect><path d="M9 11V8a3 3 0 0 1 5.8-1.1"></path><path d="M16 4.5a3 3 0 0 1 1 2.2"></path></svg>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"></circle><path d="M8.5 8.5l7 7"></path><path d="M15.5 8.5l-7 7"></path></svg>';
}
function renderActions(item) {
const actions = item.actions || {};
if (actions.can_unblock) {
return '<button class="action-icon unblock" 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\')">' + actionIconSVG('unblock') + '</button>';
}
if (actions.can_block) {
return '<button class="action-icon block" 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\')">' + actionIconSVG('block') + '</button>';
}
return '<span class="muted">—</span>';
}
function statusCodeClass(status) {
const code = Number(status || 0);
if (code >= 500) {
return 'server-error';
}
if (code >= 400) {
return 'client-error';
}
return '';
}
function currentSort() {
if (!table) {
return {
field: loadStringPreference('cob.requests.sortField', 'time'),
dir: loadStringPreference('cob.requests.sortDirTabulator', 'desc'),
};
}
const sorters = table.getSorters();
if (Array.isArray(sorters) && sorters.length > 0) {
return {
field: String(sorters[0].field || 'time'),
dir: String(sorters[0].dir || 'desc'),
};
}
return {
field: loadStringPreference('cob.requests.sortField', 'time'),
dir: loadStringPreference('cob.requests.sortDirTabulator', 'desc'),
};
}
function syncColumnControls() {
for (const key of Object.keys(defaultVisibleColumns)) {
const input = document.getElementById('column-' + key);
if (input) {
input.checked = visibleColumns[key] !== false;
}
}
}
function applyControls() {
document.getElementById('source-filter').value = sourceFilter;
document.getElementById('method-filter').value = methodFilter;
document.getElementById('status-filter').value = statusFilter;
document.getElementById('state-filter').value = stateFilter;
document.getElementById('bot-filter').value = botFilter;
document.getElementById('page-size').value = pageSize;
document.getElementById('auto-refresh-toggle').checked = autoRefresh;
document.getElementById('options-panel').open = panelOpen;
syncColumnControls();
updateControlsSummary();
}
function readColumnControls() {
for (const key of Object.keys(defaultVisibleColumns)) {
const input = document.getElementById('column-' + key);
if (input) {
visibleColumns[key] = input.checked;
}
}
}
function readControls() {
sourceFilter = document.getElementById('source-filter').value.trim();
methodFilter = document.getElementById('method-filter').value.trim();
statusFilter = document.getElementById('status-filter').value.trim();
stateFilter = document.getElementById('state-filter').value;
botFilter = document.getElementById('bot-filter').value;
pageSize = document.getElementById('page-size').value;
autoRefresh = document.getElementById('auto-refresh-toggle').checked;
readColumnControls();
}
function saveControls() {
saveStringPreference('cob.requests.source', sourceFilter);
saveStringPreference('cob.requests.method', methodFilter);
saveStringPreference('cob.requests.status', statusFilter);
saveStringPreference('cob.requests.state', stateFilter);
saveStringPreference('cob.requests.botFilter', botFilter);
saveStringPreference('cob.requests.pageSizeTabulator', pageSize);
saveBooleanPreference('cob.requests.autoRefresh', autoRefresh);
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
saveColumnPreferences();
}
function updateControlsSummary() {
const parts = [];
if (sourceFilter) { parts.push('source=' + sourceFilter); }
if (methodFilter) { parts.push('method=' + methodFilter.toUpperCase()); }
if (statusFilter) { parts.push('status=' + statusFilter); }
if (stateFilter) { parts.push('state=' + stateFilter); }
if (botFilter && botFilter !== 'all') { parts.push('bots=' + botFilter); }
const hidden = Object.keys(defaultVisibleColumns).filter(key => visibleColumns[key] === false);
if (hidden.length > 0) { parts.push('hidden=' + hidden.join(',')); }
const sort = currentSort();
if (sort.field) { parts.push('sort=' + sort.field + ' ' + sort.dir); }
parts.push('page size=' + pageSize);
if (autoRefresh) { parts.push('auto refresh'); }
document.getElementById('controls-summary').textContent = parts.length ? parts.join(' · ') : 'No active filters.';
}
function relayoutTable() {
if (!table) {
return;
}
window.requestAnimationFrame(function() {
const header = document.querySelector('#requests-table .tabulator-header .tabulator-headers');
if (header) {
header.style.width = '100%';
header.style.minWidth = '100%';
}
const body = document.querySelector('#requests-table .tabulator-tableholder .tabulator-table');
if (body) {
body.style.width = '100%';
body.style.minWidth = '100%';
}
table.redraw(true);
});
}
function applyColumnPreferences() {
if (!table) {
return;
}
for (const key of Object.keys(defaultVisibleColumns)) {
const column = table.getColumn(key);
if (!column) {
continue;
}
if (visibleColumns[key] === false) {
column.hide();
} else {
column.show();
}
}
relayoutTable();
}
function buildAjaxURL(url, config, params) {
const query = new URLSearchParams();
query.set('hours', String(recentHours));
query.set('page', String(params.page || 1));
query.set('limit', String(pageSize));
if (sourceFilter) { query.set('source', sourceFilter); }
if (methodFilter) { query.set('method', methodFilter); }
if (statusFilter) { query.set('status', statusFilter); }
if (stateFilter) { query.set('state', stateFilter); }
if (botFilter && botFilter !== 'all') { query.set('bot_filter', botFilter); }
const sorters = Array.isArray(params.sorters) ? params.sorters : [];
if (sorters.length > 0) {
query.set('sort_by', String(sorters[0].field || 'time'));
query.set('sort_dir', String(sorters[0].dir || 'desc'));
} else {
const sort = currentSort();
query.set('sort_by', sort.field || 'time');
query.set('sort_dir', sort.dir || 'desc');
}
return url + '?' + query.toString();
}
async function ajaxRequest(url, config, params) {
const response = await fetch(buildAjaxURL(url, config, params), {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = payload.error || response.statusText || 'Request failed';
document.getElementById('table-status').textContent = message;
throw new Error(message);
}
document.getElementById('table-status').textContent = '';
return payload;
}
function scheduleRefresh() {
if (refreshTimer) {
window.clearTimeout(refreshTimer);
refreshTimer = null;
}
if (autoRefresh) {
refreshTimer = window.setTimeout(refreshNow, 5000);
}
}
function toggleAutoRefresh() {
autoRefresh = document.getElementById('auto-refresh-toggle').checked;
saveControls();
updateControlsSummary();
scheduleRefresh();
}
function refreshNow() {
if (!table) {
return;
}
const currentPage = Number(table.getPage() || 1);
table.setPage(currentPage);
}
function applyFilters(event) {
if (event) {
event.preventDefault();
}
readControls();
saveControls();
updateControlsSummary();
if (!table) {
return;
}
table.setPageSize(Number(pageSize));
applyColumnPreferences();
table.setPage(1);
}
function applyColumnChanges() {
readColumnControls();
saveControls();
applyColumnPreferences();
updateControlsSummary();
}
function resetControls() {
sourceFilter = '';
methodFilter = '';
statusFilter = '';
stateFilter = '';
botFilter = 'all';
pageSize = '25';
autoRefresh = false;
visibleColumns = { ...defaultVisibleColumns };
saveStringPreference('cob.requests.sortField', 'time');
saveStringPreference('cob.requests.sortDirTabulator', 'desc');
saveControls();
applyControls();
if (!table) {
return;
}
table.clearSort();
table.setSort([{ column: 'time', dir: 'desc' }]);
table.setPageSize(25);
applyColumnPreferences();
table.setPage(1);
scheduleRefresh();
}
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' }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
window.alert(payload.error || response.statusText || 'Request failed');
return;
}
refreshNow();
}
function timeFormatter(cell) {
return '<span class="mono">' + escapeHtml(formatDate(cell.getRow().getData().occurred_at)) + '</span>';
}
function ipFormatter(cell) {
const data = cell.getRow().getData();
const ip = data.client_ip || '—';
return '<div class="ip-cell">' + renderBotChip(data.bot) + '<a class="ip-link mono" href="/ips/' + encodeURIComponent(ip) + '" title="' + escapeHtml(ip) + '">' + escapeHtml(ip) + '</a></div>';
}
function methodFormatter(cell) {
const value = cell.getRow().getData().method || 'OTHER';
return '<span class="method-pill ' + escapeHtml(methodClass(value)) + '">' + escapeHtml(value) + '</span>';
}
function requestFormatter(cell) {
const value = cell.getRow().getData().uri || '—';
return '<span class="request-text mono" title="' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
}
function statusFormatter(cell) {
const value = Number(cell.getRow().getData().status || 0);
return '<span class="status-code ' + escapeHtml(statusCodeClass(value)) + '">' + escapeHtml(String(value || 0)) + '</span>';
}
function stateFormatter(cell) {
const value = String(cell.getRow().getData().current_state || 'observed');
return '<span class="state-pill ' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
}
function reasonFormatter(cell) {
const value = cell.getRow().getData().decision_reason || '—';
return '<span class="reason-text" title="' + escapeHtml(value) + '">' + escapeHtml(value) + '</span>';
}
function actionFormatter(cell) {
return renderActions(cell.getRow().getData());
}
function createTable() {
table = new Tabulator('#requests-table', {
ajaxURL: '/api/events',
ajaxRequestFunc: ajaxRequest,
dataReceiveParams: {
data: 'items',
last_page: 'last_page',
},
layout: 'fitColumns',
responsiveLayout: 'hide',
responsiveLayoutCollapseUseFormatters: false,
renderVertical: 'virtual',
placeholder: 'No requests match the current filters in the last 24 hours.',
pagination: true,
paginationMode: 'remote',
paginationInitialPage: 1,
paginationSize: Number(pageSize),
paginationSizeSelector: false,
paginationCounter: 'rows',
sortMode: 'remote',
headerSortTristate: false,
initialSort: [{
column: loadStringPreference('cob.requests.sortField', 'time'),
dir: loadStringPreference('cob.requests.sortDirTabulator', 'desc'),
}],
columns: [
{ title: 'Time', field: 'time', headerSort: true, formatter: timeFormatter, width: 184, minWidth: 164, responsive: 0 },
{ title: 'IP', field: 'ip', headerSort: true, formatter: ipFormatter, width: 320, minWidth: 280, responsive: 0 },
{ title: 'Method', field: 'method', headerSort: true, formatter: methodFormatter, width: 96, minWidth: 88, responsive: 4 },
{ title: 'Source', field: 'source', headerSort: true, formatter: function(cell) { return escapeHtml(cell.getRow().getData().source_name || '—'); }, width: 120, minWidth: 104, responsive: 5 },
{ title: 'Request', field: 'request', headerSort: true, formatter: requestFormatter, minWidth: 220, widthGrow: 4, responsive: 0 },
{ title: 'Status', field: 'status', headerSort: true, formatter: statusFormatter, hozAlign: 'center', width: 76, minWidth: 72, responsive: 2 },
{ title: 'State', field: 'state', headerSort: true, formatter: stateFormatter, width: 112, minWidth: 104, responsive: 6 },
{ title: 'Reason', field: 'reason', headerSort: true, formatter: reasonFormatter, minWidth: 180, widthGrow: 2, responsive: 7 },
{ title: '', field: 'actions', headerSort: false, formatter: actionFormatter, hozAlign: 'center', width: 62, minWidth: 58, responsive: 1 },
],
});
table.on('tableBuilt', function() {
applyColumnPreferences();
updateControlsSummary();
scheduleRefresh();
});
table.on('sortChanged', function(sorters) {
if (Array.isArray(sorters) && sorters.length > 0) {
saveStringPreference('cob.requests.sortField', String(sorters[0].field || 'time'));
saveStringPreference('cob.requests.sortDirTabulator', String(sorters[0].dir || 'desc'));
} else {
saveStringPreference('cob.requests.sortField', 'time');
saveStringPreference('cob.requests.sortDirTabulator', 'desc');
}
updateControlsSummary();
scheduleRefresh();
});
table.on('pageLoaded', function() {
scheduleRefresh();
});
table.on('columnVisibilityChanged', function() {
relayoutTable();
});
table.on('dataLoadError', function(error) {
document.getElementById('table-status').textContent = String(error || 'Unable to load requests.');
scheduleRefresh();
});
}
document.getElementById('options-panel').addEventListener('toggle', function() {
panelOpen = document.getElementById('options-panel').open;
saveBooleanPreference('cob.requests.panelOpen', panelOpen);
});
applyControls();
createTable();
</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>`