Add talkgroup activity alerting #17

Merged
amigan merged 11 commits from alerting into trunk 2024-10-31 00:20:48 -04:00
14 changed files with 458 additions and 82 deletions
Showing only changes of commit c39613b2be - Show all commits

View file

@ -4,12 +4,15 @@ BUILDDATE!=date '+%Y-%m-%e'
LDFLAGS=-ldflags="-X '${VPKG}.Version=${VER}' -X '${VPKG}.Built=${BUILDDATE}'"
all: checkcalls
go build -o gordio ${LDFLAGS} ./cmd/gordio/
go build -o calls ${LDFLAGS} ./cmd/calls/
go build -o gordio ${GOFLAGS} ${LDFLAGS} ./cmd/gordio/
go build -o calls ${GOFLAGS} ${LDFLAGS} ./cmd/calls/
buildpprof:
go build -o gordio-pprof ${GOFLAGS} ${LDFLAGS} -tags pprof ./cmd/gordio
clean:
rm -rf client/calls/ && mkdir client/calls && touch client/calls/.gitkeep
rm -f gordio calls
rm -f gordio calls gordio-pprof
checkcalls:
@test -e client/calls/index.html || make getcalls

25
go.mod
View file

@ -1,38 +1,39 @@
module dynatron.me/x/stillbox
go 1.22.5
go 1.23.2
require (
dynatron.me/x/go-minimp3 v0.0.0-20240805171536-7ea857e216d6
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/go-audio/wav v1.1.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.9.0
github.com/go-chi/jwtauth/v5 v5.3.1
github.com/go-chi/render v1.0.3
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.4.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/oto v1.0.1
github.com/jackc/pgx/v5 v5.6.0
github.com/rs/zerolog v1.33.0
github.com/spf13/cobra v1.8.1
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
golang.org/x/crypto v0.21.0
golang.org/x/sync v0.5.0
golang.org/x/term v0.18.0
google.golang.org/protobuf v1.33.0
golang.org/x/crypto v0.28.0
golang.org/x/sync v0.8.0
golang.org/x/term v0.25.0
google.golang.org/protobuf v1.35.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/ajg/form v1.5.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/go-audio/audio v1.0.0 // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@ -48,14 +49,16 @@ require (
github.com/lestrrat-go/jwx/v2 v2.0.20 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.9.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
)

64
go.sum
View file

@ -6,8 +6,10 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -32,8 +34,6 @@ github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
@ -51,10 +51,10 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/oto v1.0.1 h1:8AMnq0Yr2YmzaiqTg/k1Yzd6IygUGk2we9nmjgbgPn4=
@ -97,8 +97,10 @@ github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
@ -111,12 +113,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
@ -128,14 +132,14 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56 h1:8jM66xzUJjNInw31Y8bic4AYSLVChztDRT93+kmofUY=
golang.org/x/exp/shiny v0.0.0-20240719175910-8a7402abbf56/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
@ -145,30 +149,28 @@ golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM=
golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -0,0 +1,107 @@
package jsontime
import (
"encoding/json"
"strings"
"time"
"github.com/araddon/dateparse"
"gopkg.in/yaml.v3"
)
type Time time.Time
func (t *Time) UnmarshalYAML(n *yaml.Node) error {
var s string
err := n.Decode(&s)
if err != nil {
return err
}
tm, err := dateparse.ParseAny(s)
if err != nil {
return err
}
*t = Time(tm)
return nil
}
func (t *Time) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
tm, err := dateparse.ParseAny(s)
if err != nil {
return err
}
*t = Time(tm)
return nil
}
func (t Time) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Time(t))
}
func (t Time) String() string {
return time.Time(t).String()
}
func (t Time) Time() time.Time {
return time.Time(t)
}
type Duration time.Duration
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
var s string
err := n.Decode(&s)
if err != nil {
return err
}
dur, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(dur)
return nil
}
func (d *Duration) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
dur, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(dur)
return nil
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d))
}
func (d Duration) Duration() time.Duration {
return time.Duration(d)
}
func (d Duration) String() string {
return time.Duration(d).String()
}
func ParseDuration(s string) (Duration, error) {
d, err := time.ParseDuration(s)
return Duration(d), err
}
func ParseAny(s string, opt ...dateparse.ParserOption) (Time, error) {
t, err := dateparse.ParseAny(s, opt...)
return Time(t), err
}
func ParseInLocal(s string, opt ...dateparse.ParserOption) (Time, error) {
t, err := dateparse.ParseIn(s, time.Now().Location(), opt...)
return Time(t), err
}

View file

@ -1,8 +1,9 @@
package timeseries
import (
"log"
"time"
"github.com/rs/zerolog/log"
)
type level struct {
@ -53,7 +54,7 @@ func (l *level) increaseAtTime(amount int, time time.Time) {
if difference < 0 {
// this cannot be negative because we advance before
// can at least be 0
log.Println("level.increaseTime was called with a time in the future")
log.Error().Time("time", time).Msg("level.increaseTime was called with a time in the future")
}
// l.length-1 because the newest element is always l.length-1 away from oldest
steps := (l.length - 1) - int(difference/l.granularity)

View file

@ -9,13 +9,6 @@ type Clock interface {
Now() time.Time
}
// defaultClock is used in case no clock is provided to the constructor.
type defaultClock struct{}
func (c *defaultClock) Now() time.Time {
return time.Now()
}
type slidingWindow struct {
buffer []float64
length int
@ -68,7 +61,7 @@ func NewSlidingWindow(os ...option) *slidingWindow {
o(&opts)
}
if opts.clock == nil {
opts.clock = &defaultClock{}
panic("clock not set")
}
if opts.step.Nanoseconds() == 0 {
opts.step = defaultStep

View file

@ -39,10 +39,9 @@ type alerter struct {
scorer trending.Scorer[cl.Talkgroup]
scores trending.Scores[cl.Talkgroup]
lastScore time.Time
sim *Simulation
}
type noopAlerter struct{}
type offsetClock time.Duration
func (c *offsetClock) Now() time.Time {
@ -53,18 +52,21 @@ func (c *offsetClock) Duration() time.Duration {
return time.Duration(*c)
}
// OffsetClock returns a clock whose Now() method returns the specified offset from the current time.
func OffsetClock(d time.Duration) offsetClock {
return offsetClock(d)
}
type AlertOption func(*alerter)
// WithClock makes the alerter use a simulated clock.
func WithClock(clock timeseries.Clock) AlertOption {
return func(as *alerter) {
as.clock = clock
}
}
// New creates a new Alerter using the provided configuration.
func New(cfg config.Alerting, opts ...AlertOption) Alerter {
if !cfg.Enable {
return &noopAlerter{}
@ -82,8 +84,8 @@ func New(cfg config.Alerting, opts ...AlertOption) Alerter {
as.scorer = trending.NewScorer[cl.Talkgroup](
trending.WithTimeSeries(as.newTimeSeries),
trending.WithStorageDuration[cl.Talkgroup](time.Hour*24*time.Duration(cfg.LookbackDays)),
trending.WithRecentDuration[cl.Talkgroup](cfg.Recent),
trending.WithHalfLife[cl.Talkgroup](cfg.HalfLife),
trending.WithRecentDuration[cl.Talkgroup](time.Duration(cfg.Recent)),
trending.WithHalfLife[cl.Talkgroup](time.Duration(cfg.HalfLife)),
trending.WithScoreThreshold[cl.Talkgroup](ScoreThreshold),
trending.WithCountThreshold[cl.Talkgroup](CountThreshold),
trending.WithClock[cl.Talkgroup](as.clock),
@ -92,6 +94,7 @@ func New(cfg config.Alerting, opts ...AlertOption) Alerter {
return as
}
// Go is the alerting loop. It does not start a goroutine.
func (as *alerter) Go(ctx context.Context) {
as.startBackfill(ctx)
@ -126,12 +129,12 @@ func (as *alerter) startBackfill(ctx context.Context) {
now := time.Now()
since := now.Add(-24 * time.Hour * time.Duration(as.cfg.LookbackDays))
log.Debug().Time("since", since).Msg("starting stats backfill")
count, err := as.backfill(ctx, since)
count, err := as.backfill(ctx, since, now)
if err != nil {
log.Error().Err(err).Msg("backfill failed")
return
}
log.Debug().Int("count", count).Str("in", time.Now().Sub(now).String()).Int("len", as.scorer.Score().Len()).Msg("backfill finished")
log.Debug().Int("callsCount", count).Str("in", time.Now().Sub(now).String()).Int("tgCount", as.scorer.Score().Len()).Msg("backfill finished")
}
func (as *alerter) score(ctx context.Context, now time.Time) {
@ -143,11 +146,11 @@ func (as *alerter) score(ctx context.Context, now time.Time) {
sort.Sort(as.scores)
}
func (as *alerter) backfill(ctx context.Context, since time.Time) (count int, err error) {
func (as *alerter) backfill(ctx context.Context, since time.Time, until time.Time) (count int, err error) {
db := database.FromCtx(ctx)
const backfillStatsQuery = `SELECT system, talkgroup, call_date FROM calls WHERE call_date > $1 AND call_date < $2`
const backfillStatsQuery = `SELECT system, talkgroup, call_date FROM calls WHERE call_date > $1 AND call_date < $2 ORDER BY call_date ASC`
rows, err := db.Query(ctx, backfillStatsQuery, since, timeseries.DefaultClock.Now())
rows, err := db.Query(ctx, backfillStatsQuery, since, until)
if err != nil {
return count, err
}
@ -163,6 +166,9 @@ func (as *alerter) backfill(ctx context.Context, since time.Time) (count int, er
return count, err
}
as.scorer.AddEvent(tg, callDate)
if as.sim != nil { // step the simulator if it is active
as.sim.stepClock(callDate)
}
count++
}
@ -187,6 +193,9 @@ func (as *alerter) Call(ctx context.Context, call *cl.Call) error {
func (*alerter) Enabled() bool { return true }
// noopAlerter is used when alerting is disabled.
type noopAlerter struct{}
func (*noopAlerter) SinkType() string { return "noopAlerter" }
func (*noopAlerter) Call(_ context.Context, _ *cl.Call) error { return nil }
func (*noopAlerter) Go(_ context.Context) {}

View file

@ -0,0 +1,172 @@
package alerting
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"time"
"dynatron.me/x/stillbox/internal/jsontime"
"dynatron.me/x/stillbox/internal/trending"
cl "dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/gordio/config"
"github.com/rs/zerolog/log"
)
// A Simulation simulates what happens to the alerter during a specified time
// period using past data from the database.
type Simulation struct {
// normal Alerting config
config.Alerting
// ScoreStart is the time when scoring begins
ScoreStart jsontime.Time `json:"scoreStart"`
// ScoreEnd is the time when the score simulator ends. Left blank, it defaults to time.Now()
ScoreEnd jsontime.Time `json:"scoreEnd"`
// SimInterval is the interval at which the scorer will be called
SimInterval jsontime.Duration `json:"simInterval"`
clock offsetClock `json:"-"`
*alerter `json:"-"`
}
func (s *Simulation) verify() error {
switch {
case !s.ScoreEnd.Time().IsZero() && s.ScoreStart.Time().After(s.ScoreEnd.Time()):
return errors.New("end is before start")
case s.LookbackDays > 14:
return errors.New("lookback days >14")
}
return nil
}
// stepClock is called by backfill during simulation operations.
func (s *Simulation) stepClock(t time.Time) {
now := s.clock.Now()
step := t.Sub(s.lastScore)
if step > time.Duration(s.SimInterval) {
s.clock += offsetClock(s.SimInterval)
s.scores = s.scorer.Score()
s.lastScore = now
}
}
// Simulate begins the simulation using the DB handle from ctx. It returns final scores.
func (s *Simulation) Simulate(ctx context.Context) trending.Scores[cl.Talkgroup] {
now := time.Now()
s.Enable = true
s.alerter = New(s.Alerting, WithClock(&s.clock)).(*alerter)
if time.Time(s.ScoreEnd).IsZero() {
s.ScoreEnd = jsontime.Time(now)
}
log.Debug().Time("scoreStart", s.ScoreStart.Time()).
Time("scoreEnd", s.ScoreEnd.Time()).
Str("interval", s.SimInterval.String()).
Uint("lookbackDays", s.LookbackDays).
Msg("simulation start")
scoreEnd := time.Time(s.ScoreEnd)
// compute lookback start Time
sinceLookback := time.Time(scoreEnd).Add(-24 * time.Hour * time.Duration(s.LookbackDays))
// backfill from lookback start until score start
s.backfill(ctx, sinceLookback, time.Time(s.ScoreStart))
// initial score
s.scores = s.scorer.Score()
s.lastScore = time.Time(s.ScoreStart)
ssT := time.Time(s.ScoreStart)
nowDiff := now.Sub(time.Time(ssT))
// and set the clock offset to it
s.clock -= offsetClock(nowDiff)
// turn on sim mode
s.alerter.sim = s
// compute time since score start until now
// backfill from scorestart until now. sim is enabled, so scoring will be done by stepClock()
s.backfill(ctx, time.Time(s.ScoreStart), scoreEnd)
s.lastScore = scoreEnd
sort.Sort(s.scores)
return s.scores
}
// simulateHandler is the POST endpoint handler.
func (as *alerter) simulateHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s := new(Simulation)
switch r.Header.Get("Content-Type") {
case "application/json":
err := json.NewDecoder(r.Body).Decode(s)
if err != nil {
err = fmt.Errorf("simulate decode: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
default:
err := r.ParseForm()
if err != nil {
err = fmt.Errorf("simulate form parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
lbd, err := strconv.Atoi(r.Form["lookbackDays"][0])
if err != nil {
err = fmt.Errorf("lookbackDays parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.LookbackDays = uint(lbd)
s.HalfLife, err = jsontime.ParseDuration(r.Form["halfLife"][0])
if err != nil {
err = fmt.Errorf("halfLife parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.Recent, err = jsontime.ParseDuration(r.Form["recent"][0])
if err != nil {
err = fmt.Errorf("recent parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.SimInterval, err = jsontime.ParseDuration(r.Form["simInterval"][0])
if err != nil {
err = fmt.Errorf("simInterval parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.ScoreStart, err = jsontime.ParseInLocal(r.Form["scoreStart"][0])
if err != nil {
err = fmt.Errorf("scoreStart parse: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.ScoreEnd, err = jsontime.ParseInLocal(r.Form["scoreEnd"][0])
if err != nil {
s.ScoreEnd = jsontime.Time{}
}
}
err := s.verify()
if err != nil {
err = fmt.Errorf("simulation profile verify: %w", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.Simulate(ctx)
s.tgStatsHandler(w, r)
}

View file

@ -2,14 +2,18 @@ package alerting
import (
_ "embed"
"errors"
"fmt"
"html/template"
"net/http"
"strconv"
"time"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/gordio/config"
"dynatron.me/x/stillbox/pkg/gordio/database"
"dynatron.me/x/stillbox/internal/jsontime"
"dynatron.me/x/stillbox/internal/trending"
"github.com/go-chi/chi/v5"
@ -25,20 +29,48 @@ type stats interface {
var (
funcMap = template.FuncMap{
"f": func(v float64) string {
"f": func(v float64, places ...int) string {
if len(places) > 0 {
return fmt.Sprintf("%."+strconv.Itoa(places[0])+"f", v)
}
return fmt.Sprintf("%.4f", v)
},
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
"formTime": func(t jsontime.Time) string {
return time.Time(t).Format("2006-01-02T15:04")
},
"ago": func(s string) (string, error) {
d, err := time.ParseDuration(s)
if err != nil {
return "", err
}
return time.Now().Add(-d).Format("2006-01-02T15:04"), nil
},
}
statTmpl = template.Must(template.New("stats").Funcs(funcMap).Parse(statsTemplateFile))
)
func (as *alerter) PrivateRoutes(r chi.Router) {
r.Get("/tgstats", as.tgStats)
r.Get("/tgstats", as.tgStatsHandler)
r.Post("/tgstats", as.simulateHandler)
}
func (as *noopAlerter) PrivateRoutes(r chi.Router) {}
func (as *alerter) tgStats(w http.ResponseWriter, r *http.Request) {
func (as *alerter) tgStatsHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
db := database.FromCtx(ctx)
@ -60,13 +92,17 @@ func (as *alerter) tgStats(w http.ResponseWriter, r *http.Request) {
}
renderData := struct {
TGs map[calls.Talkgroup]database.GetTalkgroupsByPackedIDsRow
Scores trending.Scores[calls.Talkgroup]
LastScore time.Time
TGs map[calls.Talkgroup]database.GetTalkgroupsByPackedIDsRow
Scores trending.Scores[calls.Talkgroup]
LastScore time.Time
Simulation *Simulation
Config config.Alerting
}{
TGs: tgMap,
Scores: as.scores,
LastScore: as.lastScore,
TGs: tgMap,
Scores: as.scores,
LastScore: as.lastScore,
Config: as.cfg,
Simulation: as.sim,
}
w.WriteHeader(http.StatusOK)

View file

@ -12,7 +12,7 @@
background: #105469;
font-family: sans-serif;
}
table {
table, #simform {
background: #012B39;
border-radius: 0.25em;
border-collapse: collapse;
@ -29,7 +29,7 @@
padding: 0.5em 1em;
text-align: left;
}
td {
td, #simform {
color: #fff;
font-weight: 400;
padding: 0.65em 1em;
@ -46,7 +46,30 @@
</style>
</head>
<body>
<div id="simform">
<form action="/tgstats" method="POST">
{{ define "simform" }}
<label for="lookbackDays">Lookback Days</label> <input id="lookbackDays" name="lookbackDays" type="number" min="1" max="14" value="{{ .Lookback }}" />
<label for="halfLife">Half life</label> <input id="halfLife" name="halfLife" type="text" value="{{ .HalfLife }}" />
<label for="recent">Recent</label> <input id="recent" name="recent" type="text" value="{{ .Recent }}" />
<label for="simInterval">Sim Interval</label> <input id="simInterval" name="simInterval" type="text" value="{{ .SimInterval }}" />
<label for="scoreStart">Score Start</label> <input id="scoreStart" name="scoreStart" type="datetime-local" value="{{ .ScoreStart }}" />
<label for="scoreEnd">Score End</label> <input id="scoreEnd" name="scoreEnd" type="datetime-local" value="{{ .ScoreEnd }}" />
<input type="submit" value="Simulate" />
{{end}}
{{ if .Simulation }}
{{ template "simform" dict "Lookback" .Simulation.LookbackDays "HalfLife" .Simulation.HalfLife "Recent" .Simulation.Recent "ScoreStart" (formTime .Simulation.ScoreStart) "ScoreEnd" (formTime .Simulation.ScoreEnd) "SimInterval" .Simulation.SimInterval }}
{{ else }}
{{ template "simform" dict "Lookback" .Config.LookbackDays "HalfLife" .Config.HalfLife "Recent" .Config.Recent "ScoreStart" (ago "72h") "ScoreEnd" "" "SimInterval" "5m" }}
{{ end }}
</form>
</div>
<table>
{{ if .Simulation }}
<tr>
<td colspan="10">Simulating from {{ formTime .Simulation.ScoreStart }} until {{ formTime .Simulation.ScoreEnd }}</td>
</tr>
{{ end }}
<tr>
<th>System</th>
<th>TG</th>
@ -64,9 +87,9 @@
<tr>
<td>{{ $tg.Name_2}}</td>
<td>{{ $tg.Name}}</td>
<td>{{ .ID.Talkgroup }}</td>
<td>{{ .Count }}</td>
<td>{{ f .RecentCount }}</td>
<td>{{ .ID.Talkgroup }}</td>
<td>{{ f .Count 0 }}</td>
<td>{{ f .RecentCount 0 }}</td>
<td>{{ f .Score }}</td>
<td>{{ f .Probability }}</td>
<td>{{ f .Expectation }}</td>

View file

@ -5,6 +5,8 @@ import (
"sync"
"time"
"dynatron.me/x/stillbox/internal/jsontime"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
@ -51,10 +53,10 @@ type RateLimit struct {
}
type Alerting struct {
Enable bool `yaml:"enable"`
LookbackDays uint `yaml:"lookbackDays"`
HalfLife time.Duration `yaml:"halfLife"`
Recent time.Duration `yaml:"recent"`
Enable bool `yaml:"enable"`
LookbackDays uint `yaml:"lookbackDays"`
HalfLife jsontime.Duration `yaml:"halfLife"`
Recent jsontime.Duration `yaml:"recent"`
}
func (rl *RateLimit) Verify() bool {

View file

@ -0,0 +1,6 @@
//go:build !pprof
// +build !pprof
package server
func (s *Server) installPprof() {}

View file

@ -0,0 +1,17 @@
//go:build pprof
// +build pprof
package server
import (
"net/http/pprof"
)
func (s *Server) installPprof() {
r := s.r
r.HandleFunc("/debug/pprof/", pprof.Index)
r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
r.HandleFunc("/debug/pprof/profile", pprof.Profile)
r.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
r.HandleFunc("/debug/pprof/trace", pprof.Trace)
}

View file

@ -28,6 +28,8 @@ func (s *Server) setupRoutes() {
r := s.r
r.Use(middleware.WithValue(database.DBCTXKeyValue, s.db))
s.installPprof()
r.Group(func(r chi.Router) {
// authenticated routes
r.Use(s.auth.VerifyMiddleware(), s.auth.AuthMiddleware())