feat(monitor/backend): ws hub, snapshot store, heartbeat collector with TDD

This commit is contained in:
CarterPerez-dev 2026-05-01 07:09:28 -04:00
parent bc9c2d06eb
commit a7865533cd
15 changed files with 861 additions and 0 deletions

View File

@ -3,6 +3,7 @@ module github.com/carterperez-dev/monitor-the-situation/backend
go 1.25.0
require (
github.com/coder/websocket v1.8.14
github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.23.0
github.com/go-redis/redis_rate/v10 v10.0.1
@ -17,6 +18,7 @@ require (
github.com/redis/go-redis/v9 v9.7.3
github.com/sony/gobreaker/v2 v2.4.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
go.opentelemetry.io/otel v1.41.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
go.opentelemetry.io/otel/sdk v1.35.0
@ -27,15 +29,29 @@ require (
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
@ -44,6 +60,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
@ -53,12 +70,34 @@ require (
github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/testcontainers/testcontainers-go v0.42.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect

View File

@ -1,5 +1,13 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@ -8,6 +16,20 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -15,6 +37,16 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
@ -26,6 +58,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -44,6 +78,7 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -60,6 +95,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
@ -94,33 +131,81 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
@ -131,6 +216,8 @@ go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCn
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
@ -145,12 +232,18 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
@ -165,3 +258,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@ -0,0 +1,63 @@
// ©AngelaMos | 2026
// collector.go
package heartbeat
import (
"context"
"time"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
)
const defaultInterval = 5 * time.Second
type Emitter interface {
Emit(ev events.Event)
}
type Config struct {
Interval time.Duration
Emitter Emitter
}
type Collector struct {
cfg Config
}
func New(cfg Config) *Collector {
if cfg.Interval <= 0 {
cfg.Interval = defaultInterval
}
return &Collector{cfg: cfg}
}
func (c *Collector) Name() string { return "heartbeat" }
func (c *Collector) Run(ctx context.Context) error {
ticker := time.NewTicker(c.cfg.Interval)
defer ticker.Stop()
c.emitNow()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
c.emitNow()
}
}
}
func (c *Collector) emitNow() {
now := time.Now().UTC()
c.cfg.Emitter.Emit(events.Event{
Topic: events.TopicHeartbeat,
Timestamp: now,
Source: c.Name(),
Payload: map[string]string{
"ts": now.Format(time.RFC3339Nano),
},
})
}

View File

@ -0,0 +1,72 @@
// ©AngelaMos | 2026
// collector_test.go
package heartbeat_test
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
)
type collectingEmitter struct {
mu sync.Mutex
events []events.Event
}
func (c *collectingEmitter) Emit(ev events.Event) {
c.mu.Lock()
defer c.mu.Unlock()
c.events = append(c.events, ev)
}
func (c *collectingEmitter) Count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.events)
}
func (c *collectingEmitter) Last() events.Event {
c.mu.Lock()
defer c.mu.Unlock()
return c.events[len(c.events)-1]
}
func TestCollector_EmitsAtInterval(t *testing.T) {
emitter := &collectingEmitter{}
c := heartbeat.New(heartbeat.Config{
Interval: 50 * time.Millisecond,
Emitter: emitter,
})
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
err := c.Run(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.GreaterOrEqual(t, emitter.Count(), 3)
last := emitter.Last()
require.Equal(t, events.TopicHeartbeat, last.Topic)
require.Equal(t, "heartbeat", last.Source)
}
func TestCollector_EmitsImmediatelyOnStart(t *testing.T) {
emitter := &collectingEmitter{}
c := heartbeat.New(heartbeat.Config{
Interval: time.Hour,
Emitter: emitter,
})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_ = c.Run(ctx)
require.Equal(t, 1, emitter.Count(), "should emit one tick immediately on start")
}

View File

@ -0,0 +1,29 @@
// ©AngelaMos | 2026
// handler.go
package snapshot
import (
"encoding/json"
"net/http"
)
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
all, err := h.store.GetAll(r.Context())
if err != nil {
http.Error(w, "snapshot read failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(all); err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
return
}
}

View File

@ -0,0 +1,24 @@
// ©AngelaMos | 2026
// persister.go
package snapshot
import (
"context"
"encoding/json"
"fmt"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
)
type StorePersister struct {
Store *Store
}
func (s StorePersister) Save(ctx context.Context, ev events.Event) error {
raw, err := json.Marshal(ev.Payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
return s.Store.PutLatest(ctx, ev.Topic, raw)
}

View File

@ -0,0 +1,75 @@
// ©AngelaMos | 2026
// snapshot_test.go
package snapshot_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot"
)
func startRedis(t *testing.T) *redis.Client {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c, err := tcredis.Run(ctx, "redis:7-alpine")
require.NoError(t, err)
t.Cleanup(func() {
_ = c.Terminate(context.Background())
})
endpoint, err := c.Endpoint(ctx, "")
require.NoError(t, err)
rdb := redis.NewClient(&redis.Options{Addr: endpoint})
t.Cleanup(func() { _ = rdb.Close() })
return rdb
}
func TestStore_PutLatestAndGetAll(t *testing.T) {
rdb := startRedis(t)
store := snapshot.NewStore(rdb)
ctx := context.Background()
require.NoError(t, store.PutLatest(ctx, events.TopicHeartbeat, json.RawMessage(`{"ts":"2026-05-01T00:00:00Z"}`)))
require.NoError(t, store.PutLatest(ctx, events.TopicCVENew, json.RawMessage(`{"id":"CVE-2026-9999"}`)))
all, err := store.GetAll(ctx)
require.NoError(t, err)
require.Contains(t, all, string(events.TopicHeartbeat))
require.Contains(t, all, string(events.TopicCVENew))
var hb map[string]string
require.NoError(t, json.Unmarshal(all[string(events.TopicHeartbeat)], &hb))
require.Equal(t, "2026-05-01T00:00:00Z", hb["ts"])
}
func TestStorePersister_SavesEventPayload(t *testing.T) {
rdb := startRedis(t)
store := snapshot.NewStore(rdb)
persister := snapshot.StorePersister{Store: store}
ctx := context.Background()
ev := events.Event{
Topic: events.TopicHeartbeat,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Source: "heartbeat",
Payload: map[string]string{"ts": "2026-05-01T00:00:00Z"},
}
require.NoError(t, persister.Save(ctx, ev))
all, err := store.GetAll(ctx)
require.NoError(t, err)
require.Contains(t, all, string(events.TopicHeartbeat))
}

View File

@ -0,0 +1,46 @@
// ©AngelaMos | 2026
// store.go
package snapshot
import (
"context"
"encoding/json"
"fmt"
"github.com/redis/go-redis/v9"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
)
const keyPrefix = "state:"
type Store struct {
rdb *redis.Client
}
func NewStore(rdb *redis.Client) *Store { return &Store{rdb: rdb} }
func (s *Store) PutLatest(ctx context.Context, topic events.Topic, payload json.RawMessage) error {
if err := s.rdb.Set(ctx, keyPrefix+string(topic), []byte(payload), 0).Err(); err != nil {
return fmt.Errorf("redis set %s: %w", topic, err)
}
return nil
}
func (s *Store) GetAll(ctx context.Context) (map[string]json.RawMessage, error) {
keys, err := s.rdb.Keys(ctx, keyPrefix+"*").Result()
if err != nil {
return nil, err
}
out := make(map[string]json.RawMessage, len(keys))
for _, k := range keys {
v, err := s.rdb.Get(ctx, k).Bytes()
if err != nil {
continue
}
topic := k[len(keyPrefix):]
out[topic] = json.RawMessage(v)
}
return out, nil
}

View File

@ -0,0 +1,14 @@
// ©AngelaMos | 2026
// broadcaster.go
package ws
import "github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
type HubBroadcaster struct {
Hub *Hub
}
func (b HubBroadcaster) Broadcast(topic string, payload []byte) {
b.Hub.Broadcast(events.Topic(topic), payload)
}

View File

@ -0,0 +1,34 @@
// ©AngelaMos | 2026
// envelope.go
package ws
import (
"bytes"
"encoding/json"
"time"
)
func EncodeEnvelope(channel string, payload []byte) ([]byte, error) {
var buf bytes.Buffer
buf.WriteString(`{"ch":`)
chRaw, err := json.Marshal(channel)
if err != nil {
return nil, err
}
buf.Write(chRaw)
buf.WriteString(`,"data":`)
if len(payload) == 0 {
buf.WriteString("null")
} else {
buf.Write(payload)
}
buf.WriteString(`,"ts":`)
tsRaw, err := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return nil, err
}
buf.Write(tsRaw)
buf.WriteByte('}')
return buf.Bytes(), nil
}

View File

@ -0,0 +1,33 @@
// ©AngelaMos | 2026
// envelope_test.go
package ws_test
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/ws"
)
func TestEncodeEnvelope(t *testing.T) {
out, err := ws.EncodeEnvelope("heartbeat", []byte(`{"ts":"2026-05-01T00:00:00Z"}`))
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(out, &got))
require.Equal(t, "heartbeat", got["ch"])
require.Contains(t, got, "data")
require.Contains(t, got, "ts")
}
func TestEncodeEnvelope_NilPayload(t *testing.T) {
out, err := ws.EncodeEnvelope("heartbeat", nil)
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(out, &got))
require.Nil(t, got["data"])
}

View File

@ -0,0 +1,49 @@
// ©AngelaMos | 2026
// handler.go
package ws
import (
"net/http"
"strings"
cdrws "github.com/coder/websocket"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
)
type Handler struct {
hub *Hub
}
func NewHandler(hub *Hub) *Handler { return &Handler{hub: hub} }
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{
InsecureSkipVerify: false,
})
if err != nil {
return
}
topics := parseTopics(r.URL.Query().Get("topics"))
_ = h.hub.Serve(r.Context(), c, topics)
}
func parseTopics(raw string) []events.Topic {
if raw == "" {
return events.AllTopics()
}
parts := strings.Split(raw, ",")
out := make([]events.Topic, 0, len(parts))
for _, p := range parts {
t := events.Topic(strings.TrimSpace(p))
if t.IsValid() {
out = append(out, t)
}
}
if len(out) == 0 {
return events.AllTopics()
}
return out
}

View File

@ -0,0 +1,139 @@
// ©AngelaMos | 2026
// hub.go
package ws
import (
"context"
"log/slog"
"sync"
"time"
cdrws "github.com/coder/websocket"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
)
const (
defaultSubscriberBuf = 16
defaultPingInterval = 30 * time.Second
defaultPingTimeout = 10 * time.Second
defaultWriteTimeout = 5 * time.Second
)
type HubConfig struct {
SubscriberBufferSize int
PingInterval time.Duration
PingTimeout time.Duration
WriteTimeout time.Duration
Logger *slog.Logger
}
type Hub struct {
mu sync.Mutex
subs map[*subscriber]struct{}
bufSize int
pingInterval time.Duration
pingTimeout time.Duration
writeTimeout time.Duration
logger *slog.Logger
}
func NewHub(cfg HubConfig) *Hub {
if cfg.SubscriberBufferSize <= 0 {
cfg.SubscriberBufferSize = defaultSubscriberBuf
}
if cfg.PingInterval <= 0 {
cfg.PingInterval = defaultPingInterval
}
if cfg.PingTimeout <= 0 {
cfg.PingTimeout = defaultPingTimeout
}
if cfg.WriteTimeout <= 0 {
cfg.WriteTimeout = defaultWriteTimeout
}
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Hub{
subs: make(map[*subscriber]struct{}),
bufSize: cfg.SubscriberBufferSize,
pingInterval: cfg.PingInterval,
pingTimeout: cfg.PingTimeout,
writeTimeout: cfg.WriteTimeout,
logger: logger,
}
}
func (h *Hub) Broadcast(topic events.Topic, payload []byte) {
env, err := EncodeEnvelope(string(topic), payload)
if err != nil {
h.logger.Error("encode envelope", "err", err, "topic", topic)
return
}
h.mu.Lock()
defer h.mu.Unlock()
for sub := range h.subs {
if _, ok := sub.topics[topic]; !ok {
continue
}
select {
case sub.msgs <- env:
default:
go sub.closeSlow()
}
}
}
func (h *Hub) Serve(ctx context.Context, c *cdrws.Conn, topics []events.Topic) error {
sub := newSubscriber(topics, h.bufSize, func() {
_ = c.Close(cdrws.StatusPolicyViolation, "slow consumer")
})
h.add(sub)
defer h.remove(sub)
connCtx := c.CloseRead(ctx)
pingT := time.NewTicker(h.pingInterval)
defer pingT.Stop()
for {
select {
case msg := <-sub.msgs:
wctx, cancel := context.WithTimeout(connCtx, h.writeTimeout)
err := c.Write(wctx, cdrws.MessageText, msg)
cancel()
if err != nil {
return err
}
case <-pingT.C:
pctx, cancel := context.WithTimeout(connCtx, h.pingTimeout)
err := c.Ping(pctx)
cancel()
if err != nil {
return err
}
case <-connCtx.Done():
_ = c.Close(cdrws.StatusNormalClosure, "")
return connCtx.Err()
}
}
}
func (h *Hub) add(sub *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
h.subs[sub] = struct{}{}
}
func (h *Hub) remove(sub *subscriber) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.subs, sub)
}
func (h *Hub) SubscriberCount() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs)
}

View File

@ -0,0 +1,123 @@
// ©AngelaMos | 2026
// hub_test.go
package ws_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
cdrws "github.com/coder/websocket"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/ws"
)
func TestHub_SubscribeAndReceive(t *testing.T) {
hub := ws.NewHub(ws.HubConfig{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true})
require.NoError(t, err)
_ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat})
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
conn, _, err := cdrws.Dial(ctx, wsURL, nil)
require.NoError(t, err)
defer func() { _ = conn.CloseNow() }()
require.Eventually(t, func() bool {
return hub.SubscriberCount() == 1
}, time.Second, 10*time.Millisecond)
hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"x"}`))
_, msg, err := conn.Read(ctx)
require.NoError(t, err)
require.Contains(t, string(msg), `"ch":"heartbeat"`)
}
func TestHub_TopicFiltering(t *testing.T) {
hub := ws.NewHub(ws.HubConfig{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true})
require.NoError(t, err)
_ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat})
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
conn, _, err := cdrws.Dial(ctx, wsURL, nil)
require.NoError(t, err)
defer func() { _ = conn.CloseNow() }()
require.Eventually(t, func() bool {
return hub.SubscriberCount() == 1
}, time.Second, 10*time.Millisecond)
hub.Broadcast(events.TopicCVENew, []byte(`{"id":"CVE-2026-0001"}`))
hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"y"}`))
readCtx, readCancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer readCancel()
_, msg, err := conn.Read(readCtx)
require.NoError(t, err)
require.Contains(t, string(msg), `"ch":"heartbeat"`)
require.NotContains(t, string(msg), "CVE-2026-0001")
}
func TestHub_SlowConsumerClosed(t *testing.T) {
hub := ws.NewHub(ws.HubConfig{SubscriberBufferSize: 2})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true})
require.NoError(t, err)
_ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat})
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
conn, _, err := cdrws.Dial(ctx, wsURL, nil)
require.NoError(t, err)
defer func() { _ = conn.CloseNow() }()
require.Eventually(t, func() bool {
return hub.SubscriberCount() == 1
}, time.Second, 10*time.Millisecond)
for i := 0; i < 200; i++ {
hub.Broadcast(events.TopicHeartbeat, []byte(`{"i":1}`))
}
var ce cdrws.CloseError
for {
_, _, err = conn.Read(ctx)
if err == nil {
continue
}
if errors.As(err, &ce) {
break
}
t.Fatalf("expected close error, got %v", err)
}
require.Equal(t, cdrws.StatusPolicyViolation, ce.Code)
}

View File

@ -0,0 +1,24 @@
// ©AngelaMos | 2026
// subscriber.go
package ws
import "github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
type subscriber struct {
topics map[events.Topic]struct{}
msgs chan []byte
closeSlow func()
}
func newSubscriber(topics []events.Topic, bufSize int, closeSlow func()) *subscriber {
t := make(map[events.Topic]struct{}, len(topics))
for _, top := range topics {
t[top] = struct{}{}
}
return &subscriber{
topics: t,
msgs: make(chan []byte, bufSize),
closeSlow: closeSlow,
}
}