diff --git a/pkg/rbac/policy/policy.go b/pkg/rbac/policy/policy.go index 6cf50c1..1a34780 100644 --- a/pkg/rbac/policy/policy.go +++ b/pkg/rbac/policy/policy.go @@ -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: { diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 6fc0fd8..6167393 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -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 { diff --git a/pkg/rest/calls.go b/pkg/rest/calls.go index cdbc5bf..01478cc 100644 --- a/pkg/rest/calls.go +++ b/pkg/rest/calls.go @@ -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)) diff --git a/pkg/server/server.go b/pkg/server/server.go index e8a5803..ec8d07b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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 } diff --git a/pkg/stats/stats.go b/pkg/stats/stats.go new file mode 100644 index 0000000..b1e03bd --- /dev/null +++ b/pkg/stats/stats.go @@ -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 +}