Updates
ci / test (push) Successful in 58s
ci / release (push) Successful in 1m32s

This commit is contained in:
2026-07-10 15:34:51 -04:00
parent 328128a464
commit b3bc9cd252
6 changed files with 241 additions and 13 deletions
BIN
View File
Binary file not shown.
+33
View File
@@ -76,6 +76,39 @@ func TestStateRankedAndPulse(t *testing.T) {
}
}
func TestSetStatsMaxMergesAllTime(t *testing.T) {
s := NewState()
s.SetStats(map[string]int{"CN": 100, "US": 10}, map[string]int{"CN": 5}, nil)
s.BumpGeo("CN") // all=101, hour=6
// Sparser server snapshot must not wipe optimistic all-time bump.
s.SetStats(map[string]int{"CN": 100, "BR": 3}, map[string]int{"US": 2}, nil)
if s.CountFor("CN") != 101 {
s.SetRange(RangeAll)
if s.CountFor("CN") != 101 {
t.Fatalf("all-time CN want 101 got %d", s.CountFor("CN"))
}
}
s.SetRange(RangeAll)
if s.CountFor("BR") != 3 {
t.Fatalf("BR from server: %d", s.CountFor("BR"))
}
// Hour replaced by server window
s.SetRange(RangeHour)
if s.CountFor("US") != 2 || s.CountFor("CN") != 0 {
t.Fatalf("hour replace: US=%d CN=%d", s.CountFor("US"), s.CountFor("CN"))
}
}
func TestMergeFeedCountsEnrichesAll(t *testing.T) {
s := NewState()
s.SetStats(map[string]int{"CN": 5}, map[string]int{"CN": 1}, nil)
s.MergeFeedCounts(map[string]int{"CN": 12, "US": 4}, map[string]int{"US": 2})
s.SetRange(RangeAll)
if s.CountFor("CN") != 12 || s.CountFor("US") != 4 {
t.Fatalf("all after feed merge CN=%d US=%d", s.CountFor("CN"), s.CountFor("US"))
}
}
func TestRenderProducesOutput(t *testing.T) {
s := NewState()
s.SetStats(map[string]int{"CN": 100, "US": 40}, nil, map[string]int{"US": 1})
+51 -5
View File
@@ -164,17 +164,45 @@ func (s *State) Hover() string {
return s.hover
}
// SetStats replaces aggregate geo maps from viz.Stats.
// SetStats merges aggregate geo maps from viz.Stats into local state.
//
// All-time counts are max-merged so live SSE bumps and feed-derived tallies
// are never wiped by a sparser server snapshot. Last-hour counts are replaced
// when the server provides a map (sliding window), otherwise left intact.
// Peers-by-geo is replaced when non-nil.
func (s *State) SetStats(all, hour, peers map[string]int) {
s.mu.Lock()
defer s.mu.Unlock()
s.allCounts = cloneCounts(all)
s.hourCounts = cloneCounts(hour)
s.peersByGeo = cloneCounts(peers)
s.allCounts = maxMergeCounts(s.allCounts, cloneCounts(all))
if hour != nil {
// Sliding window: trust server map when present (including empty).
s.hourCounts = cloneCounts(hour)
}
if peers != nil {
s.peersByGeo = cloneCounts(peers)
}
}
// MergeFeedCounts folds in-memory feed tallies into map counters (max-merge).
// Used so the ALL range reflects attacks seen in the live buffer, not only
// the server geoBreakdown window.
func (s *State) MergeFeedCounts(all, hour map[string]int) {
s.mu.Lock()
defer s.mu.Unlock()
s.allCounts = maxMergeCounts(s.allCounts, cloneCounts(all))
// Hour: max-merge so live buffer + optimistic bumps survive until the next
// authoritative hour snapshot replaces hourCounts via SetStats.
s.hourCounts = maxMergeCounts(s.hourCounts, cloneCounts(hour))
}
// BumpGeo increments live counters when an attack arrives (optimistic).
// Live SSE events are treated as "now" for the hour window.
func (s *State) BumpGeo(code string) {
s.BumpGeoAt(code, true)
}
// BumpGeoAt increments all-time always; hour only when inHour is true.
func (s *State) BumpGeoAt(code string, inHour bool) {
code = NormalizeCode(code)
if code == "" || code == UnknownGeo {
return
@@ -182,7 +210,25 @@ func (s *State) BumpGeo(code string) {
s.mu.Lock()
defer s.mu.Unlock()
s.allCounts[code]++
s.hourCounts[code]++
if inHour {
s.hourCounts[code]++
}
}
func maxMergeCounts(a, b map[string]int) map[string]int {
if a == nil && b == nil {
return map[string]int{}
}
out := make(map[string]int, len(a)+len(b))
for k, v := range a {
out[k] = v
}
for k, v := range b {
if cur, ok := out[k]; !ok || v > cur {
out[k] = v
}
}
return out
}
// TriggerPulse adds a visual attack flash.
+13
View File
@@ -39,8 +39,21 @@ func (m *Model) syncMapFromStats() {
peers[g]++
}
}
if len(peers) == 0 {
peers = nil // don't wipe existing peer geo via SetStats
}
}
// Max-merge server KPI maps (all-time never shrinks; hour replaced when non-nil).
m.geoMap.SetStats(all, hour, peers)
// Fold every geo-tagged event in the live ring buffer so ALL reflects
// attacks we've actually processed, not only the server's geoBreakdown
// window (which can be much smaller than totalAttacks).
if m.store != nil {
nowMS := time.Now().UnixMilli()
feedAll, feedHour := viz.GeoCountsFromFeed(m.store.Items(), nowMS)
m.geoMap.MergeFeedCounts(feedAll, feedHour)
}
}
func (m *Model) mapTickCmd() tea.Cmd {
+89 -4
View File
@@ -132,16 +132,32 @@ func MergeAuthoritativeStats(prev *Stats, next Stats, opts MergeStatsOpts) *Stat
out.AttacksLastHour = pick(prev.AttacksLastHour, next.AttacksLastHour)
if next.ServiceBreakdown != nil {
out.ServiceBreakdown = cloneIntMap(next.ServiceBreakdown)
if opts.Absolute {
out.ServiceBreakdown = cloneIntMap(next.ServiceBreakdown)
} else {
out.ServiceBreakdown = MaxMergeIntMap(prev.ServiceBreakdown, next.ServiceBreakdown)
}
}
// All-time geo is monotonic: never drop optimistic live bumps or richer
// local tallies when the server sends a sparser snapshot.
if next.GeoBreakdown != nil {
out.GeoBreakdown = cloneIntMap(next.GeoBreakdown)
out.GeoBreakdown = MaxMergeIntMap(prev.GeoBreakdown, next.GeoBreakdown)
}
// Last-hour geo is a sliding window — take absolute server values when
// Absolute, otherwise max-merge so live SSE bumps survive between ticks.
if next.GeoBreakdownLastHour != nil {
out.GeoBreakdownLastHour = cloneIntMap(next.GeoBreakdownLastHour)
if opts.Absolute {
out.GeoBreakdownLastHour = cloneIntMap(next.GeoBreakdownLastHour)
} else {
out.GeoBreakdownLastHour = MaxMergeIntMap(prev.GeoBreakdownLastHour, next.GeoBreakdownLastHour)
}
}
if next.PeersByGeo != nil {
out.PeersByGeo = cloneIntMap(next.PeersByGeo)
if opts.Absolute {
out.PeersByGeo = cloneIntMap(next.PeersByGeo)
} else {
out.PeersByGeo = MaxMergeIntMap(prev.PeersByGeo, next.PeersByGeo)
}
}
if next.RecentBlocksLastHour != 0 || opts.Absolute {
out.RecentBlocksLastHour = next.RecentBlocksLastHour
@@ -149,6 +165,75 @@ func MergeAuthoritativeStats(prev *Stats, next Stats, opts MergeStatsOpts) *Stat
return &out
}
// MaxMergeIntMap returns a map where each key is max(a[k], b[k]).
// Nil maps are treated as empty. Keys are preserved from both sides.
func MaxMergeIntMap(a, b map[string]int) map[string]int {
if a == nil && b == nil {
return map[string]int{}
}
out := cloneIntMap(a)
for k, v := range b {
if cur, ok := out[k]; !ok || v > cur {
out[k] = v
}
}
return out
}
// GeoCountsFromFeed tallies geos from in-memory feed items.
// all gets every geo-tagged attack/block/moderation; hour only events with
// Timestamp within the last hour of nowMS.
func GeoCountsFromFeed(items []FeedItem, nowMS int64) (all, hour map[string]int) {
all = map[string]int{}
hour = map[string]int{}
oneHourAgo := nowMS - hourMS
for _, it := range items {
switch it.Type {
case FeedAttack, FeedBlock, FeedModeration:
default:
continue
}
geo := normalizeGeoKey(it.Geo)
if geo == "" {
continue
}
all[geo]++
ts := it.Timestamp
if ts <= 0 {
ts = nowMS
}
if ts >= oneHourAgo {
hour[geo]++
}
}
return all, hour
}
func normalizeGeoKey(geo string) string {
if geo == "" {
return ""
}
// Uppercase ISO-ish codes without importing geomap (avoid cycle).
b := make([]byte, 0, len(geo))
for i := 0; i < len(geo); i++ {
c := geo[i]
if c >= 'a' && c <= 'z' {
c -= 'a' - 'A'
}
if (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
b = append(b, c)
}
}
s := string(b)
if s == "UK" {
return "GB"
}
if s == "UNKNOWN" || s == "XX" || s == "ZZ" {
return ""
}
return s
}
// BumpPeerOnAttack updates per-peer hour/service counters on the live roster.
func BumpPeerOnAttack(net *Network, a AttackEvent, nowMS int64) *Network {
if net == nil || a.PeerID == "" {
+55 -4
View File
@@ -67,21 +67,72 @@ func TestBumpConnectedPeers(t *testing.T) {
}
func TestMergeAuthoritativeStats(t *testing.T) {
prev := &viz.Stats{TotalAttacks: 100, ConnectedPeers: 5, BlockedIPs: 10, AttacksLastHour: 20}
next := viz.Stats{TotalAttacks: 90, ConnectedPeers: 4, BlockedIPs: 12, AttacksLastHour: 15, ServiceBreakdown: map[string]int{"SSH": 1}}
prev := &viz.Stats{
TotalAttacks: 100, ConnectedPeers: 5, BlockedIPs: 10, AttacksLastHour: 20,
GeoBreakdown: map[string]int{"CN": 50, "US": 10},
GeoBreakdownLastHour: map[string]int{"CN": 5},
ServiceBreakdown: map[string]int{"SSH": 9},
}
next := viz.Stats{
TotalAttacks: 90, ConnectedPeers: 4, BlockedIPs: 12, AttacksLastHour: 15,
ServiceBreakdown: map[string]int{"SSH": 1, "HTTP": 2},
GeoBreakdown: map[string]int{"CN": 40, "BR": 3}, // sparser CN
GeoBreakdownLastHour: map[string]int{"US": 2},
}
merged := viz.MergeAuthoritativeStats(prev, next, viz.MergeStatsOpts{})
if merged.TotalAttacks != 100 || merged.ConnectedPeers != 5 || merged.BlockedIPs != 12 || merged.AttacksLastHour != 20 {
t.Fatalf("non-absolute merge: %+v", merged)
}
if merged.ServiceBreakdown["SSH"] != 1 {
t.Fatalf("breakdown replaced: %+v", merged.ServiceBreakdown)
// Service: max-merge non-absolute
if merged.ServiceBreakdown["SSH"] != 9 || merged.ServiceBreakdown["HTTP"] != 2 {
t.Fatalf("service max-merge: %+v", merged.ServiceBreakdown)
}
// All-time geo never drops local CN=50
if merged.GeoBreakdown["CN"] != 50 || merged.GeoBreakdown["BR"] != 3 || merged.GeoBreakdown["US"] != 10 {
t.Fatalf("geo all max-merge: %+v", merged.GeoBreakdown)
}
if merged.GeoBreakdownLastHour["CN"] != 5 || merged.GeoBreakdownLastHour["US"] != 2 {
t.Fatalf("geo hour max-merge: %+v", merged.GeoBreakdownLastHour)
}
abs := viz.MergeAuthoritativeStats(prev, next, viz.MergeStatsOpts{Absolute: true})
if abs.TotalAttacks != 90 || abs.ConnectedPeers != 4 || abs.AttacksLastHour != 15 {
t.Fatalf("absolute merge: %+v", abs)
}
// Absolute still max-merges all-time geo (monotonic)
if abs.GeoBreakdown["CN"] != 50 || abs.GeoBreakdown["BR"] != 3 {
t.Fatalf("absolute geo all should max-merge: %+v", abs.GeoBreakdown)
}
// Absolute replaces hour window
if abs.GeoBreakdownLastHour["US"] != 2 || abs.GeoBreakdownLastHour["CN"] != 0 {
t.Fatalf("absolute geo hour replace: %+v", abs.GeoBreakdownLastHour)
}
}
func TestGeoCountsFromFeed(t *testing.T) {
now := int64(1_000_000_000)
items := []viz.FeedItem{
{Type: viz.FeedAttack, Geo: "cn", Timestamp: now},
{Type: viz.FeedAttack, Geo: "CN", Timestamp: now},
{Type: viz.FeedAttack, Geo: "US", Timestamp: now - 2*60*60*1000},
{Type: viz.FeedPeer, Geo: "DE", Timestamp: now}, // ignored
{Type: viz.FeedModeration, Geo: "br", Timestamp: now - 30*60*1000},
}
all, hour := viz.GeoCountsFromFeed(items, now)
if all["CN"] != 2 || all["US"] != 1 || all["BR"] != 1 {
t.Fatalf("all: %+v", all)
}
if hour["CN"] != 2 || hour["BR"] != 1 || hour["US"] != 0 {
t.Fatalf("hour: %+v", hour)
}
}
func TestMaxMergeIntMap(t *testing.T) {
got := viz.MaxMergeIntMap(map[string]int{"A": 5, "B": 1}, map[string]int{"A": 3, "C": 9})
if got["A"] != 5 || got["B"] != 1 || got["C"] != 9 {
t.Fatalf("%+v", got)
}
}
func TestBumpPeerOnAttack(t *testing.T) {