spaxel/mothership/internal/analytics/handler.go
jedarden a97960bf67 fix: resolve analytics API test failures and improve corridor response format
- Fix TestAnalyticsHandler_ErrorHandling to use proper in-memory database
  instead of nil database which caused nil pointer dereference
- Update handleGetCorridors to return corridors wrapped in {corridors: [...]}
  for consistency with frontend expectations from crowdflow.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:34:24 -04:00

319 lines
9 KiB
Go

package analytics
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
// Handler provides REST API handlers for analytics.
type Handler struct {
accumulator *FlowAccumulator
}
// NewHandler creates a new analytics handler.
func NewHandler(accumulator *FlowAccumulator) *Handler {
return &Handler{accumulator: accumulator}
}
// RegisterRoutes registers analytics API routes on the given router.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/api/analytics/flow", h.handleGetFlow)
r.Get("/api/analytics/dwell", h.handleGetDwell)
r.Get("/api/analytics/corridors", h.handleGetCorridors)
}
// handleGetFlow returns the flow map.
// Query params:
// - person_id: filter by person (optional)
// - since: Unix timestamp (optional, default 30 days ago)
// - until: Unix timestamp (optional, default now)
func (h *Handler) handleGetFlow(w http.ResponseWriter, r *http.Request) {
if h.accumulator == nil {
http.Error(w, "analytics not available", http.StatusServiceUnavailable)
return
}
personID := r.URL.Query().Get("person_id")
// Parse time range
var since, until time.Time
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
if sinceUnix, err := strconv.ParseInt(sinceStr, 10, 64); err == nil {
since = time.Unix(sinceUnix, 0)
}
}
if untilStr := r.URL.Query().Get("until"); untilStr != "" {
if untilUnix, err := strconv.ParseInt(untilStr, 10, 64); err == nil {
until = time.Unix(untilUnix, 0)
}
}
// Default time range: last 30 days
if since.IsZero() {
since = time.Now().AddDate(0, 0, -30)
}
if until.IsZero() {
until = time.Now()
}
var personIDPtr *string
if personID != "" {
personIDPtr = &personID
}
flowMap, err := h.accumulator.ComputeFlowMap(personIDPtr, &since, &until)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, flowMap)
}
// handleGetDwell returns the dwell heatmap.
// Query params:
// - person_id: filter by person (optional)
func (h *Handler) handleGetDwell(w http.ResponseWriter, r *http.Request) {
if h.accumulator == nil {
http.Error(w, "analytics not available", http.StatusServiceUnavailable)
return
}
personID := r.URL.Query().Get("person_id")
var personIDPtr *string
if personID != "" {
personIDPtr = &personID
}
heatmap, err := h.accumulator.ComputeDwellHeatmap(personIDPtr)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, heatmap)
}
// handleGetCorridors returns detected corridors.
func (h *Handler) handleGetCorridors(w http.ResponseWriter, r *http.Request) {
if h.accumulator == nil {
http.Error(w, "analytics not available", http.StatusServiceUnavailable)
return
}
corridors, err := h.accumulator.GetCorridors()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return in format expected by frontend: {corridors: [...]}
writeJSON(w, map[string]interface{}{"corridors": corridors})
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// AnomalyHandler provides REST API handlers for anomaly detection.
type AnomalyHandler struct {
detector *Detector
}
// NewAnomalyHandler creates a new anomaly handler.
func NewAnomalyHandler(detector *Detector) *AnomalyHandler {
return &AnomalyHandler{detector: detector}
}
// RegisterRoutes registers anomaly API routes on the given router.
func (h *AnomalyHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/anomalies", h.handleGetAnomalies)
r.Get("/api/anomalies/active", h.handleGetActiveAnomalies)
r.Get("/api/anomalies/history", h.handleGetHistory)
r.Post("/api/anomalies/{id}/acknowledge", h.handleAcknowledge)
r.Get("/api/anomalies/summary", h.handleGetSummary)
r.Get("/api/anomalies/learning", h.handleGetLearningProgress)
r.Post("/api/anomalies/model/update", h.handleUpdateModel)
}
// handleGetAnomalies returns anomalies filtered by the `since` query parameter.
// Query params:
// - since: duration string (e.g. "24h", "7d", "1h"). Default "24h".
// Uses DB-backed QueryAnomalyEvents so results survive server restarts.
func (h *AnomalyHandler) handleGetAnomalies(w http.ResponseWriter, r *http.Request) {
if h.detector == nil {
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
return
}
active := h.detector.GetActiveAnomalies()
// Parse since duration
sinceStr := r.URL.Query().Get("since")
if sinceStr == "" {
sinceStr = "24h"
}
sinceDur, err := time.ParseDuration(sinceStr)
if err != nil {
http.Error(w, "invalid since duration: "+err.Error(), http.StatusBadRequest)
return
}
// Use DB-backed query so results persist across restarts
cutoff := time.Now().Add(-sinceDur)
history, err := h.detector.QueryAnomalyEvents(cutoff, 1000)
if err != nil {
http.Error(w, "failed to query anomalies: "+err.Error(), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"active": active,
"history": history,
"since": sinceStr,
}
writeJSON(w, response)
}
// handleGetActiveAnomalies returns only active (unacknowledged) anomalies.
func (h *AnomalyHandler) handleGetActiveAnomalies(w http.ResponseWriter, r *http.Request) {
if h.detector == nil {
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
return
}
active := h.detector.GetActiveAnomalies()
writeJSON(w, active)
}
// handleGetHistory returns anomaly history.
func (h *AnomalyHandler) handleGetHistory(w http.ResponseWriter, r *http.Request) {
if h.detector == nil {
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
}
}
history := h.detector.GetAnomalyHistory(limit)
writeJSON(w, history)
}
// handleAcknowledge acknowledges an anomaly.
func (h *AnomalyHandler) handleAcknowledge(w http.ResponseWriter, r *http.Request) {
if h.detector == nil {
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
return
}
anomalyID := chi.URLParam(r, "id")
var req struct {
Feedback string `json:"feedback"`
By string `json:"acknowledged_by"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.detector.AcknowledgeAnomaly(anomalyID, req.Feedback, req.By); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]string{"status": "acknowledged"})
}
// handleGetSummary returns the weekly anomaly summary.
func (h *AnomalyHandler) handleGetSummary(w http.ResponseWriter, r *http.Request) {
if h.detector == nil {
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
return
}
summary := h.detector.GetWeeklySummary()
writeJSON(w, summary)
}
// handleGetLearningProgress returns the learning progress.
func (h *AnomalyHandler) handleGetLearningProgress(w http.ResponseWriter, r *http.Request) {
if h.detector == nil {
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
return
}
progress := h.detector.GetLearningProgress()
ready := h.detector.IsModelReady()
response := map[string]interface{}{
"progress": progress,
"model_ready": ready,
"days_learned": int(progress * 7),
"days_remaining": int((1 - progress) * 7),
}
writeJSON(w, response)
}
// handleUpdateModel triggers a behaviour model update.
func (h *AnomalyHandler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
if h.detector == nil {
http.Error(w, "anomaly detector not available", http.StatusServiceUnavailable)
return
}
if err := h.detector.UpdateBehaviourModel(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "updated"})
}
// PatternHandler provides REST API handlers for the Welford pattern learner.
type PatternHandler struct {
learner *PatternLearner
}
// NewPatternHandler creates a new pattern handler.
func NewPatternHandler(learner *PatternLearner) *PatternHandler {
return &PatternHandler{learner: learner}
}
// RegisterRoutes registers pattern API routes on the given router.
func (h *PatternHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/anomaly_patterns", h.handleGetPatterns)
}
// handleGetPatterns returns pattern model data for debugging.
// Query params:
// - zone: filter by zone_id (string). If omitted, returns all patterns.
func (h *PatternHandler) handleGetPatterns(w http.ResponseWriter, r *http.Request) {
if h.learner == nil {
http.Error(w, "pattern learner not available", http.StatusServiceUnavailable)
return
}
zoneID := r.URL.Query().Get("zone")
patterns := h.learner.GetPatterns(zoneID)
response := map[string]interface{}{
"cold_start": h.learner.IsColdStart(),
"patterns": patterns,
"count": len(patterns),
}
writeJSON(w, response)
}