diff --git a/README.md b/README.md
index d746c6b..36e8f7e 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,13 @@ The UI is backed by a small JSON API. The main endpoints are:
The legacy `POST /api/ips/{ip}/reset` route is still accepted as a backwards-compatible alias for `clear-override`.
+The web UI itself exposes two main pages:
+
+- `GET /` for the dashboard
+- `GET /requests` for the paginated requests log
+
+The legacy `GET /queries` route redirects permanently to `GET /requests`.
+
The full API reference, including payloads and response models, lives in [`docs/api.md`](docs/api.md).
## Configuration
diff --git a/docs/api.md b/docs/api.md
index 9deb5ee..948e7bd 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -25,7 +25,7 @@ Example response:
## `GET /api/overview`
-Returns summary counters plus recent IP and recent event samples.
+Returns summary counters, chart data, and dashboard leaderboards.
Query parameters:
@@ -37,15 +37,6 @@ Query parameters:
- optional
- default: `24`
- used for the top activity leaderboards returned in the same payload
-- `show_known_bots`
- - optional
- - default: `true`
- - when `false`, the leaderboards exclude IPs currently identified as known bots
-- `show_allowed`
- - optional
- - default: `true`
- - when `false`, the leaderboards exclude IPs whose current state is `allowed`
-
Main response fields:
- `total_events`
@@ -55,8 +46,13 @@ Main response fields:
- `allowed_ips`
- `observed_ips`
- `activity_since`
-- `top_ips_by_events`
-- `top_ips_by_traffic`
+- `activity_buckets`
+- `methods`
+- `bots`
+- `top_bot_ips_by_events`
+- `top_non_bot_ips_by_events`
+- `top_bot_ips_by_traffic`
+- `top_non_bot_ips_by_traffic`
- `top_sources`
- `top_urls`
- `recent_ips`
@@ -64,7 +60,7 @@ Main response fields:
## `GET /api/events`
-Returns recent raw events.
+Returns a paginated slice of recent raw events.
Query parameters:
@@ -72,6 +68,30 @@ Query parameters:
- optional
- default: `100`
- maximum: `1000`
+- `page`
+ - optional
+ - default: `1`
+ - one-based page number
+- `show_known_bots`
+ - optional
+ - default: `true`
+ - when `false`, verified bots are hidden from the log
+- `show_allowed`
+ - optional
+ - default: `true`
+ - when `false`, rows whose current IP state is `allowed` are hidden
+- `review_only`
+ - optional
+ - default: `false`
+ - when `true`, only requests whose current IP state is `review` are returned
+
+Main response fields:
+
+- `items`
+- `page`
+- `limit`
+- `has_prev`
+- `has_next`
Each event includes:
diff --git a/internal/model/types.go b/internal/model/types.go
index 102dc97..f9376ff 100644
--- a/internal/model/types.go
+++ b/internal/model/types.go
@@ -234,6 +234,7 @@ type EventListOptions struct {
ShowKnownBots bool
ShowAllowed bool
ReviewOnly bool
+ Offset int
}
type SourceOffset struct {
@@ -255,20 +256,32 @@ type IPDetails struct {
}
type Overview struct {
- TotalEvents int64 `json:"total_events"`
- TotalIPs int64 `json:"total_ips"`
- BlockedIPs int64 `json:"blocked_ips"`
- ReviewIPs int64 `json:"review_ips"`
- AllowedIPs int64 `json:"allowed_ips"`
- ObservedIPs int64 `json:"observed_ips"`
- ActivitySince time.Time `json:"activity_since,omitempty"`
- ActivityBuckets []ActivityBucket `json:"activity_buckets"`
- Methods []MethodBreakdownRow `json:"methods"`
- Bots []BotBreakdownRow `json:"bots"`
- TopIPsByEvents []TopIPRow `json:"top_ips_by_events"`
- TopIPsByTraffic []TopIPRow `json:"top_ips_by_traffic"`
- TopSources []TopSourceRow `json:"top_sources"`
- TopURLs []TopURLRow `json:"top_urls"`
- RecentIPs []IPState `json:"recent_ips"`
- RecentEvents []Event `json:"recent_events"`
+ TotalEvents int64 `json:"total_events"`
+ TotalIPs int64 `json:"total_ips"`
+ BlockedIPs int64 `json:"blocked_ips"`
+ ReviewIPs int64 `json:"review_ips"`
+ AllowedIPs int64 `json:"allowed_ips"`
+ ObservedIPs int64 `json:"observed_ips"`
+ ActivitySince time.Time `json:"activity_since,omitempty"`
+ ActivityBuckets []ActivityBucket `json:"activity_buckets"`
+ Methods []MethodBreakdownRow `json:"methods"`
+ Bots []BotBreakdownRow `json:"bots"`
+ TopIPsByEvents []TopIPRow `json:"top_ips_by_events"`
+ TopBotIPsByEvents []TopIPRow `json:"top_bot_ips_by_events"`
+ TopNonBotIPsByEvents []TopIPRow `json:"top_non_bot_ips_by_events"`
+ TopIPsByTraffic []TopIPRow `json:"top_ips_by_traffic"`
+ TopBotIPsByTraffic []TopIPRow `json:"top_bot_ips_by_traffic"`
+ TopNonBotIPsByTraffic []TopIPRow `json:"top_non_bot_ips_by_traffic"`
+ TopSources []TopSourceRow `json:"top_sources"`
+ TopURLs []TopURLRow `json:"top_urls"`
+ RecentIPs []IPState `json:"recent_ips"`
+ RecentEvents []Event `json:"recent_events"`
+}
+
+type EventPage struct {
+ Items []Event `json:"items"`
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+ HasPrev bool `json:"has_prev"`
+ HasNext bool `json:"has_next"`
}
diff --git a/internal/service/service.go b/internal/service/service.go
index 0281878..49460e2 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -725,28 +725,35 @@ func (s *Service) decorateOverviewTopIPs(ctx context.Context, overview *model.Ov
if overview == nil {
return nil
}
- ips := append(topIPRowIPs(overview.TopIPsByEvents), topIPRowIPs(overview.TopIPsByTraffic)...)
+ ips := append([]string{}, topIPRowIPs(overview.TopIPsByEvents)...)
+ ips = append(ips, topIPRowIPs(overview.TopBotIPsByEvents)...)
+ ips = append(ips, topIPRowIPs(overview.TopNonBotIPsByEvents)...)
+ ips = append(ips, topIPRowIPs(overview.TopIPsByTraffic)...)
+ ips = append(ips, topIPRowIPs(overview.TopBotIPsByTraffic)...)
+ ips = append(ips, topIPRowIPs(overview.TopNonBotIPsByTraffic)...)
investigations, err := s.store.GetInvestigationsForIPs(ctx, ips)
if err != nil {
return err
}
- for index := range overview.TopIPsByEvents {
- if investigation, ok := investigations[overview.TopIPsByEvents[index].IP]; ok {
- overview.TopIPsByEvents[index].Bot = investigation.Bot
- } else {
- s.enqueueInvestigation(overview.TopIPsByEvents[index].IP)
- }
- }
- for index := range overview.TopIPsByTraffic {
- if investigation, ok := investigations[overview.TopIPsByTraffic[index].IP]; ok {
- overview.TopIPsByTraffic[index].Bot = investigation.Bot
- } else {
- s.enqueueInvestigation(overview.TopIPsByTraffic[index].IP)
- }
- }
+ decorateTopIPRows(overview.TopIPsByEvents, investigations, s.enqueueInvestigation)
+ decorateTopIPRows(overview.TopBotIPsByEvents, investigations, s.enqueueInvestigation)
+ decorateTopIPRows(overview.TopNonBotIPsByEvents, investigations, s.enqueueInvestigation)
+ decorateTopIPRows(overview.TopIPsByTraffic, investigations, s.enqueueInvestigation)
+ decorateTopIPRows(overview.TopBotIPsByTraffic, investigations, s.enqueueInvestigation)
+ decorateTopIPRows(overview.TopNonBotIPsByTraffic, investigations, s.enqueueInvestigation)
return nil
}
+func decorateTopIPRows(items []model.TopIPRow, investigations map[string]model.IPInvestigation, enqueue func(string)) {
+ for index := range items {
+ if investigation, ok := investigations[items[index].IP]; ok {
+ items[index].Bot = investigation.Bot
+ } else {
+ enqueue(items[index].IP)
+ }
+ }
+}
+
func topIPRowIPs(items []model.TopIPRow) []string {
result := make([]string, 0, len(items))
for _, item := range items {
diff --git a/internal/store/store.go b/internal/store/store.go
index 3036ae6..cba789e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -236,6 +236,16 @@ func knownBotExistsClause(ipExpression string) string {
)`
}
+func anyBotExistsClause(ipExpression string) string {
+ return `EXISTS (
+ SELECT 1
+ FROM ip_investigations i
+ WHERE i.ip = ` + ipExpression + `
+ AND json_valid(i.payload_json)
+ AND json_type(i.payload_json, '$.bot') IS NOT NULL
+ )`
+}
+
func overviewFilterQueryParts(options model.OverviewOptions) (joins []string, clauses []string) {
if !options.ShowAllowed {
joins = append(joins, `LEFT JOIN ip_state s ON s.ip = e.client_ip`)
@@ -443,11 +453,27 @@ func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int, opt
if err != nil {
return model.Overview{}, err
}
- topIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events", options)
+ topIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events", options, "all")
if err != nil {
return model.Overview{}, err
}
- topIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic", options)
+ topBotIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events", options, "bots")
+ if err != nil {
+ return model.Overview{}, err
+ }
+ topNonBotIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events", options, "non-bots")
+ if err != nil {
+ return model.Overview{}, err
+ }
+ topIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic", options, "all")
+ if err != nil {
+ return model.Overview{}, err
+ }
+ topBotIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic", options, "bots")
+ if err != nil {
+ return model.Overview{}, err
+ }
+ topNonBotIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic", options, "non-bots")
if err != nil {
return model.Overview{}, err
}
@@ -477,17 +503,27 @@ func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int, opt
overview.Methods = methods
overview.Bots = bots
overview.TopIPsByEvents = topIPsByEvents
+ overview.TopBotIPsByEvents = topBotIPsByEvents
+ overview.TopNonBotIPsByEvents = topNonBotIPsByEvents
overview.TopIPsByTraffic = topIPsByTraffic
+ overview.TopBotIPsByTraffic = topBotIPsByTraffic
+ overview.TopNonBotIPsByTraffic = topNonBotIPsByTraffic
overview.TopSources = topSources
overview.TopURLs = topURLs
return overview, nil
}
-func (s *Store) listTopIPRows(ctx context.Context, since time.Time, limit int, orderBy string, options model.OverviewOptions) ([]model.TopIPRow, error) {
+func (s *Store) listTopIPRows(ctx context.Context, since time.Time, limit int, orderBy string, options model.OverviewOptions, botScope string) ([]model.TopIPRow, error) {
if limit <= 0 {
limit = 10
}
joins, clauses := overviewFilterQueryParts(options)
+ switch botScope {
+ case "bots":
+ clauses = append(clauses, anyBotExistsClause(`e.client_ip`))
+ case "non-bots":
+ clauses = append(clauses, `NOT `+anyBotExistsClause(`e.client_ip`))
+ }
query := fmt.Sprintf(`
SELECT e.client_ip,
COUNT(*) AS event_count,
@@ -797,6 +833,9 @@ func (s *Store) ListEvents(ctx context.Context, since time.Time, limit int, opti
if limit <= 0 {
limit = 100
}
+ if options.Offset < 0 {
+ options.Offset = 0
+ }
joins, clauses := eventFilterQueryParts(options)
query := `
SELECT e.id, e.source_name, e.profile_name, e.occurred_at, e.remote_ip, e.client_ip, e.host,
@@ -815,8 +854,8 @@ func (s *Store) ListEvents(ctx context.Context, since time.Time, limit int, opti
if len(clauses) > 0 {
query += ` WHERE ` + strings.Join(clauses, ` AND `)
}
- query += ` ORDER BY e.occurred_at DESC, e.id DESC LIMIT ?`
- args = append(args, limit)
+ query += ` ORDER BY e.occurred_at DESC, e.id DESC LIMIT ? OFFSET ?`
+ args = append(args, limit, options.Offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 69d7328..2a79aad 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -109,6 +109,12 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
if len(overview.TopIPsByEvents) != 1 || overview.TopIPsByEvents[0].IP != event.ClientIP {
t.Fatalf("unexpected top ips by events: %+v", overview.TopIPsByEvents)
}
+ if len(overview.TopBotIPsByEvents) != 0 {
+ t.Fatalf("expected no bot top ips by events before investigation, got %+v", overview.TopBotIPsByEvents)
+ }
+ if len(overview.TopNonBotIPsByEvents) != 1 || overview.TopNonBotIPsByEvents[0].IP != event.ClientIP {
+ t.Fatalf("unexpected top non-bot ips by events: %+v", overview.TopNonBotIPsByEvents)
+ }
if len(overview.TopSources) != 1 || overview.TopSources[0].SourceName != event.SourceName {
t.Fatalf("unexpected top sources: %+v", overview.TopSources)
}
@@ -252,6 +258,9 @@ func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) {
if overview.TopIPsByEvents[0].IP != "203.0.113.10" || overview.TopIPsByEvents[0].Events != 2 || overview.TopIPsByEvents[0].TrafficBytes != 3072 {
t.Fatalf("unexpected top IP by events row: %+v", overview.TopIPsByEvents[0])
}
+ if len(overview.TopNonBotIPsByEvents) < 2 || overview.TopNonBotIPsByEvents[0].IP != "203.0.113.10" || overview.TopNonBotIPsByEvents[0].Events != 2 {
+ t.Fatalf("unexpected top non-bot IP by events rows: %+v", overview.TopNonBotIPsByEvents)
+ }
if len(overview.TopIPsByTraffic) < 2 {
t.Fatalf("expected at least 2 top IP rows by traffic, got %+v", overview.TopIPsByTraffic)
}
@@ -272,6 +281,22 @@ func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) {
}); err != nil {
t.Fatalf("save top bot investigation: %v", err)
}
+ refreshedOverview, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
+ if err != nil {
+ t.Fatalf("get refreshed overview: %v", err)
+ }
+ if len(refreshedOverview.TopBotIPsByEvents) == 0 || refreshedOverview.TopBotIPsByEvents[0].IP != "203.0.113.10" {
+ t.Fatalf("unexpected top bot IPs by events after investigation: %+v", refreshedOverview.TopBotIPsByEvents)
+ }
+ if len(refreshedOverview.TopNonBotIPsByEvents) == 0 || refreshedOverview.TopNonBotIPsByEvents[0].IP != "203.0.113.20" {
+ t.Fatalf("unexpected top non-bot IPs by events after investigation: %+v", refreshedOverview.TopNonBotIPsByEvents)
+ }
+ if len(refreshedOverview.TopBotIPsByTraffic) == 0 || refreshedOverview.TopBotIPsByTraffic[0].IP != "203.0.113.10" {
+ t.Fatalf("unexpected top bot IPs by traffic after investigation: %+v", refreshedOverview.TopBotIPsByTraffic)
+ }
+ if len(refreshedOverview.TopNonBotIPsByTraffic) == 0 || refreshedOverview.TopNonBotIPsByTraffic[0].IP != "203.0.113.20" {
+ t.Fatalf("unexpected top non-bot IPs by traffic after investigation: %+v", refreshedOverview.TopNonBotIPsByTraffic)
+ }
if _, err := db.SetManualOverride(ctx, "203.0.113.20", model.ManualOverrideForceAllow, model.IPStateAllowed, "manual allow"); err != nil {
t.Fatalf("set manual override for filter test: %v", err)
}
@@ -283,9 +308,21 @@ func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) {
if len(filtered.TopIPsByEvents) != 0 {
t.Fatalf("expected filtered top IPs by events to be empty, got %+v", filtered.TopIPsByEvents)
}
+ if len(filtered.TopBotIPsByEvents) != 0 {
+ t.Fatalf("expected filtered top bot IPs by events to be empty, got %+v", filtered.TopBotIPsByEvents)
+ }
+ if len(filtered.TopNonBotIPsByEvents) != 0 {
+ t.Fatalf("expected filtered top non-bot IPs by events to be empty, got %+v", filtered.TopNonBotIPsByEvents)
+ }
if len(filtered.TopIPsByTraffic) != 0 {
t.Fatalf("expected filtered top IPs by traffic to be empty, got %+v", filtered.TopIPsByTraffic)
}
+ if len(filtered.TopBotIPsByTraffic) != 0 {
+ t.Fatalf("expected filtered top bot IPs by traffic to be empty, got %+v", filtered.TopBotIPsByTraffic)
+ }
+ if len(filtered.TopNonBotIPsByTraffic) != 0 {
+ t.Fatalf("expected filtered top non-bot IPs by traffic to be empty, got %+v", filtered.TopNonBotIPsByTraffic)
+ }
if len(filtered.TopSources) != 0 {
t.Fatalf("expected filtered top sources to be empty, got %+v", filtered.TopSources)
}
diff --git a/internal/web/handler.go b/internal/web/handler.go
index 88c5a25..0f412bf 100644
--- a/internal/web/handler.go
+++ b/internal/web/handler.go
@@ -55,6 +55,7 @@ func NewHandler(app App) http.Handler {
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)
@@ -79,7 +80,11 @@ func (h *handler) handleOverviewPage(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) handleQueryLogPage(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/queries" {
+ if r.URL.Path == "/queries" {
+ http.Redirect(w, r, "/requests", http.StatusMovedPermanently)
+ return
+ }
+ if r.URL.Path != "/requests" {
http.NotFound(w, r)
return
}
@@ -87,7 +92,7 @@ func (h *handler) handleQueryLogPage(w http.ResponseWriter, r *http.Request) {
methodNotAllowed(w)
return
}
- renderTemplate(w, h.queryLogPage, pageData{Title: "Query Log"})
+ renderTemplate(w, h.queryLogPage, pageData{Title: "Requests Log"})
}
func (h *handler) handleIPPage(w http.ResponseWriter, r *http.Request) {
@@ -122,10 +127,7 @@ func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
hours = 24
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
- options := model.OverviewOptions{
- ShowKnownBots: queryBool(r, "show_known_bots", true),
- ShowAllowed: queryBool(r, "show_allowed", true),
- }
+ 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)
@@ -144,18 +146,33 @@ func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) {
if hours <= 0 {
hours = 24
}
+ page := queryInt(r, "page", 1)
+ if page <= 0 {
+ page = 1
+ }
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
options := model.EventListOptions{
ShowKnownBots: queryBool(r, "show_known_bots", true),
ShowAllowed: queryBool(r, "show_allowed", true),
ReviewOnly: queryBool(r, "review_only", false),
+ Offset: (page - 1) * limit,
}
- events, err := h.app.ListEvents(r.Context(), since, limit, options)
+ events, err := h.app.ListEvents(r.Context(), since, limit+1, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
- writeJSON(w, http.StatusOK, events)
+ hasNext := len(events) > limit
+ if hasNext {
+ events = events[:limit]
+ }
+ writeJSON(w, http.StatusOK, model.EventPage{
+ Items: events,
+ Page: page,
+ Limit: limit,
+ HasPrev: page > 1,
+ HasNext: hasNext,
+ })
}
func (h *handler) handleAPIIPs(w http.ResponseWriter, r *http.Request) {
@@ -481,7 +498,7 @@ const overviewHTML = `
@@ -494,13 +511,6 @@ const overviewHTML = `
-
@@ -533,7 +543,7 @@ const overviewHTML = `
- Top IPs by events
+ Top bot IPs by events
Last 24 hours
Loading…—
@@ -542,7 +552,25 @@ const overviewHTML = `
- Top IPs by traffic
+ Top non-bot IPs by events
+ Last 24 hours
+
+ Loading…—
+ Loading…—
+ Loading…—
+
+
+
+ Top bot IPs by traffic
+ Last 24 hours
+
+ Loading…—
+ Loading…—
+ Loading…—
+
+
+
+ Top non-bot IPs by traffic
Last 24 hours
Loading…—
@@ -573,21 +601,6 @@ const overviewHTML = `
@@ -914,16 +901,23 @@ const queryLogHTML = `
a:hover { text-decoration: underline; }
.muted { color: #94a3b8; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
- .panel { background: #111827; border: 1px solid #334155; border-radius: .85rem; padding: 1rem; overflow: auto; }
+ .panel { background: #111827; border: 1px solid #334155; border-radius: .85rem; padding: 1rem; overflow: hidden; }
.controls { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.controls-group { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.toggle { display: inline-flex; align-items: center; gap: .45rem; font-size: .95rem; color: #cbd5e1; }
.toggle input { margin: 0; }
- .toolbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; }
- table { width: 100%; border-collapse: collapse; min-width: 1024px; }
+ .toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; }
+ .toolbar-actions { display: flex; align-items: center; gap: .65rem; flex-wrap: wrap; }
+ .page-status { color: #cbd5e1; font-size: .92rem; }
+ .table-shell { overflow: hidden; }
+ table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { padding: .6rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
- thead th { color: #93c5fd; white-space: nowrap; }
+ thead th { color: #93c5fd; }
tbody tr:nth-child(even) { background: rgba(15, 23, 42, .55); }
+ th.tight, td.tight { white-space: nowrap; width: 1%; }
+ th.request-col, td.request-cell { width: auto; }
+ td.request-cell { overflow: hidden; }
+ .request-text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
.status.blocked { background: #7f1d1d; }
.status.review { background: #78350f; }
@@ -940,6 +934,7 @@ const queryLogHTML = `
button { background: #2563eb; color: white; border: 0; cursor: pointer; }
button.secondary { background: #475569; }
button.danger { background: #dc2626; }
+ button[disabled] { opacity: .5; cursor: default; }
.ip-cell { display: flex; align-items: center; gap: .45rem; min-width: 0; }
.bot-chip { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border-radius: 999px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: .72rem; font-weight: 700; cursor: help; flex: 0 0 auto; }
.bot-chip.verified { border-color: #2563eb; }
@@ -956,12 +951,17 @@ const queryLogHTML = `
.bot-chip.yandex { background: #dc2626; color: white; }
.bot-chip.baidu { background: #7c3aed; color: white; }
.bot-chip.bytespider { background: #111827; color: white; }
+ @media (max-width: 960px) {
+ .toolbar, .controls { align-items: flex-start; }
+ .toolbar-actions, .controls-group { width: 100%; justify-content: flex-start; }
+ th, td { font-size: .88rem; }
+ }
@media (max-width: 720px) {
header { padding: .9rem 1rem; }
main { padding: 1rem; }
.panel { padding: .85rem; }
- .controls { align-items: flex-start; }
- .controls-group { width: 100%; justify-content: flex-start; }
+ .table-shell { overflow-x: auto; }
+ table { min-width: 900px; }
}
@@ -974,7 +974,7 @@ const queryLogHTML = `
@@ -984,8 +984,9 @@ const queryLogHTML = `
+
- These filters affect the full Query Log.
+ These filters affect the full Requests Log.
+
+
+
+
+
+
+ | Time |
+ Source |
+ IP |
+ Method |
+ Request |
+ Status |
+ State |
+ Reason |
+ Actions |
+
+
+
+ | Loading requests log… |
+
+
-
-
-
- | Time |
- Source |
- IP |
- Method |
- Request |
- Status |
- State |
- Reason |
- Actions |
-
-
-
- | Loading query log… |
-
-