Files
wweir a8ae275c88 feat(admin): track per-domain block counts
Feed the domain through the router observer into Stats.RecordRoute so
block decisions are attributed per domain. The bounded blocked map
(LRU-evicted at 10000 entries) feeds TrafficSnapshot.Blocked, ordered
by count, and rides the existing SSE traffic stream unchanged.
2026-08-07 15:51:08 +08:00

107 lines
2.9 KiB
Go

package admin
import (
"container/heap"
"time"
)
// evictionCandidate is a potential least-recently-seen entry. The heap keeps
// its newest element at the root, allowing a single scan to retain only the
// oldest candidates required for a batch eviction.
type evictionCandidate struct {
key string
lastSeen time.Time
}
type oldestCandidates []evictionCandidate
func (h oldestCandidates) Len() int { return len(h) }
func (h oldestCandidates) Less(i, j int) bool {
if h[i].lastSeen.Equal(h[j].lastSeen) {
return h[i].key > h[j].key
}
return h[i].lastSeen.After(h[j].lastSeen)
}
func (h oldestCandidates) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *oldestCandidates) Push(x any) { *h = append(*h, x.(evictionCandidate)) }
func (h *oldestCandidates) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
func older(a, b evictionCandidate) bool {
if a.lastSeen.Equal(b.lastSeen) {
return a.key < b.key
}
return a.lastSeen.Before(b.lastSeen)
}
func keepOldest(candidates *oldestCandidates, candidate evictionCandidate, limit int) {
if candidates.Len() < limit {
heap.Push(candidates, candidate)
return
}
if older(candidate, (*candidates)[0]) {
heap.Pop(candidates)
heap.Push(candidates, candidate)
}
}
// evictOldestDomainsLocked frees a batch of least-recently-seen domains so
// a high-cardinality stream amortizes the O(N) scan across many insertions.
func (s *Stats) evictOldestDomainsLocked(count int) {
count = min(count, len(s.domains))
if count == 0 {
return
}
candidates := make(oldestCandidates, 0, count)
for domain, d := range s.domains {
keepOldest(&candidates, evictionCandidate{key: domain, lastSeen: d.lastSeen}, count)
}
for _, candidate := range candidates {
delete(s.domains, candidate.key)
}
}
// evictOldestClientsLocked frees a batch of least-recently-seen client totals.
func (s *Stats) evictOldestClientsLocked(count int) {
count = min(count, len(s.clients))
if count == 0 {
return
}
candidates := make(oldestCandidates, 0, count)
for ip, c := range s.clients {
keepOldest(&candidates, evictionCandidate{key: ip, lastSeen: c.lastSeen}, count)
}
for _, candidate := range candidates {
delete(s.clients, candidate.key)
}
}
// blockedEvictionBatch is the number of least-recently-seen blocked domains
// freed at once when the map is full.
const blockedEvictionBatch = 64
// evictOldestBlockedLocked frees a batch of least-recently-seen blocked
// domains so a pathological wildcard-rule stream amortizes the O(N) scan.
func (s *Stats) evictOldestBlockedLocked(count int) {
count = min(count, len(s.blocked))
if count == 0 {
return
}
candidates := make(oldestCandidates, 0, count)
for domain, b := range s.blocked {
keepOldest(&candidates, evictionCandidate{key: domain, lastSeen: b.lastSeen}, count)
}
for _, candidate := range candidates {
delete(s.blocked, candidate.key)
}
}