Stats API

This commit is contained in:
Daniel Ponte 2025-02-14 16:20:44 -05:00
parent e9d1da92a0
commit d3702b375b
5 changed files with 120 additions and 2 deletions

View file

@ -47,6 +47,9 @@ var Policy = &restrict.PolicyDefinition{
&restrict.Permission{Preset: PresetUpdateOwn},
&restrict.Permission{Preset: PresetDeleteOwn},
},
entities.ResourceCallStats: {
&restrict.Permission{Action: entities.ActionRead},
},
},
},
entities.RoleSubmitter: {

View file

@ -6,6 +6,7 @@ import (
"net/url"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/rbac"
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
@ -165,6 +166,7 @@ var statusMapping = map[error]errResponder{
shares.ErrNoShare: notFoundErrText,
ErrBadShare: notFoundErrText,
shares.ErrBadType: badRequestErrText,
calls.ErrInvalidInterval: badRequestErrText,
}
func autoError(err error) render.Renderer {

View file

@ -10,8 +10,10 @@ import (
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/forms"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/database"
"dynatron.me/x/stillbox/pkg/stats"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@ -34,6 +36,7 @@ func (ca *callsAPI) Subrouter() http.Handler {
r.Get(`/{call:[a-f0-9-]+}`, ca.getAudioRoute)
r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudioRoute)
r.Post(`/`, ca.listCalls)
r.Get(`/stats/{interval}`, ca.getCallStats)
return r
}
@ -55,6 +58,29 @@ func (ca *callsAPI) getAudioRoute(w http.ResponseWriter, r *http.Request) {
ca.getAudio(p, w, r)
}
func (ca *callsAPI) getCallStats(w http.ResponseWriter, r *http.Request) {
p := struct {
Interval calls.StatsInterval `param:"interval"`
}{}
err := decodeParams(&p, r)
if err != nil {
wErr(w, r, badRequest(err))
return
}
ctx := r.Context()
sts := stats.FromCtx(ctx)
st, err := sts.GetCallStats(ctx, p.Interval)
if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, st)
}
func (ca *callsAPI) getAudio(p getAudioParams, w http.ResponseWriter, r *http.Request) {
if p.CallID == nil {
wErr(w, r, badRequest(ErrNoCall))

View file

@ -23,6 +23,7 @@ import (
"dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources"
"dynatron.me/x/stillbox/pkg/stats"
"dynatron.me/x/stillbox/pkg/talkgroups/tgstore"
"dynatron.me/x/stillbox/pkg/users"
@ -55,6 +56,7 @@ type Server struct {
incidents incstore.Store
share shares.Service
rbac rbac.RBAC
stats stats.Stats
}
func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
@ -80,13 +82,16 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
}
tgCache := tgstore.NewCache(db)
api := rest.New(cfg.BaseURL.URL())
rbacSvc, err := rbac.New(policy.Policy)
if err != nil {
return nil, err
}
callStore := callstore.NewStore(db)
statsSvc := stats.NewStats(callStore, stats.DefaultExpiration)
api := rest.New(cfg.BaseURL.URL())
srv := &Server{
auth: authenticator,
conf: cfg,
@ -101,9 +106,10 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) {
rest: api,
share: shares.NewService(),
users: ust,
calls: callstore.NewStore(db),
calls: callStore,
incidents: incstore.NewStore(),
rbac: rbacSvc,
stats: statsSvc,
}
if cfg.DB.Partition.Enabled {
@ -169,6 +175,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context {
ctx = incstore.CtxWithStore(ctx, s.incidents)
ctx = shares.CtxWithStore(ctx, s.share)
ctx = rbac.CtxWithRBAC(ctx, s.rbac)
ctx = stats.CtxWithStats(ctx, s.stats)
return ctx
}

80
pkg/stats/stats.go Normal file
View file

@ -0,0 +1,80 @@
package stats
import (
"context"
"time"
"dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/calls/callstore"
"dynatron.me/x/stillbox/pkg/services"
)
const DefaultExpiration = 5 * time.Minute
type Stats interface {
GetCallStats(ctx context.Context, interval calls.StatsInterval) (*calls.Stats, error)
}
type statsKeyType string
const StatsKey statsKeyType = "stats"
func CtxWithStats(ctx context.Context, s Stats) context.Context {
return services.WithValue(ctx, StatsKey, s)
}
func FromCtx(ctx context.Context) Stats {
s, ok := services.Value(ctx, StatsKey).(Stats)
if !ok {
panic("no stats in context")
}
return s
}
type stats struct {
cs callstore.Store
sc cache.Cache[calls.StatsInterval, *calls.Stats]
}
func NewStats(cst callstore.Store, cacheExp time.Duration) *stats {
s := &stats{
cs: cst,
sc: cache.New[calls.StatsInterval, *calls.Stats](cache.WithExpiration(cacheExp)),
}
return s
}
func (s *stats) GetCallStats(ctx context.Context, interval calls.StatsInterval) (*calls.Stats, error) {
st, has := s.sc.Get(interval)
if has {
return st, nil
}
var start time.Time
now := time.Now()
switch interval {
case calls.IntervalHour:
start = now.Add(-24 * time.Hour) // one day
case calls.IntervalDay:
start = now.Add(-7 * 24 * time.Hour) // one week
case calls.IntervalWeek:
start = now.Add(-30 * 24 * time.Hour) // one month
case calls.IntervalMonth:
start = now.Add(-365 * 24 * time.Hour) // one year
default:
return nil, calls.ErrInvalidInterval
}
st, err := s.cs.CallStats(ctx, interval, jsontypes.Time(start), jsontypes.Time(now))
if err != nil {
return nil, err
}
s.sc.Set(interval, st)
return st, nil
}