You've already forked caddy-opnsense-blocker
2006 lines
87 KiB
Go
2006 lines
87 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 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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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>
|
|
<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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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("unblock", "Reason for manual unblock")">Unblock</button>');
|
|
} else if (actions.can_block) {
|
|
buttons.push('<button class="danger" onclick="sendAction("block", "Reason for manual block")">Block</button>');
|
|
}
|
|
if (actions.can_clear_override) {
|
|
buttons.push('<button class="secondary" onclick="sendAction("clear-override", "Reason for clearing the manual override")">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>`
|